mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-10 01:16:39 +02:00
Favorite features (#2550)
This commit is contained in:
parent
071f62c606
commit
b32d3d0fee
@ -41,6 +41,7 @@ export interface IFlags {
|
||||
ENABLE_DARK_MODE_SUPPORT?: boolean;
|
||||
embedProxyFrontend?: boolean;
|
||||
syncSSOGroups?: boolean;
|
||||
favorites?: boolean;
|
||||
changeRequests?: boolean;
|
||||
cloneEnvironment?: boolean;
|
||||
variantsPerEnvironment?: boolean;
|
||||
|
@ -74,6 +74,7 @@ exports[`should create default config 1`] = `
|
||||
"cloneEnvironment": false,
|
||||
"embedProxy": false,
|
||||
"embedProxyFrontend": false,
|
||||
"favorites": false,
|
||||
"networkView": false,
|
||||
"proxyReturnAllToggles": false,
|
||||
"responseTimeWithAppName": false,
|
||||
@ -92,6 +93,7 @@ exports[`should create default config 1`] = `
|
||||
"cloneEnvironment": false,
|
||||
"embedProxy": false,
|
||||
"embedProxyFrontend": false,
|
||||
"favorites": false,
|
||||
"networkView": false,
|
||||
"proxyReturnAllToggles": false,
|
||||
"responseTimeWithAppName": false,
|
||||
|
94
src/lib/db/favorite-features-store.ts
Normal file
94
src/lib/db/favorite-features-store.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import EventEmitter from 'events';
|
||||
import { IFavoriteFeaturesStore } from '../types';
|
||||
import { Logger, LogProvider } from '../logger';
|
||||
import { Knex } from 'knex';
|
||||
import { IFavoriteFeatureKey } from '../types/stores/favorite-features';
|
||||
import { IFavoriteFeature } from '../types/favorites';
|
||||
|
||||
const T = {
|
||||
FAVORITE_FEATURES: 'favorite_features',
|
||||
};
|
||||
|
||||
interface IFavoriteFeatureRow {
|
||||
user_id: number;
|
||||
feature: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
const rowToFavorite = (row: IFavoriteFeatureRow) => {
|
||||
return {
|
||||
userId: row.user_id,
|
||||
feature: row.feature,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
};
|
||||
|
||||
export class FavoriteFeaturesStore implements IFavoriteFeaturesStore {
|
||||
private logger: Logger;
|
||||
|
||||
private eventBus: EventEmitter;
|
||||
|
||||
private db: Knex;
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.eventBus = eventBus;
|
||||
this.logger = getLogger('lib/db/favorites-store.ts');
|
||||
}
|
||||
|
||||
async addFavoriteFeature({
|
||||
userId,
|
||||
feature,
|
||||
}: IFavoriteFeatureKey): Promise<IFavoriteFeature> {
|
||||
const insertedFeature = await this.db<IFavoriteFeatureRow>(
|
||||
T.FAVORITE_FEATURES,
|
||||
)
|
||||
.insert({ feature, user_id: userId })
|
||||
.onConflict(['user_id', 'feature'])
|
||||
.merge()
|
||||
.returning('*');
|
||||
|
||||
return rowToFavorite(insertedFeature[0]);
|
||||
}
|
||||
|
||||
async delete({ userId, feature }: IFavoriteFeatureKey): Promise<void> {
|
||||
return this.db(T.FAVORITE_FEATURES)
|
||||
.where({ feature, user_id: userId })
|
||||
.del();
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<void> {
|
||||
await this.db(T.FAVORITE_FEATURES).del();
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
|
||||
async exists({ userId, feature }: IFavoriteFeatureKey): Promise<boolean> {
|
||||
const result = await this.db.raw(
|
||||
`SELECT EXISTS (SELECT 1 FROM ${T.FAVORITE_FEATURES} WHERE user_id = ? AND feature = ?) AS present`,
|
||||
[userId, feature],
|
||||
);
|
||||
const { present } = result.rows[0];
|
||||
return present;
|
||||
}
|
||||
|
||||
async get({
|
||||
userId,
|
||||
feature,
|
||||
}: IFavoriteFeatureKey): Promise<IFavoriteFeature> {
|
||||
const favorite = await this.db
|
||||
.table<IFavoriteFeatureRow>(T.FAVORITE_FEATURES)
|
||||
.select()
|
||||
.where({ feature, user_id: userId })
|
||||
.first();
|
||||
|
||||
return rowToFavorite(favorite);
|
||||
}
|
||||
|
||||
async getAll(): Promise<IFavoriteFeature[]> {
|
||||
const groups = await this.db<IFavoriteFeatureRow>(
|
||||
T.FAVORITE_FEATURES,
|
||||
).select();
|
||||
return groups.map(rowToFavorite);
|
||||
}
|
||||
}
|
@ -21,6 +21,7 @@ import FeatureToggleStore from './feature-toggle-store';
|
||||
import { ensureStringValue } from '../util/ensureStringValue';
|
||||
import { mapValues } from '../util/map-values';
|
||||
import { IFlagResolver } from '../types/experimental';
|
||||
import { IFeatureProjectUserParams } from '../routes/admin-api/project/features';
|
||||
|
||||
const COLUMNS = [
|
||||
'id',
|
||||
@ -59,6 +60,13 @@ interface IFeatureStrategiesTable {
|
||||
created_at?: Date;
|
||||
}
|
||||
|
||||
export interface ILoadFeatureToggleWithEnvsParams {
|
||||
featureName: string;
|
||||
archived: boolean;
|
||||
withEnvironmentVariants: boolean;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy {
|
||||
return {
|
||||
id: row.id,
|
||||
@ -214,27 +222,53 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
|
||||
async getFeatureToggleWithEnvs(
|
||||
featureName: string,
|
||||
userId?: number,
|
||||
archived: boolean = false,
|
||||
): Promise<FeatureToggleWithEnvironment> {
|
||||
return this.loadFeatureToggleWithEnvs(featureName, archived, false);
|
||||
return this.loadFeatureToggleWithEnvs({
|
||||
featureName,
|
||||
archived,
|
||||
withEnvironmentVariants: false,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async getFeatureToggleWithVariantEnvs(
|
||||
featureName: string,
|
||||
userId?: number,
|
||||
archived: boolean = false,
|
||||
): Promise<FeatureToggleWithEnvironment> {
|
||||
return this.loadFeatureToggleWithEnvs(featureName, archived, true);
|
||||
return this.loadFeatureToggleWithEnvs({
|
||||
featureName,
|
||||
archived,
|
||||
withEnvironmentVariants: true,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async loadFeatureToggleWithEnvs(
|
||||
featureName: string,
|
||||
archived: boolean,
|
||||
withEnvironmentVariants: boolean,
|
||||
): Promise<FeatureToggleWithEnvironment> {
|
||||
async loadFeatureToggleWithEnvs({
|
||||
featureName,
|
||||
archived,
|
||||
withEnvironmentVariants,
|
||||
userId,
|
||||
}: ILoadFeatureToggleWithEnvsParams): Promise<FeatureToggleWithEnvironment> {
|
||||
const stopTimer = this.timer('getFeatureAdmin');
|
||||
const rows = await this.db('features_view')
|
||||
let query = this.db('features_view')
|
||||
.where('name', featureName)
|
||||
.modify(FeatureToggleStore.filterByArchived, archived);
|
||||
|
||||
let selectColumns = ['features_view.*'];
|
||||
if (userId && this.flagResolver.isEnabled('favorites')) {
|
||||
query = query.leftJoin(`favorite_features as ff`, function () {
|
||||
this.on('ff.feature', 'features_view.name').andOnVal(
|
||||
'ff.user_id',
|
||||
'=',
|
||||
userId,
|
||||
);
|
||||
});
|
||||
selectColumns = [...selectColumns, 'ff.feature as favorite'];
|
||||
}
|
||||
const rows = await query.select(selectColumns);
|
||||
stopTimer();
|
||||
if (rows.length > 0) {
|
||||
const featureToggle = rows.reduce((acc, r) => {
|
||||
@ -243,6 +277,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
}
|
||||
|
||||
acc.name = r.name;
|
||||
acc.favorite = r.favorite != null;
|
||||
acc.impressionData = r.impression_data;
|
||||
acc.description = r.description;
|
||||
acc.project = r.project;
|
||||
@ -362,10 +397,25 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
};
|
||||
}
|
||||
|
||||
async getFeatureOverview(
|
||||
projectId: string,
|
||||
archived: boolean = false,
|
||||
): Promise<IFeatureOverview[]> {
|
||||
async getFeatureOverview({
|
||||
projectId,
|
||||
archived,
|
||||
userId,
|
||||
}: IFeatureProjectUserParams): Promise<IFeatureOverview[]> {
|
||||
let query = this.db('features')
|
||||
.where({ project: projectId })
|
||||
.modify(FeatureToggleStore.filterByArchived, archived)
|
||||
.leftJoin(
|
||||
'feature_environments',
|
||||
'feature_environments.feature_name',
|
||||
'features.name',
|
||||
)
|
||||
.leftJoin(
|
||||
'environments',
|
||||
'feature_environments.environment',
|
||||
'environments.name',
|
||||
);
|
||||
|
||||
let selectColumns = [
|
||||
'features.name as feature_name',
|
||||
'features.type as type',
|
||||
@ -379,36 +429,30 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
];
|
||||
|
||||
if (this.flagResolver.isEnabled('toggleTagFiltering')) {
|
||||
query = query.leftJoin(
|
||||
'feature_tag as ft',
|
||||
'ft.feature_name',
|
||||
'features.name',
|
||||
);
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
'ft.tag_value as tag_value',
|
||||
'ft.tag_type as tag_type',
|
||||
];
|
||||
}
|
||||
|
||||
let query = this.db('features')
|
||||
.where({ project: projectId })
|
||||
.select(selectColumns)
|
||||
.modify(FeatureToggleStore.filterByArchived, archived)
|
||||
.leftJoin(
|
||||
'feature_environments',
|
||||
'feature_environments.feature_name',
|
||||
'features.name',
|
||||
)
|
||||
.leftJoin(
|
||||
'environments',
|
||||
'feature_environments.environment',
|
||||
'environments.name',
|
||||
);
|
||||
|
||||
if (this.flagResolver.isEnabled('toggleTagFiltering')) {
|
||||
query = query.leftJoin(
|
||||
'feature_tag as ft',
|
||||
'ft.feature_name',
|
||||
'features.name',
|
||||
);
|
||||
if (userId && this.flagResolver.isEnabled('favorites')) {
|
||||
query = query.leftJoin(`favorite_features as ff`, function () {
|
||||
this.on('ff.feature', 'features.name').andOnVal(
|
||||
'ff.user_id',
|
||||
'=',
|
||||
userId,
|
||||
);
|
||||
});
|
||||
selectColumns = [...selectColumns, 'ff.feature as favorite'];
|
||||
}
|
||||
|
||||
query = query.select(selectColumns);
|
||||
|
||||
const rows = await query;
|
||||
|
||||
if (rows.length > 0) {
|
||||
@ -423,6 +467,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
} else {
|
||||
acc[r.feature_name] = {
|
||||
type: r.type,
|
||||
favorite: r.favorite != null,
|
||||
name: r.feature_name,
|
||||
createdAt: r.created_at,
|
||||
lastSeenAt: r.last_seen_at,
|
||||
|
@ -28,6 +28,20 @@ export interface FeaturesTable {
|
||||
created_at?: Date;
|
||||
}
|
||||
|
||||
export interface IGetAllFeatures {
|
||||
featureQuery?: IFeatureToggleQuery;
|
||||
archived: boolean;
|
||||
isAdmin: boolean;
|
||||
includeStrategyIds?: boolean;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export interface IGetAdminFeatures {
|
||||
featureQuery?: IFeatureToggleQuery;
|
||||
archived?: boolean;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
export default class FeatureToggleClientStore
|
||||
implements IFeatureToggleClientStore
|
||||
{
|
||||
@ -59,12 +73,13 @@ export default class FeatureToggleClientStore
|
||||
this.flagResolver = flagResolver;
|
||||
}
|
||||
|
||||
private async getAll(
|
||||
featureQuery?: IFeatureToggleQuery,
|
||||
archived: boolean = false,
|
||||
isAdmin: boolean = true,
|
||||
includeStrategyIds?: boolean,
|
||||
): Promise<IFeatureToggleClient[]> {
|
||||
private async getAll({
|
||||
featureQuery,
|
||||
archived,
|
||||
isAdmin,
|
||||
includeStrategyIds,
|
||||
userId,
|
||||
}: IGetAllFeatures): Promise<IFeatureToggleClient[]> {
|
||||
const environment = featureQuery?.environment || DEFAULT_ENV;
|
||||
const stopTimer = this.timer('getFeatureAdmin');
|
||||
|
||||
@ -88,16 +103,7 @@ export default class FeatureToggleClientStore
|
||||
'segments.constraints as segment_constraints',
|
||||
];
|
||||
|
||||
if (isAdmin && this.flagResolver.isEnabled('toggleTagFiltering')) {
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
'ft.tag_value as tag_value',
|
||||
'ft.tag_type as tag_type',
|
||||
];
|
||||
}
|
||||
|
||||
let query = this.db('features')
|
||||
.select(selectColumns)
|
||||
.modify(FeatureToggleStore.filterByArchived, archived)
|
||||
.leftJoin(
|
||||
this.db('feature_strategies')
|
||||
@ -127,14 +133,34 @@ export default class FeatureToggleClientStore
|
||||
)
|
||||
.leftJoin('segments', `segments.id`, `fss.segment_id`);
|
||||
|
||||
if (isAdmin && this.flagResolver.isEnabled('toggleTagFiltering')) {
|
||||
query = query.leftJoin(
|
||||
'feature_tag as ft',
|
||||
'ft.feature_name',
|
||||
'features.name',
|
||||
);
|
||||
if (isAdmin) {
|
||||
if (this.flagResolver.isEnabled('toggleTagFiltering')) {
|
||||
query = query.leftJoin(
|
||||
'feature_tag as ft',
|
||||
'ft.feature_name',
|
||||
'features.name',
|
||||
);
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
'ft.tag_value as tag_value',
|
||||
'ft.tag_type as tag_type',
|
||||
];
|
||||
}
|
||||
|
||||
if (userId && this.flagResolver.isEnabled('favorites')) {
|
||||
query = query.leftJoin(`favorite_features as ff`, function () {
|
||||
this.on('ff.feature', 'features.name').andOnVal(
|
||||
'ff.user_id',
|
||||
'=',
|
||||
userId,
|
||||
);
|
||||
});
|
||||
selectColumns = [...selectColumns, 'ff.feature as favorite'];
|
||||
}
|
||||
}
|
||||
|
||||
query = query.select(selectColumns);
|
||||
|
||||
if (featureQuery) {
|
||||
if (featureQuery.tag) {
|
||||
const tagQuery = this.db
|
||||
@ -181,6 +207,7 @@ export default class FeatureToggleClientStore
|
||||
feature.impressionData = r.impression_data;
|
||||
feature.enabled = !!r.enabled;
|
||||
feature.name = r.name;
|
||||
feature.favorite = r.favorite != null;
|
||||
feature.description = r.description;
|
||||
feature.project = r.project;
|
||||
feature.stale = r.stale;
|
||||
@ -292,14 +319,20 @@ export default class FeatureToggleClientStore
|
||||
featureQuery?: IFeatureToggleQuery,
|
||||
includeStrategyIds?: boolean,
|
||||
): Promise<IFeatureToggleClient[]> {
|
||||
return this.getAll(featureQuery, false, false, includeStrategyIds);
|
||||
return this.getAll({
|
||||
featureQuery,
|
||||
archived: false,
|
||||
isAdmin: false,
|
||||
includeStrategyIds,
|
||||
});
|
||||
}
|
||||
|
||||
async getAdmin(
|
||||
featureQuery?: IFeatureToggleQuery,
|
||||
archived: boolean = false,
|
||||
): Promise<IFeatureToggleClient[]> {
|
||||
return this.getAll(featureQuery, archived, true);
|
||||
async getAdmin({
|
||||
featureQuery,
|
||||
userId,
|
||||
archived,
|
||||
}: IGetAdminFeatures): Promise<IFeatureToggleClient[]> {
|
||||
return this.getAll({ featureQuery, archived, isAdmin: true, userId });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -32,6 +32,7 @@ import SegmentStore from './segment-store';
|
||||
import GroupStore from './group-store';
|
||||
import PatStore from './pat-store';
|
||||
import { PublicSignupTokenStore } from './public-signup-token-store';
|
||||
import { FavoriteFeaturesStore } from './favorite-features-store';
|
||||
|
||||
export const createStores = (
|
||||
config: IUnleashConfig,
|
||||
@ -94,6 +95,11 @@ export const createStores = (
|
||||
getLogger,
|
||||
),
|
||||
patStore: new PatStore(db, getLogger),
|
||||
favoriteFeaturesStore: new FavoriteFeaturesStore(
|
||||
db,
|
||||
eventBus,
|
||||
getLogger,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -34,6 +34,9 @@ export const featureSchema = {
|
||||
stale: {
|
||||
type: 'boolean',
|
||||
},
|
||||
favorite: {
|
||||
type: 'boolean',
|
||||
},
|
||||
impressionData: {
|
||||
type: 'boolean',
|
||||
},
|
||||
|
82
src/lib/routes/admin-api/favorites.ts
Normal file
82
src/lib/routes/admin-api/favorites.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { Response } from 'express';
|
||||
import Controller from '../controller';
|
||||
import { FavoritesService, OpenApiService } from '../../services';
|
||||
import { Logger } from '../../logger';
|
||||
import { IUnleashConfig, IUnleashServices, NONE } from '../../types';
|
||||
import { emptyResponse } from '../../openapi';
|
||||
import { IAuthRequest } from '../unleash-types';
|
||||
|
||||
export default class FavoritesController extends Controller {
|
||||
private favoritesService: FavoritesService;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
private openApiService: OpenApiService;
|
||||
|
||||
constructor(
|
||||
config: IUnleashConfig,
|
||||
{
|
||||
favoritesService,
|
||||
openApiService,
|
||||
}: Pick<IUnleashServices, 'favoritesService' | 'openApiService'>,
|
||||
) {
|
||||
super(config);
|
||||
this.logger = config.getLogger('/routes/favorites-controller');
|
||||
this.favoritesService = favoritesService;
|
||||
this.openApiService = openApiService;
|
||||
|
||||
this.route({
|
||||
method: 'post',
|
||||
path: '/:projectId/features/:featureName/favorites',
|
||||
handler: this.addFavoriteFeature,
|
||||
permission: NONE,
|
||||
middleware: [
|
||||
openApiService.validPath({
|
||||
tags: ['Features'],
|
||||
operationId: 'addFavoriteFeature',
|
||||
responses: { 200: emptyResponse },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
this.route({
|
||||
method: 'delete',
|
||||
path: '/:projectId/features/:featureName/favorites',
|
||||
handler: this.removeFavoriteFeature,
|
||||
permission: NONE,
|
||||
middleware: [
|
||||
openApiService.validPath({
|
||||
tags: ['Features'],
|
||||
operationId: 'removeFavoriteFeature',
|
||||
responses: { 200: emptyResponse },
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async addFavoriteFeature(
|
||||
req: IAuthRequest<{ featureName: string }>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { featureName } = req.params;
|
||||
const { user } = req;
|
||||
await this.favoritesService.addFavoriteFeature({
|
||||
feature: featureName,
|
||||
userId: user.id,
|
||||
});
|
||||
res.status(200).end();
|
||||
}
|
||||
|
||||
async removeFavoriteFeature(
|
||||
req: IAuthRequest<{ featureName: string }>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { featureName } = req.params;
|
||||
const { user } = req;
|
||||
await this.favoritesService.removeFavoriteFeature({
|
||||
feature: featureName,
|
||||
userId: user.id,
|
||||
});
|
||||
res.status(200).end();
|
||||
}
|
||||
}
|
@ -179,11 +179,12 @@ class FeatureController extends Controller {
|
||||
}
|
||||
|
||||
async getAllToggles(
|
||||
req: Request,
|
||||
req: IAuthRequest,
|
||||
res: Response<FeaturesSchema>,
|
||||
): Promise<void> {
|
||||
const query = await this.prepQuery(req.query);
|
||||
const features = await this.service.getFeatureToggles(query);
|
||||
const { user } = req;
|
||||
const features = await this.service.getFeatureToggles(query, user.id);
|
||||
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
|
@ -27,6 +27,7 @@ import PatController from './user/pat';
|
||||
import { PublicSignupController } from './public-signup';
|
||||
import { conditionalMiddleware } from '../../middleware/conditional-middleware';
|
||||
import InstanceAdminController from './instance-admin';
|
||||
import FavoritesController from './favorites';
|
||||
|
||||
class AdminApi extends Controller {
|
||||
constructor(config: IUnleashConfig, services: IUnleashServices) {
|
||||
@ -119,6 +120,10 @@ class AdminApi extends Controller {
|
||||
'/instance-admin',
|
||||
new InstanceAdminController(config, services).router,
|
||||
);
|
||||
this.app.use(
|
||||
`/projects`,
|
||||
new FavoritesController(config, services).router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,6 +63,11 @@ interface StrategyIdParams extends FeatureStrategyParams {
|
||||
strategyId: string;
|
||||
}
|
||||
|
||||
export interface IFeatureProjectUserParams extends ProjectParam {
|
||||
archived?: boolean;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
const PATH = '/:projectId/features';
|
||||
const PATH_FEATURE = `${PATH}/:featureName`;
|
||||
const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`;
|
||||
@ -394,13 +399,15 @@ export default class ProjectFeaturesController extends Controller {
|
||||
}
|
||||
|
||||
async getFeatures(
|
||||
req: Request<ProjectParam, any, any, any>,
|
||||
req: IAuthRequest<ProjectParam, any, any, any>,
|
||||
res: Response<FeaturesSchema>,
|
||||
): Promise<void> {
|
||||
const { projectId } = req.params;
|
||||
const features = await this.featureService.getFeatureOverview(
|
||||
const { user } = req;
|
||||
const features = await this.featureService.getFeatureOverview({
|
||||
projectId,
|
||||
);
|
||||
userId: user.id,
|
||||
});
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
res,
|
||||
@ -458,17 +465,19 @@ export default class ProjectFeaturesController extends Controller {
|
||||
}
|
||||
|
||||
async getFeature(
|
||||
req: Request<FeatureParams, any, any, any>,
|
||||
req: IAuthRequest<FeatureParams, any, any, any>,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
const { featureName, projectId } = req.params;
|
||||
const { variantEnvironments } = req.query;
|
||||
const feature = await this.featureService.getFeature(
|
||||
const { user } = req;
|
||||
const feature = await this.featureService.getFeature({
|
||||
featureName,
|
||||
false,
|
||||
archived: false,
|
||||
projectId,
|
||||
variantEnvironments === 'true',
|
||||
);
|
||||
environmentVariants: variantEnvironments === 'true',
|
||||
userId: user.id,
|
||||
});
|
||||
res.status(200).json(feature);
|
||||
}
|
||||
|
||||
|
37
src/lib/services/favorites-service.ts
Normal file
37
src/lib/services/favorites-service.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { IUnleashConfig } from '../types/option';
|
||||
import { IUnleashStores } from '../types/stores';
|
||||
import { Logger } from '../logger';
|
||||
import {
|
||||
IFavoriteFeatureKey,
|
||||
IFavoriteFeaturesStore,
|
||||
} from '../types/stores/favorite-features';
|
||||
import { IFavoriteFeature } from '../types/favorites';
|
||||
|
||||
export class FavoritesService {
|
||||
private config: IUnleashConfig;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
private favoriteFeaturesStore: IFavoriteFeaturesStore;
|
||||
|
||||
constructor(
|
||||
{
|
||||
favoriteFeaturesStore,
|
||||
}: Pick<IUnleashStores, 'favoriteFeaturesStore'>,
|
||||
config: IUnleashConfig,
|
||||
) {
|
||||
this.config = config;
|
||||
this.logger = config.getLogger('services/favorites-service.ts');
|
||||
this.favoriteFeaturesStore = favoriteFeaturesStore;
|
||||
}
|
||||
|
||||
async addFavoriteFeature(
|
||||
favorite: IFavoriteFeatureKey,
|
||||
): Promise<IFavoriteFeature> {
|
||||
return this.favoriteFeaturesStore.addFavoriteFeature(favorite);
|
||||
}
|
||||
|
||||
async removeFavoriteFeature(favorite: IFavoriteFeatureKey): Promise<void> {
|
||||
return this.favoriteFeaturesStore.delete(favorite);
|
||||
}
|
||||
}
|
@ -78,6 +78,7 @@ import { AccessService } from './access-service';
|
||||
import { User } from '../server-impl';
|
||||
import { CREATE_FEATURE_STRATEGY } from '../types/permissions';
|
||||
import NoAccessError from '../error/no-access-error';
|
||||
import { IFeatureProjectUserParams } from '../routes/admin-api/project/features';
|
||||
|
||||
interface IFeatureContext {
|
||||
featureName: string;
|
||||
@ -88,6 +89,14 @@ interface IFeatureStrategyContext extends IFeatureContext {
|
||||
environment: string;
|
||||
}
|
||||
|
||||
export interface IGetFeatureParams {
|
||||
featureName: string;
|
||||
archived?: boolean;
|
||||
projectId?: string;
|
||||
environmentVariants?: boolean;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
const oneOf = (values: string[], match: string) => {
|
||||
return values.some((value) => value === match);
|
||||
};
|
||||
@ -608,12 +617,13 @@ class FeatureToggleService {
|
||||
* @param archived - return archived or non archived toggles
|
||||
* @param projectId - provide if you're requesting the feature in the context of a specific project.
|
||||
*/
|
||||
async getFeature(
|
||||
featureName: string,
|
||||
archived: boolean = false,
|
||||
projectId?: string,
|
||||
environmentVariants: boolean = false,
|
||||
): Promise<FeatureToggleWithEnvironment> {
|
||||
async getFeature({
|
||||
featureName,
|
||||
archived,
|
||||
projectId,
|
||||
environmentVariants,
|
||||
userId,
|
||||
}: IGetFeatureParams): Promise<FeatureToggleWithEnvironment> {
|
||||
if (projectId) {
|
||||
await this.validateFeatureContext({ featureName, projectId });
|
||||
}
|
||||
@ -621,11 +631,13 @@ class FeatureToggleService {
|
||||
if (environmentVariants) {
|
||||
return this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(
|
||||
featureName,
|
||||
userId,
|
||||
archived,
|
||||
);
|
||||
} else {
|
||||
return this.featureStrategiesStore.getFeatureToggleWithEnvs(
|
||||
featureName,
|
||||
userId,
|
||||
archived,
|
||||
);
|
||||
}
|
||||
@ -674,19 +686,20 @@ class FeatureToggleService {
|
||||
*/
|
||||
async getFeatureToggles(
|
||||
query?: IFeatureToggleQuery,
|
||||
userId?: number,
|
||||
archived: boolean = false,
|
||||
): Promise<FeatureToggle[]> {
|
||||
return this.featureToggleClientStore.getAdmin(query, archived);
|
||||
return this.featureToggleClientStore.getAdmin({
|
||||
featureQuery: query,
|
||||
userId,
|
||||
archived,
|
||||
});
|
||||
}
|
||||
|
||||
async getFeatureOverview(
|
||||
projectId: string,
|
||||
archived: boolean = false,
|
||||
params: IFeatureProjectUserParams,
|
||||
): Promise<IFeatureOverview[]> {
|
||||
return this.featureStrategiesStore.getFeatureOverview(
|
||||
projectId,
|
||||
archived,
|
||||
);
|
||||
return this.featureStrategiesStore.getFeatureOverview(params);
|
||||
}
|
||||
|
||||
async getFeatureToggle(
|
||||
@ -694,7 +707,6 @@ class FeatureToggleService {
|
||||
): Promise<FeatureToggleWithEnvironment> {
|
||||
return this.featureStrategiesStore.getFeatureToggleWithEnvs(
|
||||
featureName,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1171,7 +1183,7 @@ class FeatureToggleService {
|
||||
}
|
||||
|
||||
async getArchivedFeatures(): Promise<FeatureToggle[]> {
|
||||
return this.getFeatureToggles({}, true);
|
||||
return this.getFeatureToggles({}, undefined, true);
|
||||
}
|
||||
|
||||
// TODO: add project id.
|
||||
|
@ -37,6 +37,7 @@ import PatService from './pat-service';
|
||||
import { PublicSignupTokenService } from './public-signup-token-service';
|
||||
import { LastSeenService } from './client-metrics/last-seen-service';
|
||||
import { InstanceStatsService } from './instance-stats-service';
|
||||
import { FavoritesService } from './favorites-service';
|
||||
|
||||
export const createServices = (
|
||||
stores: IUnleashStores,
|
||||
@ -123,6 +124,7 @@ export const createServices = (
|
||||
config,
|
||||
versionService,
|
||||
);
|
||||
const favoritesService = new FavoritesService(stores, config);
|
||||
|
||||
return {
|
||||
accessService,
|
||||
@ -163,6 +165,7 @@ export const createServices = (
|
||||
publicSignupTokenService,
|
||||
lastSeenService,
|
||||
instanceStatsService,
|
||||
favoritesService,
|
||||
};
|
||||
};
|
||||
|
||||
@ -204,4 +207,5 @@ export {
|
||||
PublicSignupTokenService,
|
||||
LastSeenService,
|
||||
InstanceStatsService,
|
||||
FavoritesService,
|
||||
};
|
||||
|
@ -63,10 +63,10 @@ export default class ProjectHealthService {
|
||||
const environments = await this.projectStore.getEnvironmentsForProject(
|
||||
projectId,
|
||||
);
|
||||
const features = await this.featureToggleService.getFeatureOverview(
|
||||
const features = await this.featureToggleService.getFeatureOverview({
|
||||
projectId,
|
||||
archived,
|
||||
);
|
||||
});
|
||||
const members = await this.projectStore.getMembersCountByProject(
|
||||
projectId,
|
||||
);
|
||||
|
@ -592,10 +592,10 @@ export default class ProjectService {
|
||||
const environments = await this.store.getEnvironmentsForProject(
|
||||
projectId,
|
||||
);
|
||||
const features = await this.featureToggleService.getFeatureOverview(
|
||||
const features = await this.featureToggleService.getFeatureOverview({
|
||||
projectId,
|
||||
archived,
|
||||
);
|
||||
});
|
||||
const members = await this.store.getMembersCountByProject(projectId);
|
||||
return {
|
||||
name: project.name,
|
||||
|
@ -50,6 +50,10 @@ export const defaultExperimentalOptions = {
|
||||
process.env.UNLEASH_EXPERIMENTAL_TOKENS_LAST_SEEN,
|
||||
false,
|
||||
),
|
||||
favorites: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_FAVORITES,
|
||||
false,
|
||||
),
|
||||
networkView: parseEnvVarBoolean(
|
||||
process.env.UNLEASH_EXPERIMENTAL_NETWORK_VIEW,
|
||||
false,
|
||||
@ -72,6 +76,7 @@ export interface IExperimentalOptions {
|
||||
proxyReturnAllToggles?: boolean;
|
||||
variantsPerEnvironment?: boolean;
|
||||
tokensLastSeen?: boolean;
|
||||
favorites?: boolean;
|
||||
networkView?: boolean;
|
||||
};
|
||||
externalResolver: IExternalFlagResolver;
|
||||
|
7
src/lib/types/favorites.ts
Normal file
7
src/lib/types/favorites.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface IFavoriteFeature {
|
||||
feature: string;
|
||||
}
|
||||
|
||||
export interface IFavoriteProject {
|
||||
project: string;
|
||||
}
|
@ -70,6 +70,8 @@ export interface IFeatureToggleClient {
|
||||
lastSeenAt?: Date;
|
||||
createdAt?: Date;
|
||||
tags?: ITag[];
|
||||
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface IFeatureEnvironmentInfo {
|
||||
|
@ -35,6 +35,7 @@ import PatService from '../services/pat-service';
|
||||
import { PublicSignupTokenService } from '../services/public-signup-token-service';
|
||||
import { LastSeenService } from '../services/client-metrics/last-seen-service';
|
||||
import { InstanceStatsService } from '../services/instance-stats-service';
|
||||
import { FavoritesService } from '../services';
|
||||
|
||||
export interface IUnleashServices {
|
||||
accessService: AccessService;
|
||||
@ -75,4 +76,5 @@ export interface IUnleashServices {
|
||||
patService: PatService;
|
||||
lastSeenService: LastSeenService;
|
||||
instanceStatsService: InstanceStatsService;
|
||||
favoritesService: FavoritesService;
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ import { ISegmentStore } from './stores/segment-store';
|
||||
import { IGroupStore } from './stores/group-store';
|
||||
import { IPatStore } from './stores/pat-store';
|
||||
import { IPublicSignupTokenStore } from './stores/public-signup-token-store';
|
||||
import { IFavoriteFeaturesStore } from './stores/favorite-features';
|
||||
|
||||
export interface IUnleashStores {
|
||||
accessStore: IAccessStore;
|
||||
@ -60,6 +61,7 @@ export interface IUnleashStores {
|
||||
segmentStore: ISegmentStore;
|
||||
patStore: IPatStore;
|
||||
publicSignupTokenStore: IPublicSignupTokenStore;
|
||||
favoriteFeaturesStore: IFavoriteFeaturesStore;
|
||||
}
|
||||
|
||||
export {
|
||||
@ -93,4 +95,5 @@ export {
|
||||
IUserFeedbackStore,
|
||||
IUserSplashStore,
|
||||
IUserStore,
|
||||
IFavoriteFeaturesStore,
|
||||
};
|
||||
|
14
src/lib/types/stores/favorite-features.ts
Normal file
14
src/lib/types/stores/favorite-features.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { IFavoriteFeature } from '../favorites';
|
||||
import { Store } from './store';
|
||||
|
||||
export interface IFavoriteFeatureKey {
|
||||
userId: number;
|
||||
feature: string;
|
||||
}
|
||||
|
||||
export interface IFavoriteFeaturesStore
|
||||
extends Store<IFavoriteFeature, IFavoriteFeatureKey> {
|
||||
addFavoriteFeature(
|
||||
favorite: IFavoriteFeatureKey,
|
||||
): Promise<IFavoriteFeature>;
|
||||
}
|
@ -6,6 +6,7 @@ import {
|
||||
IVariant,
|
||||
} from '../model';
|
||||
import { Store } from './store';
|
||||
import { IFeatureProjectUserParams } from '../../routes/admin-api/project/features';
|
||||
|
||||
export interface FeatureConfigurationClient {
|
||||
name: string;
|
||||
@ -32,15 +33,16 @@ export interface IFeatureStrategiesStore
|
||||
): Promise<IFeatureStrategy[]>;
|
||||
getFeatureToggleWithEnvs(
|
||||
featureName: string,
|
||||
userId?: number,
|
||||
archived?: boolean,
|
||||
): Promise<FeatureToggleWithEnvironment>;
|
||||
getFeatureToggleWithVariantEnvs(
|
||||
featureName: string,
|
||||
userId?: number,
|
||||
archived?,
|
||||
): Promise<FeatureToggleWithEnvironment>;
|
||||
getFeatureOverview(
|
||||
projectId: string,
|
||||
archived: boolean,
|
||||
params: IFeatureProjectUserParams,
|
||||
): Promise<IFeatureOverview[]>;
|
||||
getStrategyById(id: string): Promise<IFeatureStrategy>;
|
||||
updateStrategy(
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { IFeatureToggleClient, IFeatureToggleQuery } from '../model';
|
||||
import { IGetAdminFeatures } from '../../db/feature-toggle-client-store';
|
||||
|
||||
export interface IFeatureToggleClientStore {
|
||||
getClient(
|
||||
@ -7,8 +8,5 @@ export interface IFeatureToggleClientStore {
|
||||
): Promise<IFeatureToggleClient[]>;
|
||||
|
||||
// @Deprecated
|
||||
getAdmin(
|
||||
featureQuery: Partial<IFeatureToggleQuery>,
|
||||
archived: boolean,
|
||||
): Promise<IFeatureToggleClient[]>;
|
||||
getAdmin(params: IGetAdminFeatures): Promise<IFeatureToggleClient[]>;
|
||||
}
|
||||
|
34
src/migrations/20221124123914-add-favorites.js
Normal file
34
src/migrations/20221124123914-add-favorites.js
Normal file
@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
|
||||
exports.up = function (db, callback) {
|
||||
db.runSql(
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS favorite_features
|
||||
(
|
||||
feature VARCHAR(255) NOT NULL REFERENCES features (name) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (feature, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS favorite_projects
|
||||
(
|
||||
project VARCHAR(255) NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (project, user_id)
|
||||
);
|
||||
`,
|
||||
callback,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, callback) {
|
||||
db.runSql(
|
||||
`
|
||||
DROP TABLE IF EXISTS favorite_features;
|
||||
DROP TABLE IF EXISTS favorite_projects;
|
||||
`,
|
||||
callback,
|
||||
);
|
||||
};
|
@ -42,6 +42,7 @@ process.nextTick(async () => {
|
||||
changeRequests: true,
|
||||
cloneEnvironment: true,
|
||||
toggleTagFiltering: true,
|
||||
favorites: true,
|
||||
variantsPerEnvironment: true,
|
||||
},
|
||||
},
|
||||
|
@ -31,6 +31,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
||||
changeRequests: true,
|
||||
cloneEnvironment: true,
|
||||
variantsPerEnvironment: true,
|
||||
favorites: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
171
src/test/e2e/api/admin/favorites.e2e.test.ts
Normal file
171
src/test/e2e/api/admin/favorites.e2e.test.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper';
|
||||
import { IUnleashStores, RoleName } from '../../../../lib/types';
|
||||
import { AccessService } from '../../../../lib/services';
|
||||
import dbInit, { ITestDb } from '../../helpers/database-init';
|
||||
import getLogger from '../../../fixtures/no-logger';
|
||||
|
||||
let app: IUnleashTest;
|
||||
let db: ITestDb;
|
||||
let stores: IUnleashStores;
|
||||
let accessService: AccessService;
|
||||
let editorRole;
|
||||
|
||||
const regularUserName = 'favorites-user';
|
||||
|
||||
const createFeature = async (featureName: string) => {
|
||||
await app.request
|
||||
.post('/api/admin/projects/default/features')
|
||||
.send({
|
||||
name: featureName,
|
||||
})
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(201);
|
||||
|
||||
// await projectService.addEnvironmentToProject('default', environment);
|
||||
};
|
||||
|
||||
const loginRegularUser = () =>
|
||||
app.request
|
||||
.post(`/auth/demo/login`)
|
||||
.send({
|
||||
email: `${regularUserName}@getunleash.io`,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const createUserEditorAccess = async (name, email) => {
|
||||
const { userStore } = stores;
|
||||
const user = await userStore.insert({ name, email });
|
||||
await accessService.addUserToRole(user.id, editorRole.id, 'default');
|
||||
return user;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('favorites_api_serial', getLogger);
|
||||
app = await setupAppWithAuth(db.stores);
|
||||
stores = db.stores;
|
||||
accessService = app.services.accessService;
|
||||
|
||||
const roles = await accessService.getRootRoles();
|
||||
editorRole = roles.find((role) => role.name === RoleName.EDITOR);
|
||||
|
||||
await createUserEditorAccess(
|
||||
regularUserName,
|
||||
`${regularUserName}@getunleash.io`,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.destroy();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.stores.favoriteFeaturesStore.deleteAll();
|
||||
await db.stores.featureToggleStore.deleteAll();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await loginRegularUser();
|
||||
});
|
||||
|
||||
test('should have favorites true in project endpoint', async () => {
|
||||
const featureName = 'test-feature';
|
||||
await createFeature(featureName);
|
||||
|
||||
await app.request
|
||||
.post(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
const { body } = await app.request
|
||||
.get(`/api/admin/projects/default/features`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
expect(body.features).toHaveLength(1);
|
||||
expect(body.features[0]).toMatchObject({
|
||||
name: featureName,
|
||||
favorite: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('should have favorites false by default', async () => {
|
||||
const featureName = 'test-feature';
|
||||
await createFeature(featureName);
|
||||
|
||||
const { body } = await app.request
|
||||
.get(`/api/admin/projects/default/features`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
expect(body.features).toHaveLength(1);
|
||||
expect(body.features[0]).toMatchObject({
|
||||
name: featureName,
|
||||
favorite: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('should have favorites true in admin endpoint', async () => {
|
||||
const featureName = 'test-feature';
|
||||
await createFeature(featureName);
|
||||
|
||||
await app.request
|
||||
.post(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
const { body } = await app.request
|
||||
.get(`/api/admin/features`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
expect(body.features).toHaveLength(1);
|
||||
expect(body.features[0]).toMatchObject({
|
||||
name: featureName,
|
||||
favorite: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('should have favorites true in project single feature endpoint', async () => {
|
||||
const featureName = 'test-feature';
|
||||
await createFeature(featureName);
|
||||
|
||||
await app.request
|
||||
.post(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
const { body } = await app.request
|
||||
.get(`/api/admin/projects/default/features/${featureName}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
name: featureName,
|
||||
favorite: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('should have favorites false after deleting favorite', async () => {
|
||||
const featureName = 'test-feature';
|
||||
await createFeature(featureName);
|
||||
|
||||
await app.request
|
||||
.post(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
await app.request
|
||||
.delete(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
const { body } = await app.request
|
||||
.get(`/api/admin/projects/default/features/${featureName}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(200);
|
||||
|
||||
expect(body).toMatchObject({
|
||||
name: featureName,
|
||||
favorite: false,
|
||||
});
|
||||
});
|
@ -314,7 +314,9 @@ test('Roundtrip with strategies in multiple environments works', async () => {
|
||||
keepExisting: false,
|
||||
userName: 'export-tester',
|
||||
});
|
||||
const f = await app.services.featureToggleServiceV2.getFeature(featureName);
|
||||
const f = await app.services.featureToggleServiceV2.getFeature({
|
||||
featureName,
|
||||
});
|
||||
expect(f.environments).toHaveLength(4);
|
||||
});
|
||||
|
||||
|
@ -1186,6 +1186,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"favorite": {
|
||||
"type": "boolean",
|
||||
},
|
||||
"impressionData": {
|
||||
"type": "boolean",
|
||||
},
|
||||
@ -5996,6 +5999,66 @@ If the provided project does not exist, the list of events will be empty.",
|
||||
],
|
||||
},
|
||||
},
|
||||
"/api/admin/projects/{projectId}/features/{featureName}/favorites": {
|
||||
"delete": {
|
||||
"operationId": "removeFavoriteFeature",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "projectId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "featureName",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "This response has no body.",
|
||||
},
|
||||
},
|
||||
"tags": [
|
||||
"Features",
|
||||
],
|
||||
},
|
||||
"post": {
|
||||
"operationId": "addFavoriteFeature",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "projectId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
{
|
||||
"in": "path",
|
||||
"name": "featureName",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "This response has no body.",
|
||||
},
|
||||
},
|
||||
"tags": [
|
||||
"Features",
|
||||
],
|
||||
},
|
||||
},
|
||||
"/api/admin/projects/{projectId}/features/{featureName}/variants": {
|
||||
"get": {
|
||||
"operationId": "getFeatureVariants",
|
||||
|
@ -1,9 +1,21 @@
|
||||
import dbInit from './helpers/database-init';
|
||||
import { setupAppWithCustomAuth } from './helpers/test-helper';
|
||||
import { RoleName } from '../../lib/types';
|
||||
|
||||
let db;
|
||||
let stores;
|
||||
|
||||
const preHook = (app, config, { userService, accessService }) => {
|
||||
app.use('/api/admin/', async (req, res, next) => {
|
||||
const role = await accessService.getRootRole(RoleName.EDITOR);
|
||||
req.user = await userService.createUser({
|
||||
email: 'editor2@example.com',
|
||||
rootRole: role.id,
|
||||
});
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('custom_auth_serial');
|
||||
stores = db.stores;
|
||||
@ -28,7 +40,7 @@ test('Using custom auth type without defining custom middleware causes default D
|
||||
|
||||
test('If actually configuring a custom middleware should configure the middleware', async () => {
|
||||
expect.assertions(0);
|
||||
const { request, destroy } = await setupAppWithCustomAuth(stores, () => {});
|
||||
const { request, destroy } = await setupAppWithCustomAuth(stores, preHook);
|
||||
await request.get('/api/admin/features').expect(200);
|
||||
await destroy();
|
||||
});
|
||||
|
@ -51,10 +51,9 @@ test('Can connect environment to project', async () => {
|
||||
stale: false,
|
||||
});
|
||||
await service.addEnvironmentToProject('test-connection', 'default');
|
||||
const overview = await stores.featureStrategiesStore.getFeatureOverview(
|
||||
'default',
|
||||
false,
|
||||
);
|
||||
const overview = await stores.featureStrategiesStore.getFeatureOverview({
|
||||
projectId: 'default',
|
||||
});
|
||||
overview.forEach((f) => {
|
||||
expect(f.environments).toEqual([
|
||||
{
|
||||
@ -77,10 +76,9 @@ test('Can remove environment from project', async () => {
|
||||
});
|
||||
await service.removeEnvironmentFromProject('test-connection', 'default');
|
||||
await service.addEnvironmentToProject('removal-test', 'default');
|
||||
let overview = await stores.featureStrategiesStore.getFeatureOverview(
|
||||
'default',
|
||||
false,
|
||||
);
|
||||
let overview = await stores.featureStrategiesStore.getFeatureOverview({
|
||||
projectId: 'default',
|
||||
});
|
||||
expect(overview.length).toBeGreaterThan(0);
|
||||
overview.forEach((f) => {
|
||||
expect(f.environments).toEqual([
|
||||
@ -93,10 +91,9 @@ test('Can remove environment from project', async () => {
|
||||
]);
|
||||
});
|
||||
await service.removeEnvironmentFromProject('removal-test', 'default');
|
||||
overview = await stores.featureStrategiesStore.getFeatureOverview(
|
||||
'default',
|
||||
false,
|
||||
);
|
||||
overview = await stores.featureStrategiesStore.getFeatureOverview({
|
||||
projectId: 'default',
|
||||
});
|
||||
expect(overview.length).toBeGreaterThan(0);
|
||||
overview.forEach((o) => {
|
||||
expect(o.environments).toEqual([]);
|
||||
|
@ -167,8 +167,10 @@ test('should ignore name in the body when updating feature toggle', async () =>
|
||||
};
|
||||
|
||||
await service.updateFeatureToggle(projectId, update, userName, featureName);
|
||||
const featureOne = await service.getFeature(featureName);
|
||||
const featureTwo = await service.getFeature(secondFeatureName);
|
||||
const featureOne = await service.getFeature({ featureName });
|
||||
const featureTwo = await service.getFeature({
|
||||
featureName: secondFeatureName,
|
||||
});
|
||||
|
||||
expect(featureOne.description).toBe(`I'm changed`);
|
||||
expect(featureTwo.description).toBe('Second toggle');
|
||||
@ -250,7 +252,11 @@ test('adding and removing an environment preserves variants when variants per en
|
||||
await environmentService.removeEnvironmentFromProject(prodEnv, 'default');
|
||||
await environmentService.addEnvironmentToProject(prodEnv, 'default');
|
||||
|
||||
const toggle = await service.getFeature(featureName, false, null, false);
|
||||
const toggle = await service.getFeature({
|
||||
featureName,
|
||||
projectId: null,
|
||||
environmentVariants: false,
|
||||
});
|
||||
expect(toggle.variants).toHaveLength(1);
|
||||
});
|
||||
|
||||
@ -355,7 +361,7 @@ test('Cloning a feature toggle also clones segments correctly', async () => {
|
||||
'test-user',
|
||||
);
|
||||
|
||||
let feature = await service.getFeature(clonedFeatureName);
|
||||
let feature = await service.getFeature({ featureName: clonedFeatureName });
|
||||
expect(
|
||||
feature.environments.find((x) => x.name === 'default').strategies[0]
|
||||
.segments,
|
||||
|
@ -514,7 +514,9 @@ test('should change project when checks pass', async () => {
|
||||
projectA.id,
|
||||
);
|
||||
|
||||
const updatedFeature = await featureToggleService.getFeature(toggle.name);
|
||||
const updatedFeature = await featureToggleService.getFeature({
|
||||
featureName: toggle.name,
|
||||
});
|
||||
expect(updatedFeature.project).toBe(projectB.id);
|
||||
});
|
||||
|
||||
|
35
src/test/fixtures/fake-favorite-features-store.ts
vendored
Normal file
35
src/test/fixtures/fake-favorite-features-store.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
||||
import { IFavoriteFeaturesStore } from '../../lib/types';
|
||||
import { IFavoriteFeatureKey } from '../../lib/types/stores/favorite-features';
|
||||
import { IFavoriteFeature } from '../../lib/types/favorites';
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
export default class FakeFavoriteFeaturesStore
|
||||
implements IFavoriteFeaturesStore
|
||||
{
|
||||
addFavoriteFeature(
|
||||
favorite: IFavoriteFeatureKey,
|
||||
): Promise<IFavoriteFeature> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
delete(key: IFavoriteFeatureKey): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
deleteAll(): Promise<void> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
destroy(): void {}
|
||||
|
||||
exists(key: IFavoriteFeatureKey): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
get(key: IFavoriteFeatureKey): Promise<IFavoriteFeature> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
getAll(query?: Object): Promise<IFavoriteFeature[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
@ -9,6 +9,7 @@ import {
|
||||
} from '../../lib/types/model';
|
||||
import NotFoundError from '../../lib/error/notfound-error';
|
||||
import { IFeatureStrategiesStore } from '../../lib/types/stores/feature-strategies-store';
|
||||
import { IFeatureProjectUserParams } from '../../lib/routes/admin-api/project/features';
|
||||
|
||||
interface ProjectEnvironment {
|
||||
projectName: string;
|
||||
@ -140,6 +141,7 @@ export default class FakeFeatureStrategiesStore
|
||||
|
||||
async getFeatureToggleWithEnvs(
|
||||
featureName: string,
|
||||
userId?: number,
|
||||
archived: boolean = false,
|
||||
): Promise<FeatureToggleWithEnvironment> {
|
||||
const toggle = this.featureToggles.find(
|
||||
@ -155,18 +157,10 @@ export default class FakeFeatureStrategiesStore
|
||||
|
||||
getFeatureToggleWithVariantEnvs(
|
||||
featureName: string,
|
||||
userId?: number,
|
||||
archived?: boolean,
|
||||
): Promise<FeatureToggleWithEnvironment> {
|
||||
return this.getFeatureToggleWithEnvs(featureName, archived);
|
||||
}
|
||||
|
||||
async getFeatureOverview(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
projectId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
archived: boolean,
|
||||
): Promise<IFeatureOverview[]> {
|
||||
return Promise.resolve([]);
|
||||
return this.getFeatureToggleWithEnvs(featureName, userId, archived);
|
||||
}
|
||||
|
||||
async getFeatures(
|
||||
@ -309,6 +303,13 @@ export default class FakeFeatureStrategiesStore
|
||||
getStrategiesBySegment(): Promise<IFeatureStrategy[]> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
getFeatureOverview(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
params: IFeatureProjectUserParams,
|
||||
): Promise<IFeatureOverview[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FakeFeatureStrategiesStore;
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
IFeatureToggleQuery,
|
||||
} from '../../lib/types/model';
|
||||
import { IFeatureToggleClientStore } from '../../lib/types/stores/feature-toggle-client-store';
|
||||
import { IGetAdminFeatures } from '../../lib/db/feature-toggle-client-store';
|
||||
|
||||
export default class FakeFeatureToggleClientStore
|
||||
implements IFeatureToggleClientStore
|
||||
@ -52,10 +53,10 @@ export default class FakeFeatureToggleClientStore
|
||||
return this.getFeatures(query);
|
||||
}
|
||||
|
||||
async getAdmin(
|
||||
query?: IFeatureToggleQuery,
|
||||
archived: boolean = false,
|
||||
): Promise<IFeatureToggleClient[]> {
|
||||
async getAdmin({
|
||||
featureQuery: query,
|
||||
archived,
|
||||
}: IGetAdminFeatures): Promise<IFeatureToggleClient[]> {
|
||||
return this.getFeatures(query, archived);
|
||||
}
|
||||
|
||||
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -29,6 +29,7 @@ import FakeSegmentStore from './fake-segment-store';
|
||||
import FakeGroupStore from './fake-group-store';
|
||||
import FakePatStore from './fake-pat-store';
|
||||
import FakePublicSignupStore from './fake-public-signup-store';
|
||||
import FakeFavoriteFeaturesStore from './fake-favorite-features-store';
|
||||
|
||||
const createStores: () => IUnleashStores = () => {
|
||||
const db = {
|
||||
@ -69,6 +70,7 @@ const createStores: () => IUnleashStores = () => {
|
||||
groupStore: new FakeGroupStore(),
|
||||
patStore: new FakePatStore(),
|
||||
publicSignupTokenStore: new FakePublicSignupStore(),
|
||||
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
|
||||
};
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user