diff --git a/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx b/frontend/src/component/project/Project/ProjectFeatureToggles/ProjectFeatureToggles.tsx index 4772dc117c5..3d8d6946914 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 623418b3e2c..72694984617 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 abd0bf212d3..5eadd68b93f 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 052ebe0be89..afa58a7e94f 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 df6069f1a58..5659fec7c12 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 072df4897ee..82b76ef111a 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 310eb12a9c1..0832e302321 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 72a96dfe0d7..41b7ba49efd 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 272b39ccd5f..2be09bb099c 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 f83d989e8c7..718ade1543a 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 287bf2099bb..e934491044c 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 8b2c4b90386..dc53a127acf 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 f0648b100c0..e40444fe1e6 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 c5eb0e0ddc3..2b380f9570b 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 82928dad7d9..312e9b95bdd 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 66bd4895405..8e7fe478370 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 00000000000..4edef97c9f0 --- /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 d654c1b1377..259b5602389 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 296429eccb4..4807c54aa33 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 0491c63c47a..7d50aca4ca6 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 5cb19fb772f..9468138bfc5 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 b80f1c8bafd..2bfadfc40fa 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 bcd8085817a..5aeffb89da7 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 0875fc2acbe..f4066155673 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 6b4f4bed863..4a53d120c2d 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 cb56b667190..052f870ca34 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 8c7de595fc0..65d05539b39 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 2fe56648f1a..b4af1b9a7be 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 4272b801dc7..0f70d527df5 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.'); + } }