diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx
index 4772dc117c..3d8d694691 100644
--- a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx
@@ -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 (
+
+
+ }
+ />
+
+ );
+ },
+ sortType: 'boolean',
+ filterName: name,
+ filterParsing: (value: boolean) =>
+ value ? 'enabled' : 'disabled',
+ };
+ }),
- return (
-
-
- }
- />
-
- );
- },
- 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,
diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef.ts b/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef.ts
index 623418b3e2..7269498461 100644
--- a/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef.ts
+++ b/frontend/src/component/project/Project/ProjectFeatureToggles/hooks/useEnvironmentsRef.ts
@@ -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(environments);
- if (environments?.join('') !== ref.current?.join('')) {
- ref.current = environments;
+export type ProjectEnvironmentType = {
+ environment: string;
+ defaultStrategy: CreateFeatureStrategySchema | null;
+};
+export const useEnvironmentsRef = (
+ environments: Array = []
+): 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>(names);
+ if (names.join('') !== ref.current?.join('')) {
+ ref.current = names;
}
return ref.current;
diff --git a/src/lib/db/project-store.ts b/src/lib/db/project-store.ts
index abd0bf212d..5eadd68b93 100644
--- a/src/lib/db/project-store.ts
+++ b/src/lib/db/project-store.ts
@@ -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 {
- return this.db('project_environments')
+ async getEnvironmentsForProject(id: string): Promise {
+ 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 {
@@ -495,6 +503,32 @@ class ProjectStore implements IProjectStore {
.where({ project: projectId });
}
+ async getDefaultStrategy(
+ projectId: string,
+ environment: string,
+ ): Promise {
+ 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 {
+ 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 {
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;
diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts
index 052ebe0be8..afa58a7e94 100644
--- a/src/lib/openapi/index.ts
+++ b/src/lib/openapi/index.ts
@@ -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';
diff --git a/src/lib/openapi/spec/health-overview-schema.ts b/src/lib/openapi/spec/health-overview-schema.ts
index df6069f1a5..5659fec7c1 100644
--- a/src/lib/openapi/spec/health-overview-schema.ts
+++ b/src/lib/openapi/spec/health-overview-schema.ts
@@ -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,
diff --git a/src/lib/openapi/spec/project-environment-schema.ts b/src/lib/openapi/spec/project-environment-schema.ts
index 072df4897e..82b76ef111 100644
--- a/src/lib/openapi/spec/project-environment-schema.ts
+++ b/src/lib/openapi/spec/project-environment-schema.ts
@@ -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<
diff --git a/src/lib/openapi/spec/project-overview-schema.ts b/src/lib/openapi/spec/project-overview-schema.ts
index 310eb12a9c..0832e30232 100644
--- a/src/lib/openapi/spec/project-overview-schema.ts
+++ b/src/lib/openapi/spec/project-overview-schema.ts
@@ -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,
diff --git a/src/lib/routes/admin-api/project/environments.ts b/src/lib/routes/admin-api/project/environments.ts
index 72a96dfe0d..41b7ba49ef 100644
--- a/src/lib/routes/admin-api/project/environments.ts
+++ b/src/lib/routes/admin-api/project/environments.ts
@@ -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,
+ res: Response,
+ ): Promise {
+ 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),
+ );
+ }
}
diff --git a/src/lib/routes/admin-api/project/index.ts b/src/lib/routes/admin-api/project/index.ts
index 272b39ccd5..2be09bb099 100644
--- a/src/lib/routes/admin-api/project/index.ts
+++ b/src/lib/routes/admin-api/project/index.ts
@@ -110,6 +110,7 @@ export default class ProjectApi extends Controller {
archived,
user.id,
);
+
this.openApiService.respondWithValidation(
200,
res,
diff --git a/src/lib/routes/admin-api/project/project-features.ts b/src/lib/routes/admin-api/project/project-features.ts
index f83d989e8c..718ade1543 100644
--- a/src/lib/routes/admin-api/project/project-features.ts
+++ b/src/lib/routes/admin-api/project/project-features.ts
@@ -611,11 +611,12 @@ export default class ProjectFeaturesController extends Controller {
res: Response,
): Promise {
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,
diff --git a/src/lib/server-impl.ts b/src/lib/server-impl.ts
index 287bf2099b..e934491044 100644
--- a/src/lib/server-impl.ts
+++ b/src/lib/server-impl.ts
@@ -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,
);
diff --git a/src/lib/services/environment-service.ts b/src/lib/services/environment-service.ts
index 8b2c4b9038..dc53a127ac 100644
--- a/src/lib/services/environment-service.ts
+++ b/src/lib/services/environment-service.ts
@@ -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 {
+ 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 {
diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts
index f0648b100c..e40444fe1e 100644
--- a/src/lib/services/feature-toggle-service.ts
+++ b/src/lib/services/feature-toggle-service.ts
@@ -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,
};
}
diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts
index c5eb0e0ddc..2b380f9570 100644
--- a/src/lib/services/project-service.ts
+++ b/src/lib/services/project-service.ts
@@ -238,7 +238,7 @@ export default class ProjectService {
);
return arraysHaveSameItems(
featureEnvs.map((env) => env.environment),
- newEnvs,
+ newEnvs.map((projectEnv) => projectEnv.environment),
);
}
diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts
index 82928dad7d..312e9b95bd 100644
--- a/src/lib/types/model.ts
+++ b/src/lib/types/model.ts
@@ -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;
diff --git a/src/lib/types/stores/project-store.ts b/src/lib/types/stores/project-store.ts
index 66bd489540..8e7fe47837 100644
--- a/src/lib/types/stores/project-store.ts
+++ b/src/lib/types/stores/project-store.ts
@@ -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 {
deleteEnvironmentForProject(id: string, environment: string): Promise;
- getEnvironmentsForProject(id: string): Promise;
+ getEnvironmentsForProject(id: string): Promise;
getMembersCountByProject(projectId: string): Promise;
@@ -103,4 +115,14 @@ export interface IProjectStore extends Store {
defaultStickiness: string,
mode: ProjectMode,
): Promise;
+
+ getDefaultStrategy(
+ projectId: string,
+ environment: string,
+ ): Promise;
+ updateDefaultStrategy(
+ projectId: string,
+ environment: string,
+ strategy: CreateFeatureStrategySchema,
+ ): Promise;
}
diff --git a/src/migrations/20230424090942-project-default-strategy-settings.js b/src/migrations/20230424090942-project-default-strategy-settings.js
new file mode 100644
index 0000000000..4edef97c9f
--- /dev/null
+++ b/src/migrations/20230424090942-project-default-strategy-settings.js
@@ -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,
+ );
+};
diff --git a/src/test/e2e/api/admin/api-token.e2e.test.ts b/src/test/e2e/api/admin/api-token.e2e.test.ts
index d654c1b137..259b560238 100644
--- a/src/test/e2e/api/admin/api-token.e2e.test.ts
+++ b/src/test/e2e/api/admin/api-token.e2e.test.ts
@@ -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(
diff --git a/src/test/e2e/api/admin/playground.e2e.test.ts b/src/test/e2e/api/admin/playground.e2e.test.ts
index 296429eccb..4807c54aa3 100644
--- a/src/test/e2e/api/admin/playground.e2e.test.ts
+++ b/src/test/e2e/api/admin/playground.e2e.test.ts
@@ -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');
});
});
});
diff --git a/src/test/e2e/api/admin/project/environments.e2e.test.ts b/src/test/e2e/api/admin/project/environments.e2e.test.ts
index 0491c63c47..7d50aca4ca 100644
--- a/src/test/e2e/api/admin/project/environments.e2e.test.ts
+++ b/src/test/e2e/api/admin/project/environments.e2e.test.ts
@@ -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);
+});
diff --git a/src/test/e2e/api/admin/project/features.auth.e2e.test.ts b/src/test/e2e/api/admin/project/features.auth.e2e.test.ts
index 5cb19fb772..9468138bfc 100644
--- a/src/test/e2e/api/admin/project/features.auth.e2e.test.ts
+++ b/src/test/e2e/api/admin/project/features.auth.e2e.test.ts
@@ -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,
),
),
);
diff --git a/src/test/e2e/api/admin/project/features.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts
index b80f1c8baf..2bfadfc40f 100644
--- a/src/test/e2e/api/admin/project/features.e2e.test.ts
+++ b/src/test/e2e/api/admin/project/features.e2e.test.ts
@@ -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,
);
diff --git a/src/test/e2e/api/admin/project/project.health.e2e.test.ts b/src/test/e2e/api/admin/project/project.health.e2e.test.ts
index bcd8085817..5aeffb89da 100644
--- a/src/test/e2e/api/admin/project/project.health.e2e.test.ts
+++ b/src/test/e2e/api/admin/project/project.health.e2e.test.ts
@@ -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' },
+ ]);
});
});
diff --git a/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts b/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
index 0875fc2acb..f406615567 100644
--- a/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
+++ b/src/test/e2e/api/auth/reset-password-controller.e2e.test.ts
@@ -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);
diff --git a/src/test/e2e/api/auth/simple-password-provider.e2e.test.ts b/src/test/e2e/api/auth/simple-password-provider.e2e.test.ts
index 6b4f4bed86..4a53d120c2 100644
--- a/src/test/e2e/api/auth/simple-password-provider.e2e.test.ts
+++ b/src/test/e2e/api/auth/simple-password-provider.e2e.test.ts
@@ -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,
});
});
diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
index cb56b66719..052f870ca3 100644
--- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
+++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
@@ -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",
diff --git a/src/test/e2e/services/environment-service.test.ts b/src/test/e2e/services/environment-service.test.ts
index 8c7de595fc..65d05539b3 100644
--- a/src/test/e2e/services/environment-service.test.ts
+++ b/src/test/e2e/services/environment-service.test.ts
@@ -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);
});
diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts
index 2fe56648f1..b4af1b9a7b 100644
--- a/src/test/e2e/services/project-service.e2e.test.ts
+++ b/src/test/e2e/services/project-service.e2e.test.ts
@@ -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 () => {
diff --git a/src/test/fixtures/fake-project-store.ts b/src/test/fixtures/fake-project-store.ts
index 4272b801dc..0f70d527df 100644
--- a/src/test/fixtures/fake-project-store.ts
+++ b/src/test/fixtures/fake-project-store.ts
@@ -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> = new Map();
- getEnvironmentsForProject(): Promise {
+ getEnvironmentsForProject(): Promise {
throw new Error('Method not implemented.');
}
@@ -180,4 +182,24 @@ export default class FakeProjectStore implements IProjectStore {
): Promise {
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 {
+ 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 {
+ throw new Error('Method not implemented.');
+ }
}