1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-05 17:53:12 +02:00

Merge branch 'master' into fix/sort-order

This commit is contained in:
Fredrik Strand Oseberg 2021-10-06 09:25:48 +02:00 committed by GitHub
commit 240786ea37
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 955 additions and 11036 deletions

View File

@ -12,7 +12,7 @@
"ecmaVersion": 2019, "ecmaVersion": 2019,
"project": "./tsconfig.json" "project": "./tsconfig.json"
}, },
"plugins": ["@typescript-eslint","prettier"], "plugins": ["@typescript-eslint", "prettier", "import"],
"root": true, "root": true,
"rules": { "rules": {
"@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-var-requires": 0,

View File

@ -1,6 +1,6 @@
<div align="center"> <div align="center">
![Build & Tests](https://github.com/Unleash/unleash/workflows/Build%20%26%20Tests/badge.svg?branch=master) [![Coverage Status](https://coveralls.io/repos/github/Unleash/unleash/badge.svg?branch=master)](https://coveralls.io/github/Unleash/unleash?branch=master) [![npm](https://img.shields.io/npm/v/unleash-server)](https://www.npmjs.com/package/unleash-server) [![Docker Pulls](https://img.shields.io/docker/pulls/unleashorg/unleash-server)](https://hub.docker.com/r/unleashorg/unleash-server) ![Build & Tests](https://github.com/Unleash/unleash/workflows/Build%20%26%20Tests/badge.svg?branch=master) [![Coverage Status](https://coveralls.io/repos/github/Unleash/unleash/badge.svg?branch=master&)](https://coveralls.io/github/Unleash/unleash?branch=master) [![npm](https://img.shields.io/npm/v/unleash-server)](https://www.npmjs.com/package/unleash-server) [![Docker Pulls](https://img.shields.io/docker/pulls/unleashorg/unleash-server)](https://hub.docker.com/r/unleashorg/unleash-server)
[![Deploy to Heroku](./.github/deploy-heroku-20.png)](https://www.heroku.com/deploy/?template=https://github.com/Unleash/unleash) [![Deploy to DigitalOcean](./.github/deploy-digital.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Unleash/unleash/tree/master&refcode=0e1d75187044) [![Twitter Follow](https://img.shields.io/twitter/follow/getunleash)](https://twitter.com/intent/follow?screen_name=getunleash) [![Deploy to Heroku](./.github/deploy-heroku-20.png)](https://www.heroku.com/deploy/?template=https://github.com/Unleash/unleash) [![Deploy to DigitalOcean](./.github/deploy-digital.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Unleash/unleash/tree/master&refcode=0e1d75187044) [![Twitter Follow](https://img.shields.io/twitter/follow/getunleash)](https://twitter.com/intent/follow?screen_name=getunleash)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "unleash-server", "name": "unleash-server",
"description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.", "description": "Unleash is an enterprise ready feature toggles service. It provides different strategies for handling feature toggles.",
"version": "4.2.0-0", "version": "4.2.0-2",
"keywords": [ "keywords": [
"unleash", "unleash",
"feature toggle", "feature toggle",
@ -90,7 +90,7 @@
"gravatar-url": "^3.1.0", "gravatar-url": "^3.1.0",
"helmet": "^4.1.0", "helmet": "^4.1.0",
"joi": "^17.3.0", "joi": "^17.3.0",
"js-yaml": "^3.14.0", "js-yaml": "^4.1.0",
"knex": "0.95.11", "knex": "0.95.11",
"log4js": "^6.0.0", "log4js": "^6.0.0",
"memoizee": "^0.4.15", "memoizee": "^0.4.15",
@ -109,7 +109,7 @@
"response-time": "^2.3.2", "response-time": "^2.3.2",
"serve-favicon": "^2.5.0", "serve-favicon": "^2.5.0",
"stoppable": "^1.1.0", "stoppable": "^1.1.0",
"unleash-frontend": "4.2.0", "unleash-frontend": "4.2.2",
"uuid": "^8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
@ -118,7 +118,7 @@
"@types/express-session": "1.17.4", "@types/express-session": "1.17.4",
"@types/faker": "5.5.8", "@types/faker": "5.5.8",
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@types/js-yaml": "3.12.7", "@types/js-yaml": "4.0.3",
"@types/memoizee": "0.4.6", "@types/memoizee": "0.4.6",
"@types/node": "16.6.1", "@types/node": "16.6.1",
"@types/node-fetch": "2.5.12", "@types/node-fetch": "2.5.12",
@ -127,23 +127,23 @@
"@types/stoppable": "1.1.1", "@types/stoppable": "1.1.1",
"@types/supertest": "2.0.11", "@types/supertest": "2.0.11",
"@types/uuid": "8.3.1", "@types/uuid": "8.3.1",
"@typescript-eslint/eslint-plugin": "4.32.0", "@typescript-eslint/eslint-plugin": "4.33.0",
"@typescript-eslint/parser": "4.32.0", "@typescript-eslint/parser": "4.33.0",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"coveralls": "3.1.1", "coveralls": "3.1.1",
"del-cli": "4.0.1", "del-cli": "4.0.1",
"eslint": "7.32.0", "eslint": "7.32.0",
"eslint-config-airbnb-base": "14.2.1", "eslint-config-airbnb-base": "14.2.1",
"eslint-config-airbnb-typescript": "12.3.1", "eslint-config-airbnb-typescript": "14.0.0",
"eslint-config-prettier": "8.3.0", "eslint-config-prettier": "8.3.0",
"eslint-plugin-import": "2.24.2", "eslint-plugin-import": "2.24.2",
"eslint-plugin-prettier": "4.0.0", "eslint-plugin-prettier": "4.0.0",
"faker": "5.5.3", "faker": "5.5.3",
"fetch-mock": "9.11.0", "fetch-mock": "9.11.0",
"husky": "7.0.2", "husky": "7.0.2",
"jest": "27.2.2", "jest": "27.2.4",
"jest-fetch-mock": "3.0.3", "jest-fetch-mock": "3.0.3",
"lint-staged": "11.1.2", "lint-staged": "11.2.0",
"prettier": "2.4.1", "prettier": "2.4.1",
"proxyquire": "2.1.3", "proxyquire": "2.1.3",
"source-map-support": "0.5.20", "source-map-support": "0.5.20",

View File

@ -1,10 +1,9 @@
{ {
"extends": [ "extends": ["config:base"],
"config:base"
],
"packageRules": [ "packageRules": [
{ {
"matchUpdateTypes": ["minor", "patch"], "matchUpdateTypes": ["minor", "patch"],
"matchPackagePatterns": ["*"],
"automerge": true "automerge": true
} }
] ]

View File

@ -4,14 +4,14 @@ import Addon from './addon';
import slackDefinition from './slack-definition'; import slackDefinition from './slack-definition';
import { IAddonConfig, IEvent } from '../types/model'; import { IAddonConfig, IEvent } from '../types/model';
const { import {
FEATURE_CREATED, FEATURE_CREATED,
FEATURE_UPDATED, FEATURE_UPDATED,
FEATURE_ARCHIVED, FEATURE_ARCHIVED,
FEATURE_REVIVED, FEATURE_REVIVED,
FEATURE_STALE_ON, FEATURE_STALE_ON,
FEATURE_STALE_OFF, FEATURE_STALE_OFF,
} = require('../types/events'); } from '../types/events';
export default class SlackAddon extends Addon { export default class SlackAddon extends Addon {
unleashUrl: string; unleashUrl: string;
@ -123,7 +123,7 @@ This was changed by ${createdBy}.`;
const stale = data.stale ? '("stale")' : ''; const stale = data.stale ? '("stale")' : '';
const typeStr = `*Type*: ${data.type}`; const typeStr = `*Type*: ${data.type}`;
const project = `*Project*: ${data.project}`; const project = `*Project*: ${data.project}`;
const strategies = `*Activation strategies*: \`\`\`${YAML.safeDump( const strategies = `*Activation strategies*: \`\`\`${YAML.dump(
data.strategies, data.strategies,
{ skipInvalid: true }, { skipInvalid: true },
)}\`\`\``; )}\`\`\``;

View File

@ -109,7 +109,7 @@ export default class TeamsAddon extends Addon {
const { data } = event; const { data } = event;
const typeStr = `*Type*: ${data.type}`; const typeStr = `*Type*: ${data.type}`;
const project = `*Project*: ${data.project}`; const project = `*Project*: ${data.project}`;
const strategies = `*Activation strategies*: \n${YAML.safeDump( const strategies = `*Activation strategies*: \n${YAML.dump(
data.strategies, data.strategies,
{ skipInvalid: true }, { skipInvalid: true },
)}`; )}`;

View File

@ -10,7 +10,6 @@ import { snakeCaseKeys } from '../util/snakeCase';
interface IEnvironmentsTable { interface IEnvironmentsTable {
name: string; name: string;
display_name: string;
created_at?: Date; created_at?: Date;
type: string; type: string;
sort_order: number; sort_order: number;
@ -20,7 +19,6 @@ interface IEnvironmentsTable {
const COLUMNS = [ const COLUMNS = [
'type', 'type',
'display_name',
'name', 'name',
'created_at', 'created_at',
'sort_order', 'sort_order',
@ -31,7 +29,6 @@ const COLUMNS = [
function mapRow(row: IEnvironmentsTable): IEnvironment { function mapRow(row: IEnvironmentsTable): IEnvironment {
return { return {
name: row.name, name: row.name,
displayName: row.display_name,
type: row.type, type: row.type,
sortOrder: row.sort_order, sortOrder: row.sort_order,
enabled: row.enabled, enabled: row.enabled,
@ -42,7 +39,6 @@ function mapRow(row: IEnvironmentsTable): IEnvironment {
function fieldToRow(env: IEnvironment): IEnvironmentsTable { function fieldToRow(env: IEnvironment): IEnvironmentsTable {
return { return {
name: env.name, name: env.name,
display_name: env.displayName,
type: env.type, type: env.type,
sort_order: env.sortOrder, sort_order: env.sortOrder,
enabled: env.enabled, enabled: env.enabled,
@ -95,10 +91,14 @@ export default class EnvironmentStore implements IEnvironmentStore {
throw new NotFoundError(`Could not find environment with name: ${key}`); throw new NotFoundError(`Could not find environment with name: ${key}`);
} }
async getAll(): Promise<IEnvironment[]> { async getAll(query?: Object): Promise<IEnvironment[]> {
const rows = await this.db<IEnvironmentsTable>(TABLE) let qB = this.db<IEnvironmentsTable>(TABLE)
.select('*') .select('*')
.orderBy('sort_order', 'created_at'); .orderBy('sort_order', 'created_at');
if (query) {
qB = qB.where(query);
}
const rows = await qB;
return rows.map(mapRow); return rows.map(mapRow);
} }
@ -144,7 +144,7 @@ export default class EnvironmentStore implements IEnvironmentStore {
} }
async update( async update(
env: Pick<IEnvironment, 'displayName' | 'type' | 'protected'>, env: Pick<IEnvironment, 'type' | 'protected'>,
name: string, name: string,
): Promise<IEnvironment> { ): Promise<IEnvironment> {
const updatedEnv = await this.db<IEnvironmentsTable>(TABLE) const updatedEnv = await this.db<IEnvironmentsTable>(TABLE)

View File

@ -10,7 +10,10 @@ import { DB_TIME } from '../metric-events';
import { IFeatureEnvironment } from '../types/model'; import { IFeatureEnvironment } from '../types/model';
import NotFoundError from '../error/notfound-error'; import NotFoundError from '../error/notfound-error';
const T = { featureEnvs: 'feature_environments' }; const T = {
featureEnvs: 'feature_environments',
featureStrategies: 'feature_strategies',
};
interface IFeatureEnvironmentRow { interface IFeatureEnvironmentRow {
environment: string; environment: string;
@ -92,6 +95,22 @@ export class FeatureEnvironmentStore implements IFeatureEnvironmentStore {
})); }));
} }
async disableEnvironmentIfNoStrategies(
featureName: string,
environment: string,
): Promise<void> {
const result = await this.db.raw(
`SELECT EXISTS (SELECT 1 FROM ${T.featureStrategies} WHERE feature_name = ? AND environment = ?) AS enabled`,
[featureName, environment],
);
const { enabled } = result.rows[0];
if (!enabled) {
await this.db(T.featureEnvs)
.update({ enabled: false })
.where({ feature_name: featureName, environment });
}
}
async addEnvironmentToFeature( async addEnvironmentToFeature(
featureName: string, featureName: string,
environment: string, environment: string,

View File

@ -213,7 +213,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'environments.name as environment_name', 'environments.name as environment_name',
'environments.type as environment_type', 'environments.type as environment_type',
'environments.sort_order as environment_sort_order', 'environments.sort_order as environment_sort_order',
'environments.display_name as environment_display_name',
'feature_strategies.id as strategy_id', 'feature_strategies.id as strategy_id',
'feature_strategies.strategy_name as strategy_name', 'feature_strategies.strategy_name as strategy_name',
'feature_strategies.parameters as parameters', 'feature_strategies.parameters as parameters',
@ -266,7 +265,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
env.enabled = r.enabled; env.enabled = r.enabled;
env.type = r.environment_type; env.type = r.environment_type;
env.sortOrder = r.environment_sort_order; env.sortOrder = r.environment_sort_order;
env.displayName = r.environment_display_name;
if (!env.strategies) { if (!env.strategies) {
env.strategies = []; env.strategies = [];
} }
@ -300,7 +298,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
private getEnvironment(r: any): IEnvironmentOverview { private getEnvironment(r: any): IEnvironmentOverview {
return { return {
name: r.environment, name: r.environment,
displayName: r.display_name,
enabled: r.enabled, enabled: r.enabled,
type: r.environment_type, type: r.environment_type,
sortOrder: r.environment_sort_order, sortOrder: r.environment_sort_order,
@ -321,7 +318,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'features.stale as stale', 'features.stale as stale',
'feature_environments.enabled as enabled', 'feature_environments.enabled as enabled',
'feature_environments.environment as environment', 'feature_environments.environment as environment',
'environments.display_name as display_name',
'environments.type as environment_type', 'environments.type as environment_type',
'environments.sort_order as environment_sort_order', 'environments.sort_order as environment_sort_order',
) )

View File

@ -45,16 +45,20 @@ export default class FeatureToggleClientStore
r: any, r: any,
includeId: boolean = true, includeId: boolean = true,
): IStrategyConfig { ): IStrategyConfig {
const strategy = { if (includeId) {
name: r.strategy_name, return {
constraints: r.constraints || [], name: r.strategy_name,
parameters: r.parameters, constraints: r.constraints || [],
id: r.strategy_id, parameters: r.parameters,
}; id: r.strategy_id,
if (!includeId) { };
delete strategy.id; } else {
return {
name: r.strategy_name,
constraints: r.constraints || [],
parameters: r.parameters,
};
} }
return strategy;
} }
private async getAll( private async getAll(
@ -74,28 +78,29 @@ export default class FeatureToggleClientStore
'features.variants as variants', 'features.variants as variants',
'features.created_at as created_at', 'features.created_at as created_at',
'features.last_seen_at as last_seen_at', 'features.last_seen_at as last_seen_at',
'feature_environments.enabled as enabled', 'fe.enabled as enabled',
'feature_environments.environment as environment', 'fe.environment as environment',
'feature_strategies.id as strategy_id', 'fs.id as strategy_id',
'feature_strategies.strategy_name as strategy_name', 'fs.strategy_name as strategy_name',
'feature_strategies.parameters as parameters', 'fs.parameters as parameters',
'feature_strategies.constraints as constraints', 'fs.constraints as constraints',
) )
.fullOuterJoin( .fullOuterJoin(
'feature_environments', this.db('feature_strategies')
'feature_environments.feature_name', .select('*')
.where({ environment })
.as('fs'),
'fs.feature_name',
'features.name',
)
.fullOuterJoin(
this.db('feature_environments')
.select('feature_name', 'enabled', 'environment')
.where({ environment })
.as('fe'),
'fe.feature_name',
'features.name', 'features.name',
) )
.fullOuterJoin('feature_strategies', function () {
this.on(
'feature_strategies.feature_name',
'features.name',
).andOn(
'feature_strategies.environment',
'feature_environments.environment',
);
})
.where('feature_environments.environment', environment)
.where({ archived }); .where({ archived });
if (featureQuery) { if (featureQuery) {
@ -117,6 +122,7 @@ export default class FeatureToggleClientStore
); );
} }
} }
const rows = await query; const rows = await query;
stopTimer(); stopTimer();
const featureToggles = rows.reduce((acc, r) => { const featureToggles = rows.reduce((acc, r) => {
@ -132,7 +138,7 @@ export default class FeatureToggleClientStore
if (r.strategy_name) { if (r.strategy_name) {
feature.strategies.push(this.getAdminStrategy(r, isAdmin)); feature.strategies.push(this.getAdminStrategy(r, isAdmin));
} }
feature.enabled = r.enabled; feature.enabled = !!r.enabled;
feature.name = r.name; feature.name = r.name;
feature.description = r.description; feature.description = r.description;
feature.project = r.project; feature.project = r.project;

View File

@ -6,6 +6,7 @@ import { IProject } from '../types/model';
import { import {
IProjectHealthUpdate, IProjectHealthUpdate,
IProjectInsert, IProjectInsert,
IProjectQuery,
IProjectStore, IProjectStore,
} from '../types/stores/project-store'; } from '../types/stores/project-store';
import { DEFAULT_ENV } from '../util/constants'; import { DEFAULT_ENV } from '../util/constants';
@ -43,10 +44,11 @@ class ProjectStore implements IProjectStore {
return present; return present;
} }
async getAll(): Promise<IProject[]> { async getAll(query: IProjectQuery = {}): Promise<IProject[]> {
const rows = await this.db const rows = await this.db
.select(COLUMNS) .select(COLUMNS)
.from(TABLE) .from(TABLE)
.where(query)
.orderBy('name', 'asc'); .orderBy('name', 'asc');
return rows.map(this.mapRow); return rows.map(this.mapRow);

View File

@ -300,12 +300,13 @@ export default class ProjectFeaturesController extends Controller {
res: Response, res: Response,
): Promise<void> { ): Promise<void> {
this.logger.info('Deleting strategy'); this.logger.info('Deleting strategy');
const { environment, projectId } = req.params; const { environment, projectId, featureName } = req.params;
const userName = extractUsername(req); const userName = extractUsername(req);
const { strategyId } = req.params; const { strategyId } = req.params;
this.logger.info(strategyId); this.logger.info(strategyId);
const strategy = await this.featureService.deleteStrategy( const strategy = await this.featureService.deleteStrategy(
strategyId, strategyId,
featureName,
userName, userName,
projectId, projectId,
environment, environment,

View File

@ -1,15 +1,28 @@
import { Request, Response } from 'express';
import Controller from '../../controller'; import Controller from '../../controller';
import { IUnleashConfig } from '../../../types/option'; import { IUnleashConfig } from '../../../types/option';
import { IUnleashServices } from '../../../types/services'; import { IUnleashServices } from '../../../types/services';
import ProjectFeaturesController from './features'; import ProjectFeaturesController from './features';
import EnvironmentsController from './environments'; import EnvironmentsController from './environments';
import ProjectHealthReport from './health-report'; import ProjectHealthReport from './health-report';
import ProjectService from '../../../services/project-service';
export default class ProjectApi extends Controller { export default class ProjectApi extends Controller {
private projectService: ProjectService;
constructor(config: IUnleashConfig, services: IUnleashServices) { constructor(config: IUnleashConfig, services: IUnleashServices) {
super(config); super(config);
this.projectService = services.projectService;
this.get('/', this.getProjects);
this.use('/', new ProjectFeaturesController(config, services).router); this.use('/', new ProjectFeaturesController(config, services).router);
this.use('/', new EnvironmentsController(config, services).router); this.use('/', new EnvironmentsController(config, services).router);
this.use('/', new ProjectHealthReport(config, services).router); this.use('/', new ProjectHealthReport(config, services).router);
} }
async getProjects(req: Request, res: Response): Promise<void> {
const projects = await this.projectService.getProjects({
id: 'default',
});
res.json({ version: 1, projects }).end();
}
} }

View File

@ -50,7 +50,7 @@ class StateController extends Controller {
// @ts-ignore // @ts-ignore
if (mime.getType(req.file.originalname) === 'text/yaml') { if (mime.getType(req.file.originalname) === 'text/yaml') {
// @ts-ignore // @ts-ignore
data = YAML.safeLoad(req.file.buffer); data = YAML.load(req.file.buffer);
} else { } else {
// @ts-ignore // @ts-ignore
data = JSON.parse(req.file.buffer); data = JSON.parse(req.file.buffer);
@ -93,7 +93,7 @@ class StateController extends Controller {
if (downloadFile) { if (downloadFile) {
res.attachment(`export-${timestamp}.yml`); res.attachment(`export-${timestamp}.yml`);
} }
res.type('yaml').send(YAML.safeDump(data, { skipInvalid: true })); res.type('yaml').send(YAML.dump(data, { skipInvalid: true }));
} else { } else {
if (downloadFile) { if (downloadFile) {
res.attachment(`export-${timestamp}.json`); res.attachment(`export-${timestamp}.json`);

View File

@ -140,8 +140,12 @@ export default class ClientMetricsService {
): Promise<void> { ): Promise<void> {
const value = await clientMetricsSchema.validateAsync(data); const value = await clientMetricsSchema.validateAsync(data);
const toggleNames = Object.keys(value.bucket.toggles); const toggleNames = Object.keys(value.bucket.toggles);
await this.featureToggleStore.setLastSeen(toggleNames);
await this.clientMetricsStore.insert(value); if (toggleNames.length > 0) {
await this.featureToggleStore.setLastSeen(toggleNames);
await this.clientMetricsStore.insert(value);
}
await this.clientInstanceStore.insert({ await this.clientInstanceStore.insert({
appName: value.appName, appName: value.appName,
instanceId: value.instanceId, instanceId: value.instanceId,

View File

@ -3,7 +3,6 @@ import { IUnleashStores } from '../types/stores';
import { Logger } from '../logger'; import { Logger } from '../logger';
import { IEventStore } from '../types/stores/event-store'; import { IEventStore } from '../types/stores/event-store';
import { IEvent } from '../types/model'; import { IEvent } from '../types/model';
import { FEATURE_METADATA_UPDATED } from '../types/events';
export default class EventService { export default class EventService {
private logger: Logger; private logger: Logger;
@ -23,10 +22,7 @@ export default class EventService {
} }
async getEventsForToggle(name: string): Promise<IEvent[]> { async getEventsForToggle(name: string): Promise<IEvent[]> {
const events = await this.eventStore.getEventsFilterByType(name); return this.eventStore.getEventsFilterByType(name);
return events.filter(
(e: IEvent) => e.type !== FEATURE_METADATA_UPDATED,
);
} }
async getEventsForProject(project: string): Promise<IEvent[]> { async getEventsForProject(project: string): Promise<IEvent[]> {

View File

@ -226,6 +226,7 @@ class FeatureToggleServiceV2 {
*/ */
async deleteStrategy( async deleteStrategy(
id: string, id: string,
featureName: string,
userName: string, userName: string,
project: string = 'default', project: string = 'default',
environment: string = DEFAULT_ENV, environment: string = DEFAULT_ENV,
@ -240,6 +241,11 @@ class FeatureToggleServiceV2 {
id, id,
}, },
}); });
// If there are no strategies left for environment disable it
await this.featureEnvironmentStore.disableEnvironmentIfNoStrategies(
featureName,
environment,
);
} }
async getStrategiesForEnvironment( async getStrategiesForEnvironment(
@ -576,7 +582,7 @@ class FeatureToggleServiceV2 {
? FEATURE_ENVIRONMENT_ENABLED ? FEATURE_ENVIRONMENT_ENABLED
: FEATURE_ENVIRONMENT_DISABLED, : FEATURE_ENVIRONMENT_DISABLED,
createdBy: userName, createdBy: userName,
data, data: { name: featureName },
tags, tags,
project: projectId, project: projectId,
environment, environment,

View File

@ -63,6 +63,9 @@ export default class ProjectHealthService {
archived: boolean = false, archived: boolean = false,
): Promise<IProjectOverview> { ): Promise<IProjectOverview> {
const project = await this.projectStore.get(projectId); const project = await this.projectStore.get(projectId);
const environments = await this.projectStore.getEnvironmentsForProject(
projectId,
);
const features = await this.featureToggleService.getFeatureOverview( const features = await this.featureToggleService.getFeatureOverview(
projectId, projectId,
archived, archived,
@ -72,6 +75,7 @@ export default class ProjectHealthService {
name: project.name, name: project.name,
description: project.description, description: project.description,
health: project.health, health: project.health,
environments,
features, features,
members, members,
version: 1, version: 1,

View File

@ -24,13 +24,12 @@ import { IEnvironmentStore } from '../types/stores/environment-store';
import { IFeatureTypeStore } from '../types/stores/feature-type-store'; import { IFeatureTypeStore } from '../types/stores/feature-type-store';
import { IFeatureToggleStore } from '../types/stores/feature-toggle-store'; import { IFeatureToggleStore } from '../types/stores/feature-toggle-store';
import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store'; import { IFeatureEnvironmentStore } from '../types/stores/feature-environment-store';
import { IProjectStore } from '../types/stores/project-store'; import { IProjectQuery, IProjectStore } from '../types/stores/project-store';
import { IRole } from '../types/stores/access-store'; import { IRole } from '../types/stores/access-store';
import { IEventStore } from '../types/stores/event-store'; import { IEventStore } from '../types/stores/event-store';
import FeatureToggleServiceV2 from './feature-toggle-service-v2'; import FeatureToggleServiceV2 from './feature-toggle-service-v2';
import { CREATE_FEATURE, UPDATE_FEATURE } from '../types/permissions'; import { CREATE_FEATURE, UPDATE_FEATURE } from '../types/permissions';
import NoAccessError from '../error/no-access-error'; import NoAccessError from '../error/no-access-error';
import { DEFAULT_ENV } from '../util/constants';
const getCreatedBy = (user: User) => user.email || user.username; const getCreatedBy = (user: User) => user.email || user.username;
@ -92,8 +91,8 @@ export default class ProjectService {
this.logger = config.getLogger('services/project-service.js'); this.logger = config.getLogger('services/project-service.js');
} }
async getProjects(): Promise<IProjectWithCount[]> { async getProjects(query?: IProjectQuery): Promise<IProjectWithCount[]> {
const projects = await this.store.getAll(); const projects = await this.store.getAll(query);
const projectsWithCount = await Promise.all( const projectsWithCount = await Promise.all(
projects.map(async (p) => { projects.map(async (p) => {
let featureCount = 0; let featureCount = 0;
@ -123,8 +122,17 @@ export default class ProjectService {
await this.store.create(data); await this.store.create(data);
// TODO: we should only connect to enabled environments const enabledEnvironments = await this.environmentStore.getAll({
await this.featureEnvironmentStore.connectProject(DEFAULT_ENV, data.id); enabled: true,
});
await Promise.all(
enabledEnvironments.map(async (e) => {
await this.featureEnvironmentStore.connectProject(
e.name,
data.id,
);
}),
);
await this.accessService.createDefaultProjectRoles(user, data.id); await this.accessService.createDefaultProjectRoles(user, data.id);
@ -302,6 +310,9 @@ export default class ProjectService {
archived: boolean = false, archived: boolean = false,
): Promise<IProjectOverview> { ): Promise<IProjectOverview> {
const project = await this.store.get(projectId); const project = await this.store.get(projectId);
const environments = await this.store.getEnvironmentsForProject(
projectId,
);
const features = await this.featureToggleService.getFeatureOverview( const features = await this.featureToggleService.getFeatureOverview(
projectId, projectId,
archived, archived,
@ -309,6 +320,7 @@ export default class ProjectService {
const members = await this.store.getMembers(projectId); const members = await this.store.getMembers(projectId);
return { return {
name: project.name, name: project.name,
environments,
description: project.description, description: project.description,
health: project.health, health: project.health,
features, features,

View File

@ -533,12 +533,10 @@ test('exporting to new format works', async () => {
}); });
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'dev', name: 'dev',
displayName: 'Development',
type: 'development', type: 'development',
}); });
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'prod', name: 'prod',
displayName: 'Production',
type: 'production', type: 'production',
}); });
await stores.featureToggleStore.create('fancy', { await stores.featureToggleStore.create('fancy', {
@ -575,12 +573,10 @@ test('featureStrategies can keep existing', async () => {
}); });
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'dev', name: 'dev',
displayName: 'Development',
type: 'development', type: 'development',
}); });
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'prod', name: 'prod',
displayName: 'Production',
type: 'production', type: 'production',
}); });
await stores.featureToggleStore.create('fancy', { await stores.featureToggleStore.create('fancy', {
@ -623,12 +619,10 @@ test('featureStrategies should not keep existing if dropBeforeImport', async ()
}); });
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'dev', name: 'dev',
displayName: 'Development',
type: 'development', type: 'development',
}); });
await stores.environmentStore.create({ await stores.environmentStore.create({
name: 'prod', name: 'prod',
displayName: 'Production',
type: 'production', type: 'production',
}); });
await stores.featureToggleStore.create('fancy', { await stores.featureToggleStore.create('fancy', {

View File

@ -12,8 +12,7 @@ export const readFile: (file: string) => Promise<string> = (file) =>
export const parseFile: (file: string, data: string) => any = ( export const parseFile: (file: string, data: string) => any = (
file: string, file: string,
data: string, data: string,
) => ) => (mime.lookup(file) === 'text/yaml' ? YAML.load(data) : JSON.parse(data));
mime.lookup(file) === 'text/yaml' ? YAML.safeLoad(data) : JSON.parse(data);
export const filterExisting: ( export const filterExisting: (
keepExisting: boolean, keepExisting: boolean,

View File

@ -103,7 +103,6 @@ export interface IVariant {
export interface IEnvironment { export interface IEnvironment {
name: string; name: string;
displayName: string;
type: string; type: string;
sortOrder: number; sortOrder: number;
enabled: boolean; enabled: boolean;
@ -112,14 +111,13 @@ export interface IEnvironment {
export interface IEnvironmentCreate { export interface IEnvironmentCreate {
name: string; name: string;
displayName: string;
type: string; type: string;
sortOrder?: number; sortOrder?: number;
enabled?: boolean;
} }
export interface IEnvironmentOverview { export interface IEnvironmentOverview {
name: string; name: string;
displayName: string;
enabled: boolean; enabled: boolean;
type: string; type: string;
sortOrder: number; sortOrder: number;
@ -137,6 +135,7 @@ export interface IFeatureOverview {
export interface IProjectOverview { export interface IProjectOverview {
name: string; name: string;
description: string; description: string;
environments: string[];
features: IFeatureOverview[]; features: IFeatureOverview[];
members: number; members: number;
version: number; version: number;

View File

@ -5,7 +5,7 @@ export interface IEnvironmentStore extends Store<IEnvironment, string> {
exists(name: string): Promise<boolean>; exists(name: string): Promise<boolean>;
create(env: IEnvironmentCreate): Promise<IEnvironment>; create(env: IEnvironmentCreate): Promise<IEnvironment>;
update( update(
env: Pick<IEnvironment, 'displayName' | 'type' | 'protected'>, env: Pick<IEnvironment, 'type' | 'protected'>,
name: string, name: string,
): Promise<IEnvironment>; ): Promise<IEnvironment>;
updateProperty( updateProperty(

View File

@ -34,7 +34,10 @@ export interface IFeatureEnvironmentStore
environment: string, environment: string,
enabled: boolean, enabled: boolean,
): Promise<void>; ): Promise<void>;
disableEnvironmentIfNoStrategies(
featureName: string,
environment: string,
): Promise<void>;
disconnectFeatures(environment: string, project: string): Promise<void>; disconnectFeatures(environment: string, project: string): Promise<void>;
connectFeatures(environment: string, projectId: string): Promise<void>; connectFeatures(environment: string, projectId: string): Promise<void>;

View File

@ -17,6 +17,10 @@ export interface IProjectHealthUpdate {
health: number; health: number;
} }
export interface IProjectQuery {
id?: string;
}
export interface IProjectStore extends Store<IProject, string> { export interface IProjectStore extends Store<IProject, string> {
hasProject(id: string): Promise<boolean>; hasProject(id: string): Promise<boolean>;
updateHealth(healthUpdate: IProjectHealthUpdate): Promise<void>; updateHealth(healthUpdate: IProjectHealthUpdate): Promise<void>;
@ -28,4 +32,5 @@ export interface IProjectStore extends Store<IProject, string> {
getEnvironmentsForProject(id: string): Promise<string[]>; getEnvironmentsForProject(id: string): Promise<string[]>;
getMembers(projectId: string): Promise<number>; getMembers(projectId: string): Promise<number>;
count(): Promise<number>; count(): Promise<number>;
getAll(query?: IProjectQuery): Promise<IProject[]>;
} }

View File

@ -0,0 +1,17 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(
`ALTER TABLE environments
DROP COLUMN display_name`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`ALTER TABLE environments
ADD COLUMN display_name TEXT`,
cb,
);
};

View File

@ -0,0 +1,23 @@
'use strict';
exports.up = function (db, cb) {
db.runSql(
`
INSERT INTO environments(name, type, enabled)
VALUES ('development', 'development', true),
('production', 'production', true);
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
DELETE
FROM environments
WHERE name IN ('development', 'production');
`,
cb,
);
};

View File

@ -0,0 +1,22 @@
exports.up = function (db, cb) {
db.runSql(
`
INSERT INTO project_environments(project_id, environment_name)
SELECT id, 'default'
FROM projects
ON CONFLICT DO NOTHING;
`,
cb,
);
};
exports.down = function (db, cb) {
db.runSql(
`
DELETE
FROM project_environments
WHERE environment_name = 'default';
`,
cb,
);
};

View File

@ -24,7 +24,6 @@ test('Can list all existing environments', async () => {
.expect((res) => { .expect((res) => {
expect(res.body.version).toBe(1); expect(res.body.version).toBe(1);
expect(res.body.environments[0]).toStrictEqual({ expect(res.body.environments[0]).toStrictEqual({
displayName: 'Default Environment',
name: DEFAULT_ENV, name: DEFAULT_ENV,
enabled: true, enabled: true,
sortOrder: 1, sortOrder: 1,
@ -38,7 +37,6 @@ test('Can update sort order', async () => {
const envName = 'update-sort-order'; const envName = 'update-sort-order';
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
await app.request await app.request
@ -81,7 +79,6 @@ test('Can update environment enabled status', async () => {
const envName = 'enable-environment'; const envName = 'enable-environment';
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
await app.request await app.request
@ -95,7 +92,6 @@ test('Can update environment disabled status', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
@ -128,7 +124,6 @@ test('Can get specific environment', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
type: 'production', type: 'production',
displayName: 'Fun!',
}); });
await app.request await app.request
.get(`/api/admin/environments/${envName}`) .get(`/api/admin/environments/${envName}`)

View File

@ -36,7 +36,6 @@ test('Should add environment to project', async () => {
// Endpoint to create env does not exists anymore // Endpoint to create env does not exists anymore
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: 'test', name: 'test',
displayName: 'Test Env',
type: 'test', type: 'test',
}); });
await app.request await app.request
@ -67,7 +66,6 @@ test('Should remove environment from project', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name, name,
displayName: 'Test Env',
type: 'test', type: 'test',
}); });

View File

@ -174,7 +174,6 @@ test('Project overview includes environment connected to feature', async () => {
}); });
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: 'project-overview', name: 'project-overview',
displayName: 'Project Overview',
type: 'production', type: 'production',
}); });
await app.request await app.request
@ -208,7 +207,6 @@ test('Disconnecting environment from project, removes environment from features
}); });
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: 'dis-project-overview', name: 'dis-project-overview',
displayName: 'Project Overview',
type: 'production', type: 'production',
}); });
await app.request await app.request
@ -236,7 +234,6 @@ test('Can enable/disable environment for feature with strategies', async () => {
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
// Connect environment to project // Connect environment to project
@ -386,7 +383,6 @@ test('Can get environment info for feature toggle', async () => {
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
// Connect environment to project // Connect environment to project
@ -550,7 +546,6 @@ test('Can add strategy to feature toggle to a "some-env-2"', async () => {
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
// Connect environment to project // Connect environment to project
@ -591,13 +586,11 @@ test('Environments are returned in sortOrder', async () => {
// Create environments // Create environments
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: sortedLast, name: sortedLast,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
sortOrder: 8000, sortOrder: 8000,
}); });
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: sortedSecond, name: sortedSecond,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
sortOrder: 8, sortOrder: 8,
}); });
@ -659,7 +652,6 @@ test('Can get strategies for feature and environment', async () => {
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
// Connect environment to project // Connect environment to project
@ -714,7 +706,6 @@ test('Can update a strategy based on id', async () => {
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
// Connect environment to project // Connect environment to project
@ -767,7 +758,6 @@ test('Trying to update a non existing feature strategy should yield 404', async
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
// Connect environment to project // Connect environment to project
@ -798,7 +788,6 @@ test('Can patch a strategy based on id', async () => {
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'test', type: 'test',
}); });
// Connect environment to project // Connect environment to project
@ -851,7 +840,6 @@ test('Trying to get a non existing feature strategy should yield 404', async ()
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'production', type: 'production',
}); });
// Connect environment to project // Connect environment to project
@ -880,7 +868,6 @@ test('Can not enable environment for feature without strategies', async () => {
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: environment, name: environment,
displayName: 'Enable feature for environment',
type: 'test', type: 'test',
}); });
// Connect environment to project // Connect environment to project
@ -922,7 +909,6 @@ test('Enabling environment creates a FEATURE_ENVIRONMENT_ENABLED event', async (
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: environment, name: environment,
displayName: 'Enable feature for environment',
type: 'test', type: 'test',
}); });
// Connect environment to project // Connect environment to project
@ -965,7 +951,6 @@ test('Disabling environment creates a FEATURE_ENVIRONMENT_DISABLED event', async
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: environment, name: environment,
displayName: 'Enable feature for environment',
type: 'test', type: 'test',
}); });
// Connect environment to project // Connect environment to project
@ -1009,7 +994,6 @@ test('Can delete strategy from feature toggle', async () => {
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'test', type: 'test',
}); });
// Connect environment to project // Connect environment to project
@ -1053,7 +1037,6 @@ test('List of strategies should respect sortOrder', async () => {
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Enable feature for environment',
type: 'test', type: 'test',
}); });
// Connect environment to project // Connect environment to project
@ -1084,12 +1067,10 @@ test('Feature strategies list should respect strategy sortorders for each enviro
// Create environment // Create environment
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: envName, name: envName,
displayName: 'Sort orders within environment',
type: 'test', type: 'test',
}); });
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: secondEnv, name: secondEnv,
displayName: 'Sort orders within environment',
type: 'test', type: 'test',
}); });
// Connect environment to project // Connect environment to project
@ -1127,3 +1108,140 @@ test('Feature strategies list should respect strategy sortorders for each enviro
expect(strategies[1].sortOrder).toBe(sortOrderSecond); expect(strategies[1].sortOrder).toBe(sortOrderSecond);
expect(strategies[2].sortOrder).toBe(sortOrderDefault); expect(strategies[2].sortOrder).toBe(sortOrderDefault);
}); });
test('Deleting last strategy for feature environment should disable that environment', async () => {
const envName = 'last_strategy_delete_env';
const featureName = 'last_strategy_delete_feature';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'test',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
let strategyId;
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200)
.expect((res) => {
strategyId = res.body.id;
});
// Enable feature_environment
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/on`,
)
.send({})
.expect(200);
await app.request
.get(
`/api/admin/projects/default/features/${featureName}/environments/${envName}`,
)
.expect(200)
.expect((res) => {
expect(res.body.enabled).toBeTruthy();
});
// Delete last strategy, this should also disable the environment
await app.request.delete(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies/${strategyId}`,
);
await app.request
.get(
`/api/admin/projects/default/features/${featureName}/environments/${envName}`,
)
.expect(200)
.expect((res) => {
expect(res.body.enabled).toBeFalsy();
});
});
test('Deleting strategy for feature environment should not disable that environment as long as there are other strategies', async () => {
const envName = 'any_strategy_delete_env';
const featureName = 'any_strategy_delete_feature';
// Create environment
await db.stores.environmentStore.create({
name: envName,
type: 'test',
});
// Connect environment to project
await app.request
.post('/api/admin/projects/default/environments')
.send({
environment: envName,
})
.expect(200);
await app.request
.post('/api/admin/projects/default/features')
.send({ name: featureName })
.expect(201);
let strategyId;
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
)
.send({
name: 'default',
parameters: {
userId: 'string',
},
})
.expect(200)
.expect((res) => {
strategyId = res.body.id;
});
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies`,
)
.send({
name: 'default',
parameters: {
customerId: 'string',
},
})
.expect(200);
// Enable feature_environment
await app.request
.post(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/on`,
)
.send({})
.expect(200);
await app.request
.get(
`/api/admin/projects/default/features/${featureName}/environments/${envName}`,
)
.expect(200)
.expect((res) => {
expect(res.body.enabled).toBeTruthy();
});
// Delete a strategy, this should also disable the environment
await app.request.delete(
`/api/admin/projects/default/features/${featureName}/environments/${envName}/strategies/${strategyId}`,
);
await app.request
.get(
`/api/admin/projects/default/features/${featureName}/environments/${envName}`,
)
.expect(200)
.expect((res) => {
expect(res.body.enabled).toBeTruthy();
});
});

View File

@ -49,6 +49,8 @@ test('Project with no stale toggles should have 100% health rating', async () =>
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect((res) => { .expect((res) => {
expect(res.body.health).toBe(100); expect(res.body.health).toBe(100);
expect(res.body.environments).toHaveLength(1);
expect(res.body.environments).toStrictEqual(['default']);
}); });
}); });

View File

@ -0,0 +1,32 @@
import dbInit from '../../../helpers/database-init';
import { setupApp } from '../../../helpers/test-helper';
import getLogger from '../../../../fixtures/no-logger';
import ProjectStore from '../../../../../lib/db/project-store';
let app;
let db;
let projectStore: ProjectStore;
beforeAll(async () => {
db = await dbInit('projects_api_serial', getLogger);
app = await setupApp(db.stores);
projectStore = db.stores.projectStore;
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('Should ONLY return default project', async () => {
projectStore.create({ id: 'test2', name: 'test', description: '' });
const { body } = await app.request
.get('/api/admin/projects')
.expect(200)
.expect('Content-Type', /json/);
expect(body.projects).toHaveLength(1);
expect(body.projects[0].id).toBe('default');
});

View File

@ -142,7 +142,6 @@ test('Can roundtrip. I.e. export and then import', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: environmentId, name: environmentId,
type: 'test', type: 'test',
displayName: 'Environment for export',
}); });
await db.stores.projectStore.create({ await db.stores.projectStore.create({
name: projectId, name: projectId,
@ -191,7 +190,6 @@ test('Roundtrip with tags works', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: environmentId, name: environmentId,
type: 'test', type: 'test',
displayName: 'Environment for export',
}); });
await db.stores.projectStore.create({ await db.stores.projectStore.create({
name: projectId, name: projectId,
@ -253,7 +251,6 @@ test('Roundtrip with strategies in multiple environments works', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: environmentId, name: environmentId,
type: 'test', type: 'test',
displayName: 'Environment for export',
}); });
await db.stores.projectStore.create({ await db.stores.projectStore.create({
name: projectId, name: projectId,

View File

@ -176,7 +176,6 @@ test('Can get strategies for specific environment', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: 'testing', name: 'testing',
displayName: 'simple test',
type: 'test', type: 'test',
}); });

View File

@ -0,0 +1,70 @@
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
import dbInit, { ITestDb } from '../../helpers/database-init';
import getLogger from '../../../fixtures/no-logger';
let app: IUnleashTest;
let db: ITestDb;
const featureName = 'feature.default.1';
beforeAll(async () => {
db = await dbInit('feature_api_client', getLogger);
app = await setupApp(db.stores);
await app.services.featureToggleServiceV2.createFeatureToggle(
'default',
{
name: featureName,
description: 'the #1 feature',
},
'test',
);
await app.services.featureToggleServiceV2.createStrategy(
{ name: 'default', constraints: [], parameters: {} },
'default',
featureName,
'test',
);
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('returns feature toggle for default env', async () => {
await app.services.featureToggleServiceV2.updateEnabled(
'default',
'feature.default.1',
'default',
true,
'test',
);
await app.request
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(1);
expect(res.body.features[0].enabled).toBe(true);
expect(res.body.features[0].strategies).toHaveLength(1);
});
});
test('returns feature toggle for default env even if it is removed from project', async () => {
await app.services.environmentService.removeEnvironmentFromProject(
'default',
'default',
);
await app.request
.get('/api/client/features')
.expect('Content-Type', /json/)
.expect(200)
.expect((res) => {
expect(res.body.features).toHaveLength(1);
expect(res.body.features[0].enabled).toBe(false);
expect(res.body.features[0].strategies).toHaveLength(1);
});
});

View File

@ -28,7 +28,6 @@ beforeAll(async () => {
await environmentStore.create({ await environmentStore.create({
name: environment, name: environment,
displayName: '',
type: 'test', type: 'test',
}); });

View File

@ -23,7 +23,6 @@ test('should enrich metrics with environment from api-token', async () => {
await environmentStore.create({ await environmentStore.create({
name: 'some', name: 'some',
displayName: '',
type: 'test', type: 'test',
}); });

View File

@ -30,8 +30,8 @@
"environments": [ "environments": [
{ {
"name": "default", "name": "default",
"displayName": "Default Environment",
"type": "production", "type": "production",
"sortOrder": 1, "sortOrder": 1,
"enabled": true, "enabled": true,
"protected": true "protected": true

View File

@ -22,7 +22,6 @@ afterAll(async () => {
test('Can get environment', async () => { test('Can get environment', async () => {
const created = await db.stores.environmentStore.create({ const created = await db.stores.environmentStore.create({
name: 'testenv', name: 'testenv',
displayName: 'Environment for testing',
type: 'production', type: 'production',
}); });
@ -33,7 +32,6 @@ test('Can get environment', async () => {
test('Can get all', async () => { test('Can get all', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: 'testenv2', name: 'testenv2',
displayName: 'Environment for testing',
type: 'production', type: 'production',
}); });
@ -44,7 +42,6 @@ test('Can get all', async () => {
test('Can connect environment to project', async () => { test('Can connect environment to project', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: 'test-connection', name: 'test-connection',
displayName: '',
type: 'production', type: 'production',
}); });
await stores.featureToggleStore.create('default', { await stores.featureToggleStore.create('default', {
@ -63,7 +60,6 @@ test('Can connect environment to project', async () => {
expect(f.environments).toEqual([ expect(f.environments).toEqual([
{ {
name: 'test-connection', name: 'test-connection',
displayName: '',
enabled: false, enabled: false,
sortOrder: 9999, sortOrder: 9999,
type: 'production', type: 'production',
@ -75,7 +71,6 @@ test('Can connect environment to project', async () => {
test('Can remove environment from project', async () => { test('Can remove environment from project', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: 'removal-test', name: 'removal-test',
displayName: '',
type: 'production', type: 'production',
}); });
await stores.featureToggleStore.create('default', { await stores.featureToggleStore.create('default', {
@ -92,7 +87,6 @@ test('Can remove environment from project', async () => {
expect(f.environments).toEqual([ expect(f.environments).toEqual([
{ {
name: 'removal-test', name: 'removal-test',
displayName: '',
enabled: false, enabled: false,
sortOrder: 9999, sortOrder: 9999,
type: 'production', type: 'production',
@ -113,7 +107,6 @@ test('Can remove environment from project', async () => {
test('Adding same environment twice should throw a NameExistsError', async () => { test('Adding same environment twice should throw a NameExistsError', async () => {
await db.stores.environmentStore.create({ await db.stores.environmentStore.create({
name: 'uniqueness-test', name: 'uniqueness-test',
displayName: '',
type: 'production', type: 'production',
}); });
await service.removeEnvironmentFromProject('test-connection', 'default'); await service.removeEnvironmentFromProject('test-connection', 'default');

View File

@ -132,7 +132,7 @@ test('should validate name, legal', async () => {
expect(result).toBe(true); expect(result).toBe(true);
}); });
test('should not be able to create exiting project', async () => { test('should not be able to create existing project', async () => {
const project = { const project = {
id: 'test-delete', id: 'test-delete',
name: 'New project', name: 'New project',
@ -510,3 +510,29 @@ test('should change project when checks pass', async () => {
expect(updatedFeature.project).toBe(projectDestination.id); expect(updatedFeature.project).toBe(projectDestination.id);
}); });
test('A newly created project only gets connected to enabled environments', async () => {
const project = {
id: 'environment-test',
name: 'New environment project',
description: 'Blah',
};
const enabledEnv = 'connection_test';
await db.stores.environmentStore.create({
name: enabledEnv,
type: 'test',
});
const disabledEnv = 'do_not_connect';
await db.stores.environmentStore.create({
name: disabledEnv,
type: 'test',
enabled: false,
});
await projectService.createProject(project, user);
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();
});

View File

@ -123,7 +123,6 @@ test('should add environment to project', async () => {
await environmentStore.create({ await environmentStore.create({
name: 'test', name: 'test',
displayName: 'Test Env',
type: 'production', type: 'production',
}); });

View File

@ -37,7 +37,7 @@ export default class FakeEnvironmentStore implements IEnvironmentStore {
} }
async update( async update(
env: Pick<IEnvironment, 'displayName' | 'type' | 'protected'>, env: Pick<IEnvironment, 'type' | 'protected'>,
name: string, name: string,
): Promise<IEnvironment> { ): Promise<IEnvironment> {
const found = this.environments.find( const found = this.environments.find(

View File

@ -148,4 +148,13 @@ export default class FakeFeatureEnvironmentStore
): Promise<void> { ): Promise<void> {
return Promise.reject(new Error('Not implemented')); return Promise.reject(new Error('Not implemented'));
} }
disableEnvironmentIfNoStrategies(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
featureName: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
environment: string,
): Promise<void> {
return Promise.reject(new Error('Not implemented'));
}
} }

View File

@ -49,5 +49,6 @@ To make use of toggle variants, you need to use a compatible client. Client SDK
- [unleash-client-python](https://github.com/Unleash/unleash-client-python) (from v3.3.0) - [unleash-client-python](https://github.com/Unleash/unleash-client-python) (from v3.3.0)
- [unleash-client-dotnet](https://github.com/Unleash/unleash-client-dotnet) (from v1.3.6) - [unleash-client-dotnet](https://github.com/Unleash/unleash-client-dotnet) (from v1.3.6)
- [unleash-client-go](https://github.com/Unleash/unleash-client-go) (from v3 branch) - [unleash-client-go](https://github.com/Unleash/unleash-client-go) (from v3 branch)
- [unleash-client-php](https://github.com/Unleash/unleash-client-php) (from v1.0.0)
If you would like to give feedback on this feature, experience issues or have questions, please feel free to open an issue on [GitHub](https://github.com/Unleash/unleash/). If you would like to give feedback on this feature, experience issues or have questions, please feel free to open an issue on [GitHub](https://github.com/Unleash/unleash/).

View File

@ -76,4 +76,26 @@ $context = new UnleashContext(
$unleash->isEnabled("someToggle", $context); $unleash->isEnabled("someToggle", $context);
``` ```
**b) Via a UnleashContextProvider**
This is a bit more advanced approach, where you configure a unleash-context provider. By doing this you do not have to rebuild or to pass the unleash-context object to every place you are calling `$unleash->isEnabled()`.
```php
<?php
use Unleash\Client\UnleashBuilder;
$contextProvider = new MyAwesomeContextProvider();
$unleash = UnleashBuilder::create()
->withAppName('my.php-app')
->withInstanceId('your-instance-1')
->withAppUrl('http://unleash.herokuapp.com/api/')
->withContextProvider($contextProvider)
->build();
// Anywhere in the code unleash will get the unleash context from your registered provider.
$unleash->isEnabled("someToggle");
```
> You can read more complete documentation in the [Client SDK repository](https://github.com/Unleash/unleash-client-php). > You can read more complete documentation in the [Client SDK repository](https://github.com/Unleash/unleash-client-php).

942
yarn.lock

File diff suppressed because it is too large Load Diff