1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-31 00:16:47 +01:00

Feat: project default strategy (#3644)

<!-- 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! ❤️ -->

Adds default strategy to project environment link table

## 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 #
[1-876](https://linear.app/unleash/issue/1-876/default-strategy-backend)

<!-- (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-04-28 14:59:04 +03:00 committed by GitHub
parent e378793de5
commit 1ccbbfeb57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 556 additions and 138 deletions

View File

@ -48,7 +48,10 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { IFeatureToggleListItem } from 'interfaces/featureToggle';
import { FavoriteIconHeader } from 'component/common/Table/FavoriteIconHeader/FavoriteIconHeader';
import { FavoriteIconCell } from 'component/common/Table/cells/FavoriteIconCell/FavoriteIconCell';
import { useEnvironmentsRef } from './hooks/useEnvironmentsRef';
import {
ProjectEnvironmentType,
useEnvironmentsRef,
} from './hooks/useEnvironmentsRef';
import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import { FeatureToggleSwitch } from './FeatureToggleSwitch/FeatureToggleSwitch';
import { ActionsCell } from './ActionsCell/ActionsCell';
@ -321,46 +324,53 @@ export const ProjectFeatureToggles = ({
sortType: 'date',
minWidth: 120,
},
...environments.map((name: string) => ({
Header: loading ? () => '' : name,
maxWidth: 90,
id: `environments.${name}`,
accessor: (row: ListItemType) =>
row.environments[name]?.enabled,
align: 'center',
Cell: ({
value,
row: { original: feature },
}: {
value: boolean;
row: { original: ListItemType };
}) => {
const hasWarning =
feature.someEnabledEnvironmentHasVariants &&
feature.environments[name].variantCount === 0 &&
feature.environments[name].enabled;
...environments.map((value: ProjectEnvironmentType | string) => {
const name =
typeof value === 'string'
? value
: (value as ProjectEnvironmentType).environment;
return {
Header: loading ? () => '' : name,
maxWidth: 90,
id: `environments.${name}`,
accessor: (row: ListItemType) =>
row.environments[name]?.enabled,
align: 'center',
Cell: ({
value,
row: { original: feature },
}: {
value: boolean;
row: { original: ListItemType };
}) => {
const hasWarning =
feature.someEnabledEnvironmentHasVariants &&
feature.environments[name].variantCount === 0 &&
feature.environments[name].enabled;
return (
<StyledSwitchContainer hasWarning={hasWarning}>
<FeatureToggleSwitch
value={value}
projectId={projectId}
featureName={feature?.name}
environmentName={name}
onToggle={onToggle}
/>
<ConditionallyRender
condition={hasWarning}
show={<VariantsWarningTooltip />}
/>
</StyledSwitchContainer>
);
},
sortType: 'boolean',
filterName: name,
filterParsing: (value: boolean) =>
value ? 'enabled' : 'disabled',
};
}),
return (
<StyledSwitchContainer hasWarning={hasWarning}>
<FeatureToggleSwitch
value={value}
projectId={projectId}
featureName={feature?.name}
environmentName={name}
onToggle={onToggle}
/>
<ConditionallyRender
condition={hasWarning}
show={<VariantsWarningTooltip />}
/>
</StyledSwitchContainer>
);
},
sortType: 'boolean',
filterName: name,
filterParsing: (value: boolean) =>
value ? 'enabled' : 'disabled',
})),
{
id: 'Actions',
maxWidth: 56,
@ -477,7 +487,6 @@ export const ProjectFeatureToggles = ({
);
const getRowId = useCallback((row: any) => row.name, []);
const {
allColumns,
headerGroups,

View File

@ -1,14 +1,32 @@
import { useRef } from 'react';
import { CreateFeatureStrategySchema } from 'openapi';
/**
* Don't revalidate if array content didn't change.
* Needed for `columns` memo optimization.
*/
export const useEnvironmentsRef = (environments: string[] = []) => {
const ref = useRef<string[]>(environments);
if (environments?.join('') !== ref.current?.join('')) {
ref.current = environments;
export type ProjectEnvironmentType = {
environment: string;
defaultStrategy: CreateFeatureStrategySchema | null;
};
export const useEnvironmentsRef = (
environments: Array<string | ProjectEnvironmentType> = []
): string[] => {
let names: string[];
if (
environments &&
environments.length > 0 &&
typeof environments[0] !== 'string'
) {
names = environments.map(
env => (env as ProjectEnvironmentType).environment
);
} else {
names = environments as string[];
}
const ref = useRef<Array<string>>(names);
if (names.join('') !== ref.current?.join('')) {
ref.current = names;
}
return ref.current;

View File

@ -16,6 +16,7 @@ import {
IProjectSettings,
IProjectSettingsRow,
IProjectStore,
ProjectEnvironment,
} from '../types/stores/project-store';
import { DEFAULT_ENV } from '../util';
import metricsHelper from '../util/metrics-helper';
@ -23,6 +24,7 @@ import { DB_TIME } from '../metric-events';
import EventEmitter from 'events';
import { Db } from './db';
import Raw = Knex.Raw;
import { CreateFeatureStrategySchema } from '../openapi';
const COLUMNS = [
'id',
@ -35,6 +37,7 @@ const COLUMNS = [
const TABLE = 'projects';
const SETTINGS_COLUMNS = ['project_mode', 'default_stickiness'];
const SETTINGS_TABLE = 'project_settings';
const PROJECT_ENVIRONMENTS = 'project_environments';
export interface IEnvironmentProjectLink {
environmentName: string;
@ -350,8 +353,8 @@ class ProjectStore implements IProjectStore {
.ignore();
}
async getEnvironmentsForProject(id: string): Promise<string[]> {
return this.db('project_environments')
async getEnvironmentsForProject(id: string): Promise<ProjectEnvironment[]> {
const rows = await this.db(PROJECT_ENVIRONMENTS)
.where({
project_id: id,
})
@ -362,7 +365,12 @@ class ProjectStore implements IProjectStore {
)
.orderBy('environments.sort_order', 'asc')
.orderBy('project_environments.environment_name', 'asc')
.pluck('project_environments.environment_name');
.returning([
'project_environments.environment_name',
'project_environments.default_strategy',
]);
return rows.map(this.mapProjectEnvironmentRow);
}
async getMembersCount(): Promise<IProjectMembersCount[]> {
@ -495,6 +503,32 @@ class ProjectStore implements IProjectStore {
.where({ project: projectId });
}
async getDefaultStrategy(
projectId: string,
environment: string,
): Promise<CreateFeatureStrategySchema | null> {
const rows = await this.db(PROJECT_ENVIRONMENTS)
.select('default_strategy')
.where({ project_id: projectId, environment_name: environment });
return rows.length > 0 ? rows[0].default_strategy : null;
}
async updateDefaultStrategy(
projectId: string,
environment: string,
strategy: CreateFeatureStrategySchema,
): Promise<CreateFeatureStrategySchema> {
const rows = await this.db(PROJECT_ENVIRONMENTS)
.update({
default_strategy: strategy,
})
.where({ project_id: projectId, environment_name: environment })
.returning('default_strategy');
return rows[0].default_strategy;
}
async count(): Promise<number> {
return this.db
.from(TABLE)
@ -534,6 +568,19 @@ class ProjectStore implements IProjectStore {
defaultStickiness: row.default_stickiness || 'default',
};
}
mapProjectEnvironmentRow(row: {
environment_name: string;
default_strategy: CreateFeatureStrategySchema;
}): ProjectEnvironment {
return {
environment: row.environment_name,
defaultStrategy:
row.default_strategy === null
? undefined
: row.default_strategy,
};
}
}
export default ProjectStore;

View File

@ -1,11 +1,11 @@
import { OpenAPIV3 } from 'openapi-types';
import {
adminFeaturesQuerySchema,
addonCreateUpdateSchema,
addonParameterSchema,
addonSchema,
addonCreateUpdateSchema,
addonsSchema,
addonTypeSchema,
adminFeaturesQuerySchema,
apiTokenSchema,
apiTokensSchema,
applicationSchema,
@ -35,8 +35,8 @@ import {
environmentsSchema,
eventSchema,
eventsSchema,
exportResultSchema,
exportQuerySchema,
exportResultSchema,
featureEnvironmentMetricsSchema,
featureEnvironmentSchema,
featureEventsSchema,
@ -58,6 +58,9 @@ import {
healthOverviewSchema,
healthReportSchema,
idSchema,
importTogglesSchema,
importTogglesValidateItemSchema,
importTogglesValidateSchema,
instanceAdminStatsSchema,
legalValueSchema,
loginSchema,
@ -79,20 +82,21 @@ import {
playgroundStrategySchema,
profileSchema,
projectEnvironmentSchema,
projectOverviewSchema,
projectSchema,
projectsSchema,
projectStatsSchema,
proxyClientSchema,
proxyFeatureSchema,
proxyFeaturesSchema,
publicSignupTokenCreateSchema,
projectStatsSchema,
publicSignupTokenSchema,
publicSignupTokensSchema,
publicSignupTokenUpdateSchema,
pushVariantsSchema,
resetPasswordSchema,
requestsPerSecondSchema,
requestsPerSecondSegmentedSchema,
resetPasswordSchema,
roleSchema,
sdkContextSchema,
searchEventsSchema,
@ -131,10 +135,6 @@ import {
variantSchema,
variantsSchema,
versionSchema,
projectOverviewSchema,
importTogglesSchema,
importTogglesValidateSchema,
importTogglesValidateItemSchema,
} from './spec';
import { IServerOption } from '../types';
import { mapValues, omitKeys } from '../util';

View File

@ -8,6 +8,8 @@ import { constraintSchema } from './constraint-schema';
import { environmentSchema } from './environment-schema';
import { featureEnvironmentSchema } from './feature-environment-schema';
import { projectStatsSchema } from './project-stats-schema';
import { createFeatureStrategySchema } from './create-feature-strategy-schema';
import { projectEnvironmentSchema } from './project-environment-schema';
export const healthOverviewSchema = {
$id: '#/components/schemas/healthOverviewSchema',
@ -47,7 +49,7 @@ export const healthOverviewSchema = {
environments: {
type: 'array',
items: {
type: 'string',
$ref: '#/components/schemas/projectEnvironmentSchema',
},
},
features: {
@ -71,8 +73,10 @@ export const healthOverviewSchema = {
},
components: {
schemas: {
constraintSchema,
environmentSchema,
projectEnvironmentSchema,
createFeatureStrategySchema,
constraintSchema,
featureSchema,
featureEnvironmentSchema,
overrideSchema,

View File

@ -1,4 +1,5 @@
import { FromSchema } from 'json-schema-to-ts';
import { createFeatureStrategySchema } from './create-feature-strategy-schema';
export const projectEnvironmentSchema = {
$id: '#/components/schemas/projectEnvironmentSchema',
@ -12,8 +13,15 @@ export const projectEnvironmentSchema = {
changeRequestsEnabled: {
type: 'boolean',
},
defaultStrategy: {
$ref: '#/components/schemas/createFeatureStrategySchema',
},
},
components: {
schemas: {
createFeatureStrategySchema,
},
},
components: {},
} as const;
export type ProjectEnvironmentSchema = FromSchema<

View File

@ -8,6 +8,8 @@ import { constraintSchema } from './constraint-schema';
import { environmentSchema } from './environment-schema';
import { featureEnvironmentSchema } from './feature-environment-schema';
import { projectStatsSchema } from './project-stats-schema';
import { createFeatureStrategySchema } from './create-feature-strategy-schema';
import { projectEnvironmentSchema } from './project-environment-schema';
export const projectOverviewSchema = {
$id: '#/components/schemas/projectOverviewSchema',
@ -63,9 +65,23 @@ export const projectOverviewSchema = {
environments: {
type: 'array',
items: {
type: 'string',
$ref: '#/components/schemas/projectEnvironmentSchema',
},
example: ['development', 'production'],
example: [
{ environment: 'development' },
{
environment: 'production',
defaultStrategy: {
name: 'flexibleRollout',
constraints: [],
parameters: {
rollout: '50',
stickiness: 'customAppName',
groupId: 'stickytoggle',
},
},
},
],
description: 'The environments that are enabled for this project',
},
features: {
@ -91,8 +107,10 @@ export const projectOverviewSchema = {
},
components: {
schemas: {
constraintSchema,
environmentSchema,
projectEnvironmentSchema,
createFeatureStrategySchema,
constraintSchema,
featureSchema,
featureEnvironmentSchema,
overrideSchema,

View File

@ -1,13 +1,23 @@
import { Request, Response } from 'express';
import Controller from '../../controller';
import { IUnleashConfig } from '../../../types/option';
import { IUnleashServices } from '../../../types/services';
import {
IUnleashConfig,
IUnleashServices,
serializeDates,
UPDATE_PROJECT,
} from '../../../types';
import { Logger } from '../../../logger';
import EnvironmentService from '../../../services/environment-service';
import { UPDATE_PROJECT } from '../../../types/permissions';
import { createRequestSchema } from '../../../openapi/util/create-request-schema';
import { ProjectEnvironmentSchema } from '../../../openapi/spec/project-environment-schema';
import { emptyResponse } from '../../../openapi/util/standard-responses';
import {
createFeatureStrategySchema,
CreateFeatureStrategySchema,
createRequestSchema,
createResponseSchema,
emptyResponse,
getStandardResponses,
ProjectEnvironmentSchema,
} from '../../../openapi';
import { OpenApiService } from '../../../services';
const PREFIX = '/:projectId/environments';
@ -21,6 +31,8 @@ export default class EnvironmentsController extends Controller {
private environmentService: EnvironmentService;
private openApiService: OpenApiService;
constructor(
config: IUnleashConfig,
{
@ -32,6 +44,7 @@ export default class EnvironmentsController extends Controller {
this.logger = config.getLogger('admin-api/project/environments.ts');
this.environmentService = environmentService;
this.openApiService = openApiService;
this.route({
method: 'post',
@ -64,6 +77,30 @@ export default class EnvironmentsController extends Controller {
}),
],
});
this.route({
method: 'post',
path: `${PREFIX}/:environment/default-strategy`,
handler: this.addDefaultStrategyToProjectEnvironment,
permission: UPDATE_PROJECT,
middleware: [
openApiService.validPath({
tags: ['Projects'],
operationId: 'addDefaultStrategyToProjectEnvironment',
description:
'Adds a default strategy for this environment. Unleash will use this strategy by default when enabling a toggle. Use the wild card "*" for `:environment` to add to all environments. ',
requestBody: createRequestSchema(
'createFeatureStrategySchema',
),
responses: {
200: createResponseSchema(
'createFeatureStrategySchema',
),
...getStandardResponses(400),
},
}),
],
});
}
async addEnvironmentToProject(
@ -98,4 +135,25 @@ export default class EnvironmentsController extends Controller {
res.status(200).end();
}
async addDefaultStrategyToProjectEnvironment(
req: Request<IProjectEnvironmentParams, CreateFeatureStrategySchema>,
res: Response<CreateFeatureStrategySchema>,
): Promise<void> {
const { projectId, environment } = req.params;
const strategy = req.body;
const saved = await this.environmentService.addDefaultStrategy(
environment,
projectId,
strategy,
);
this.openApiService.respondWithValidation(
200,
res,
createFeatureStrategySchema.$id,
serializeDates(saved),
);
}
}

View File

@ -110,6 +110,7 @@ export default class ProjectApi extends Controller {
archived,
user.id,
);
this.openApiService.respondWithValidation(
200,
res,

View File

@ -611,11 +611,12 @@ export default class ProjectFeaturesController extends Controller {
res: Response<FeatureEnvironmentSchema>,
): Promise<void> {
const { environment, featureName, projectId } = req.params;
const environmentInfo = await this.featureService.getEnvironmentInfo(
projectId,
environment,
featureName,
);
const { defaultStrategy, ...environmentInfo } =
await this.featureService.getEnvironmentInfo(
projectId,
environment,
featureName,
);
const result = {
...environmentInfo,

View File

@ -95,7 +95,10 @@ async function createApp(
});
}
if (config.environmentEnableOverrides?.length > 0) {
if (
config.environmentEnableOverrides &&
config.environmentEnableOverrides?.length > 0
) {
await services.environmentService.overrideEnabledProjects(
config.environmentEnableOverrides,
);

View File

@ -1,17 +1,22 @@
import { IUnleashStores } from '../types/stores';
import { IUnleashConfig } from '../types/option';
import {
IEnvironment,
IEnvironmentStore,
IFeatureEnvironmentStore,
IFeatureStrategiesStore,
IProjectEnvironment,
ISortOrder,
IUnleashConfig,
IUnleashStores,
} from '../types';
import { Logger } from '../logger';
import { IEnvironment, IProjectEnvironment, ISortOrder } from '../types/model';
import { UNIQUE_CONSTRAINT_VIOLATION } from '../error/db-error';
import { BadDataError, UNIQUE_CONSTRAINT_VIOLATION } from '../error';
import NameExistsError from '../error/name-exists-error';
import { sortOrderSchema } from './state-schema';
import NotFoundError from '../error/notfound-error';
import { IEnvironmentStore } from '../types/stores/environment-store';
import { IFeatureStrategiesStore } from '../types/stores/feature-strategies-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IProjectStore } from 'lib/types/stores/project-store';
import MinimumOneEnvironmentError from '../error/minimum-one-environment-error';
import { IFlagResolver } from 'lib/types/experimental';
import { CreateFeatureStrategySchema } from '../openapi';
export default class EnvironmentService {
private logger: Logger;
@ -107,6 +112,23 @@ export default class EnvironmentService {
}
}
async addDefaultStrategy(
environment: string,
projectId: string,
strategy: CreateFeatureStrategySchema,
): Promise<CreateFeatureStrategySchema> {
if (strategy.name !== 'flexibleRollout') {
throw new BadDataError(
'Only "flexibleRollout" strategy can be used as a default strategy for an environment',
);
}
return this.projectStore.updateDefaultStrategy(
projectId,
environment,
strategy,
);
}
async overrideEnabledProjects(
environmentNamesToEnable: string[],
): Promise<void> {

View File

@ -1062,11 +1062,16 @@ class FeatureToggleService {
featureName,
environment,
);
const defaultStrategy = await this.projectStore.getDefaultStrategy(
project,
environment,
);
return {
name: featureName,
environment,
enabled: envMetadata.enabled,
strategies,
defaultStrategy,
};
}

View File

@ -238,7 +238,7 @@ export default class ProjectService {
);
return arraysHaveSameItems(
featureEnvs.map((env) => env.environment),
newEnvs,
newEnvs.map((projectEnv) => projectEnv.environment),
);
}

View File

@ -4,6 +4,8 @@ import { IRole } from './stores/access-store';
import { IUser } from './user';
import { ALL_OPERATORS } from '../util';
import { IProjectStats } from 'lib/services/project-service';
import { CreateFeatureStrategySchema } from '../openapi';
import { ProjectEnvironment } from './stores/project-store';
export type Operator = typeof ALL_OPERATORS[number];
@ -84,6 +86,7 @@ export interface IFeatureEnvironmentInfo {
environment: string;
enabled: boolean;
strategies: IFeatureStrategy[];
defaultStrategy?: CreateFeatureStrategySchema;
}
export interface FeatureToggleWithEnvironment extends FeatureToggle {
@ -141,6 +144,7 @@ export interface IEnvironment {
export interface IProjectEnvironment extends IEnvironment {
projectApiTokenCount?: number;
projectEnabledToggleCount?: number;
defaultStrategy?: CreateFeatureStrategySchema;
}
export interface IEnvironmentCreate {
@ -182,7 +186,7 @@ export type ProjectMode = 'open' | 'protected';
export interface IProjectOverview {
name: string;
description: string;
environments: string[];
environments: ProjectEnvironment[];
features: IFeatureOverview[];
members: number;
version: number;

View File

@ -9,6 +9,7 @@ import {
ProjectMode,
} from '../model';
import { Store } from './store';
import { CreateFeatureStrategySchema } from '../../openapi';
export interface IProjectInsert {
id: string;
@ -29,6 +30,11 @@ export interface IProjectSettingsRow {
default_stickiness: string;
}
export interface IProjectEnvironmenDefaultStrategyRow {
environment: string;
default_strategy: any;
}
export interface IProjectArchived {
id: string;
archived: boolean;
@ -43,6 +49,12 @@ export interface IProjectQuery {
id?: string;
}
export type ProjectEnvironment = {
environment: string;
changeRequestEnabled?: boolean;
defaultStrategy?: CreateFeatureStrategySchema;
};
export interface IProjectEnvironmentWithChangeRequests {
environment: string;
changeRequestsEnabled: boolean;
@ -66,7 +78,7 @@ export interface IProjectStore extends Store<IProject, string> {
deleteEnvironmentForProject(id: string, environment: string): Promise<void>;
getEnvironmentsForProject(id: string): Promise<string[]>;
getEnvironmentsForProject(id: string): Promise<ProjectEnvironment[]>;
getMembersCountByProject(projectId: string): Promise<number>;
@ -103,4 +115,14 @@ export interface IProjectStore extends Store<IProject, string> {
defaultStickiness: string,
mode: ProjectMode,
): Promise<void>;
getDefaultStrategy(
projectId: string,
environment: string,
): Promise<CreateFeatureStrategySchema | null>;
updateDefaultStrategy(
projectId: string,
environment: string,
strategy: CreateFeatureStrategySchema,
): Promise<CreateFeatureStrategySchema>;
}

View File

@ -0,0 +1,21 @@
'use strict';
exports.up = function (db, callback) {
db.runSql(
`
ALTER TABLE project_environments
ADD COLUMN IF NOT EXISTS default_strategy jsonb;
`,
callback,
);
};
exports.down = function (db, callback) {
db.runSql(
`
ALTER TABLE project_environments
DROP COLUMN IF EXISTS default_strategy;
`,
callback,
);
};

View File

@ -132,7 +132,7 @@ test('update admin token with expiry', async () => {
});
test('creates a lot of client tokens', async () => {
const requests = [];
const requests: any[] = [];
for (let i = 0; i < 10; i++) {
requests.push(

View File

@ -92,11 +92,11 @@ describe('Playground API E2E', () => {
features.map(async (feature) => {
// create feature
const toggle = await database.stores.featureToggleStore.create(
feature.project,
feature.project!,
{
...feature,
...(feature as any),
createdAt: undefined,
variants: null,
variants: null as any,
},
);
@ -108,7 +108,7 @@ describe('Playground API E2E', () => {
);
await database.stores.featureToggleStore.saveVariants(
feature.project,
feature.project!,
feature.name,
[
...(feature.variants ?? []).map((variant) => ({
@ -131,7 +131,7 @@ describe('Playground API E2E', () => {
environment,
strategyName: strategy.name,
disabled: !!(index % 2),
projectId: feature.project,
projectId: feature.project!,
},
),
),
@ -194,7 +194,7 @@ describe('Playground API E2E', () => {
),
);
request.projects = projects;
request.projects = projects as any;
// create a list of features that can be filtered
// pass in args that should filter the list
@ -388,6 +388,7 @@ describe('Playground API E2E', () => {
(acc, next) => ({
...acc,
[next.name]:
// @ts-ignore
next.strategies[0].constraints[0]
.values[0] === req.context.appName,
}),
@ -485,8 +486,8 @@ describe('Playground API E2E', () => {
(acc, next) => ({
...acc,
[next.name]:
next.strategies[0].constraints[0]
.values[0] === contextField,
next.strategies![0].constraints![0]
.values![0] === contextField,
}),
{},
);
@ -599,14 +600,14 @@ describe('Playground API E2E', () => {
const shouldBeEnabled = features.reduce(
(acc, next) => {
const constraint =
next.strategies[0].constraints[0];
next.strategies![0].constraints![0];
return {
...acc,
[next.name]:
constraint.contextName ===
generatedContextValue.name &&
constraint.values[0] ===
constraint.values![0] ===
generatedContextValue.value,
};
},
@ -684,7 +685,7 @@ describe('Playground API E2E', () => {
const body = await playgroundRequest(app, token.secret, request);
// when enabled, this toggle should have one of the variants
expect(body.features[0].variant.name).toBe('a');
expect(body.features[0].variant!.name).toBe('a');
});
});
});

View File

@ -4,7 +4,7 @@ import {
setupAppWithCustomConfig,
} from '../../../helpers/test-helper';
import getLogger from '../../../../fixtures/no-logger';
import { DEFAULT_ENV } from '../../../../../lib/util/constants';
import { DEFAULT_ENV } from '../../../../../lib/util';
let app: IUnleashTest;
let db: ITestDb;
@ -26,11 +26,11 @@ afterEach(async () => {
);
await Promise.all(
all
.filter((env) => env !== DEFAULT_ENV)
.filter((env) => env.environment !== DEFAULT_ENV)
.map(async (env) =>
db.stores.projectStore.deleteEnvironmentForProject(
'default',
env,
env.environment,
),
),
);
@ -56,7 +56,7 @@ test('Should add environment to project', async () => {
'default',
);
const environment = envs.find((env) => env === 'test');
const environment = envs.find((env) => env.environment === 'test');
expect(environment).toBeDefined();
expect(envs).toHaveLength(2);
@ -111,3 +111,51 @@ test('Should not remove environment from project if project only has one environ
expect(envs).toHaveLength(1);
});
test('Should add default strategy to environment', async () => {
await app.request
.post(
`/api/admin/projects/default/environments/default/default-strategy`,
)
.send({
name: 'flexibleRollout',
constraints: [],
parameters: {
rollout: '50',
stickiness: 'customAppName',
groupId: 'stickytoggle',
},
})
.expect(200);
const envs = await db.stores.projectStore.getEnvironmentsForProject(
'default',
);
expect(envs).toHaveLength(1);
expect(envs[0]).toStrictEqual({
environment: 'default',
defaultStrategy: {
name: 'flexibleRollout',
constraints: [],
parameters: {
rollout: '50',
stickiness: 'customAppName',
groupId: 'stickytoggle',
},
},
});
});
test('Should throw an error if you try to set defaultStrategy other than flexibleRollout', async () => {
await app.request
.post(
`/api/admin/projects/default/environments/default/default-strategy`,
)
.send({
name: 'default',
constraints: [],
parameters: {},
})
.expect(400);
});

View File

@ -18,11 +18,11 @@ afterEach(async () => {
);
await Promise.all(
all
.filter((env) => env !== DEFAULT_ENV)
.filter((env) => env.environment !== DEFAULT_ENV)
.map(async (env) =>
db.stores.projectStore.deleteEnvironmentForProject(
'default',
env,
env.environment,
),
),
);

View File

@ -102,11 +102,11 @@ afterEach(async () => {
);
await Promise.all(
all
.filter((env) => env !== DEFAULT_ENV)
.filter((env) => env.environment !== DEFAULT_ENV)
.map(async (env) =>
db.stores.projectStore.deleteEnvironmentForProject(
'default',
env,
env.environment,
),
),
);
@ -2693,7 +2693,7 @@ test('should add multiple segments to a strategy', async () => {
const defaultEnv = res.body.environments.find(
(env) => env.name === 'default',
);
const strategy = defaultEnv.strategies.find(
const strategy = defaultEnv?.strategies.find(
(strat) => strat.id === strategyOne.id,
);

View File

@ -56,7 +56,9 @@ test('Project with no stale toggles should have 100% health rating', async () =>
.expect((res) => {
expect(res.body.health).toBe(100);
expect(res.body.environments).toHaveLength(1);
expect(res.body.environments).toStrictEqual(['default']);
expect(res.body.environments).toStrictEqual([
{ environment: 'default' },
]);
});
});

View File

@ -73,13 +73,13 @@ beforeAll(async () => {
settingService,
});
resetTokenService = new ResetTokenService(stores, config);
const adminRole = await accessService.getRootRole(RoleName.ADMIN);
const adminRole = (await accessService.getRootRole(RoleName.ADMIN))!;
adminUser = await userService.createUser({
username: 'admin@test.com',
rootRole: adminRole.id,
});
})!;
const userRole = await accessService.getRootRole(RoleName.EDITOR);
const userRole = (await accessService.getRootRole(RoleName.EDITOR))!;
user = await userService.createUser({
username: 'test@test.com',
email: 'test@test.com',
@ -99,7 +99,7 @@ afterAll(async () => {
test('Can validate token for password reset', async () => {
const url = await resetTokenService.createResetPasswordUrl(
user.id,
adminUser.username,
adminUser.username!,
);
const relative = getBackendResetUrl(url);
return app.request
@ -114,12 +114,12 @@ test('Can validate token for password reset', async () => {
test('Can use token to reset password', async () => {
const url = await resetTokenService.createResetPasswordUrl(
user.id,
adminUser.username,
adminUser.username!,
);
const relative = getBackendResetUrl(url);
// Can't login before reset
await expect(async () =>
userService.loginUser(user.email, password),
userService.loginUser(user.email!, password),
).rejects.toThrow(Error);
let token;
@ -137,14 +137,14 @@ test('Can use token to reset password', async () => {
password,
})
.expect(200);
const loggedInUser = await userService.loginUser(user.email, password);
const loggedInUser = await userService.loginUser(user.email!, password);
expect(user.email).toBe(loggedInUser.email);
});
test('Trying to reset password with same token twice does not work', async () => {
const url = await resetTokenService.createResetPasswordUrl(
user.id,
adminUser.username,
adminUser.username!,
);
const relative = getBackendResetUrl(url);
let token;
@ -205,7 +205,7 @@ test('Calling reset endpoint with already existing session should logout/destroy
const { request, destroy } = await setupAppWithAuth(stores);
const url = await resetTokenService.createResetPasswordUrl(
user.id,
adminUser.username,
adminUser.username!,
);
const relative = getBackendResetUrl(url);
let token;
@ -248,7 +248,7 @@ test('Trying to change password to undefined should yield 400 without crashing t
const url = await resetTokenService.createResetPasswordUrl(
user.id,
adminUser.username,
adminUser.username!,
);
const relative = getBackendResetUrl(url);
let token;
@ -271,7 +271,7 @@ test('Trying to change password to undefined should yield 400 without crashing t
test('changing password should expire all active tokens', async () => {
const url = await resetTokenService.createResetPasswordUrl(
user.id,
adminUser.username,
adminUser.username!,
);
const relative = getBackendResetUrl(url);

View File

@ -37,6 +37,7 @@ beforeEach(async () => {
const groupService = new GroupService(stores, config);
const accessService = new AccessService(stores, config, groupService);
const resetTokenService = new ResetTokenService(stores, config);
// @ts-ignore
const emailService = new EmailService(undefined, config.getLogger);
const sessionService = new SessionService(stores, config);
const settingService = new SettingService(stores, config);
@ -52,7 +53,7 @@ beforeEach(async () => {
adminUser = await userService.createUser({
username: 'admin@test.com',
email: 'admin@test.com',
rootRole: adminRole.id,
rootRole: adminRole!.id,
password: password,
});
});

View File

@ -2593,7 +2593,7 @@ The provider you choose for your addon dictates what properties the \`parameters
},
"environments": {
"items": {
"type": "string",
"$ref": "#/components/schemas/projectEnvironmentSchema",
},
"type": "array",
},
@ -2660,7 +2660,7 @@ The provider you choose for your addon dictates what properties the \`parameters
},
"environments": {
"items": {
"type": "string",
"$ref": "#/components/schemas/projectEnvironmentSchema",
},
"type": "array",
},
@ -3514,6 +3514,9 @@ The provider you choose for your addon dictates what properties the \`parameters
"changeRequestsEnabled": {
"type": "boolean",
},
"defaultStrategy": {
"$ref": "#/components/schemas/createFeatureStrategySchema",
},
"environment": {
"type": "string",
},
@ -3541,11 +3544,24 @@ The provider you choose for your addon dictates what properties the \`parameters
"environments": {
"description": "The environments that are enabled for this project",
"example": [
"development",
"production",
{
"environment": "development",
},
{
"defaultStrategy": {
"constraints": [],
"name": "flexibleRollout",
"parameters": {
"groupId": "stickytoggle",
"rollout": "50",
"stickiness": "customAppName",
},
},
"environment": "production",
},
],
"items": {
"type": "string",
"$ref": "#/components/schemas/projectEnvironmentSchema",
},
"type": "array",
},
@ -9169,6 +9185,83 @@ If the provided project does not exist, the list of events will be empty.",
],
},
},
"/api/admin/projects/{projectId}/environments/{environment}/default-strategy": {
"post": {
"description": "Adds a default strategy for this environment. Unleash will use this strategy by default when enabling a toggle. Use the wild card "*" for \`:environment\` to add to all environments. ",
"operationId": "addDefaultStrategyToProjectEnvironment",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
{
"in": "path",
"name": "environment",
"required": true,
"schema": {
"type": "string",
},
},
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/createFeatureStrategySchema",
},
},
},
"description": "createFeatureStrategySchema",
"required": true,
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/createFeatureStrategySchema",
},
},
},
"description": "createFeatureStrategySchema",
},
"400": {
"content": {
"application/json": {
"schema": {
"properties": {
"id": {
"description": "The ID of the error instance",
"example": "9c40958a-daac-400e-98fb-3bb438567008",
"type": "string",
},
"message": {
"description": "A description of what went wrong.",
"example": "The request payload you provided doesn't conform to the schema. The .parameters property should be object. You sent [].",
"type": "string",
},
"name": {
"description": "The name of the error kind",
"example": "ValidationError",
"type": "string",
},
},
"type": "object",
},
},
},
"description": "The request data does not match what we expect.",
},
},
"tags": [
"Projects",
],
},
},
"/api/admin/projects/{projectId}/favorites": {
"delete": {
"operationId": "removeFavoriteProject",

View File

@ -2,7 +2,7 @@ import EnvironmentService from '../../../lib/services/environment-service';
import { createTestConfig } from '../../config/test-config';
import dbInit from '../helpers/database-init';
import NotFoundError from '../../../lib/error/notfound-error';
import { IUnleashStores } from '../../../lib/types/stores';
import { IUnleashStores } from '../../../lib/types';
import NameExistsError from '../../../lib/error/name-exists-error';
let stores: IUnleashStores;
@ -164,8 +164,8 @@ test('Setting an override disables all other envs', async () => {
.filter((x) => x.name != enabledEnvName)
.map((env) => env.enabled);
expect(targetedEnvironment.enabled).toBe(true);
expect(allOtherEnvironments.every((x) => x === false)).toBe(true);
expect(targetedEnvironment?.enabled).toBe(true);
expect(allOtherEnvironments.every((x) => !x)).toBe(true);
});
test('Passing an empty override does nothing', async () => {
@ -185,7 +185,7 @@ test('Passing an empty override does nothing', async () => {
(env) => env.name == enabledEnvName,
);
expect(targetedEnvironment.enabled).toBe(true);
expect(targetedEnvironment?.enabled).toBe(true);
});
test('When given overrides should remap projects to override environments', async () => {
@ -224,9 +224,9 @@ test('When given overrides should remap projects to override environments', asyn
await service.overrideEnabledProjects([enabledEnvName]);
const projects = await stores.projectStore.getEnvironmentsForProject(
'default',
);
const projects = (
await stores.projectStore.getEnvironmentsForProject('default')
).map((e) => e.environment);
expect(projects).toContain('enabled');
expect(projects).not.toContain('default');
@ -263,6 +263,6 @@ test('Override works correctly when enabling default and disabling prod and dev'
expect(envNames).toContain('production');
expect(envNames).toContain('development');
expect(targetedEnvironment.enabled).toBe(true);
expect(allOtherEnvironments.every((x) => x === false)).toBe(true);
expect(targetedEnvironment?.enabled).toBe(true);
expect(allOtherEnvironments.every((x) => !x)).toBe(true);
});

View File

@ -727,8 +727,12 @@ test('A newly created project only gets connected to enabled environments', asyn
const connectedEnvs =
await db.stores.projectStore.getEnvironmentsForProject(project.id);
expect(connectedEnvs).toHaveLength(2); // default, connection_test
expect(connectedEnvs.some((e) => e === enabledEnv)).toBeTruthy();
expect(connectedEnvs.some((e) => e === disabledEnv)).toBeFalsy();
expect(
connectedEnvs.some((e) => e.environment === enabledEnv),
).toBeTruthy();
expect(
connectedEnvs.some((e) => e.environment === disabledEnv),
).toBeFalsy();
});
test('should have environments sorted in order', async () => {
@ -768,7 +772,13 @@ test('should have environments sorted in order', async () => {
const connectedEnvs =
await db.stores.projectStore.getEnvironmentsForProject(project.id);
expect(connectedEnvs).toEqual(['default', first, second, third, fourth]);
expect(connectedEnvs.map((e) => e.environment)).toEqual([
'default',
first,
second,
third,
fourth,
]);
});
test('should add a user to the project with a custom role', async () => {

View File

@ -3,6 +3,7 @@ import {
IProjectInsert,
IProjectSettings,
IProjectStore,
ProjectEnvironment,
} from '../../lib/types/stores/project-store';
import {
IEnvironment,
@ -15,13 +16,14 @@ import {
IEnvironmentProjectLink,
IProjectMembersCount,
} from 'lib/db/project-store';
import { CreateFeatureStrategySchema } from '../../lib/openapi';
export default class FakeProjectStore implements IProjectStore {
projects: IProject[] = [];
projectEnvironment: Map<string, Set<string>> = new Map();
getEnvironmentsForProject(): Promise<string[]> {
getEnvironmentsForProject(): Promise<ProjectEnvironment[]> {
throw new Error('Method not implemented.');
}
@ -180,4 +182,24 @@ export default class FakeProjectStore implements IProjectStore {
): Promise<void> {
throw new Error('Method not implemented.');
}
updateDefaultStrategy(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
strategy: CreateFeatureStrategySchema,
): Promise<CreateFeatureStrategySchema> {
throw new Error('Method not implemented.');
}
getDefaultStrategy(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
projectId: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
): Promise<CreateFeatureStrategySchema | undefined> {
throw new Error('Method not implemented.');
}
}