1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

Favorite features (#2550)

This commit is contained in:
sjaanus 2022-11-29 16:06:08 +01:00 committed by GitHub
parent 071f62c606
commit b32d3d0fee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 824 additions and 129 deletions

View File

@ -41,6 +41,7 @@ export interface IFlags {
ENABLE_DARK_MODE_SUPPORT?: boolean; ENABLE_DARK_MODE_SUPPORT?: boolean;
embedProxyFrontend?: boolean; embedProxyFrontend?: boolean;
syncSSOGroups?: boolean; syncSSOGroups?: boolean;
favorites?: boolean;
changeRequests?: boolean; changeRequests?: boolean;
cloneEnvironment?: boolean; cloneEnvironment?: boolean;
variantsPerEnvironment?: boolean; variantsPerEnvironment?: boolean;

View File

@ -74,6 +74,7 @@ exports[`should create default config 1`] = `
"cloneEnvironment": false, "cloneEnvironment": false,
"embedProxy": false, "embedProxy": false,
"embedProxyFrontend": false, "embedProxyFrontend": false,
"favorites": false,
"networkView": false, "networkView": false,
"proxyReturnAllToggles": false, "proxyReturnAllToggles": false,
"responseTimeWithAppName": false, "responseTimeWithAppName": false,
@ -92,6 +93,7 @@ exports[`should create default config 1`] = `
"cloneEnvironment": false, "cloneEnvironment": false,
"embedProxy": false, "embedProxy": false,
"embedProxyFrontend": false, "embedProxyFrontend": false,
"favorites": false,
"networkView": false, "networkView": false,
"proxyReturnAllToggles": false, "proxyReturnAllToggles": false,
"responseTimeWithAppName": false, "responseTimeWithAppName": false,

View 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);
}
}

View File

@ -21,6 +21,7 @@ import FeatureToggleStore from './feature-toggle-store';
import { ensureStringValue } from '../util/ensureStringValue'; import { ensureStringValue } from '../util/ensureStringValue';
import { mapValues } from '../util/map-values'; import { mapValues } from '../util/map-values';
import { IFlagResolver } from '../types/experimental'; import { IFlagResolver } from '../types/experimental';
import { IFeatureProjectUserParams } from '../routes/admin-api/project/features';
const COLUMNS = [ const COLUMNS = [
'id', 'id',
@ -59,6 +60,13 @@ interface IFeatureStrategiesTable {
created_at?: Date; created_at?: Date;
} }
export interface ILoadFeatureToggleWithEnvsParams {
featureName: string;
archived: boolean;
withEnvironmentVariants: boolean;
userId?: number;
}
function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy { function mapRow(row: IFeatureStrategiesTable): IFeatureStrategy {
return { return {
id: row.id, id: row.id,
@ -214,27 +222,53 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
async getFeatureToggleWithEnvs( async getFeatureToggleWithEnvs(
featureName: string, featureName: string,
userId?: number,
archived: boolean = false, archived: boolean = false,
): Promise<FeatureToggleWithEnvironment> { ): Promise<FeatureToggleWithEnvironment> {
return this.loadFeatureToggleWithEnvs(featureName, archived, false); return this.loadFeatureToggleWithEnvs({
featureName,
archived,
withEnvironmentVariants: false,
userId,
});
} }
async getFeatureToggleWithVariantEnvs( async getFeatureToggleWithVariantEnvs(
featureName: string, featureName: string,
userId?: number,
archived: boolean = false, archived: boolean = false,
): Promise<FeatureToggleWithEnvironment> { ): Promise<FeatureToggleWithEnvironment> {
return this.loadFeatureToggleWithEnvs(featureName, archived, true); return this.loadFeatureToggleWithEnvs({
featureName,
archived,
withEnvironmentVariants: true,
userId,
});
} }
async loadFeatureToggleWithEnvs( async loadFeatureToggleWithEnvs({
featureName: string, featureName,
archived: boolean, archived,
withEnvironmentVariants: boolean, withEnvironmentVariants,
): Promise<FeatureToggleWithEnvironment> { userId,
}: ILoadFeatureToggleWithEnvsParams): Promise<FeatureToggleWithEnvironment> {
const stopTimer = this.timer('getFeatureAdmin'); const stopTimer = this.timer('getFeatureAdmin');
const rows = await this.db('features_view') let query = this.db('features_view')
.where('name', featureName) .where('name', featureName)
.modify(FeatureToggleStore.filterByArchived, archived); .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(); stopTimer();
if (rows.length > 0) { if (rows.length > 0) {
const featureToggle = rows.reduce((acc, r) => { const featureToggle = rows.reduce((acc, r) => {
@ -243,6 +277,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
} }
acc.name = r.name; acc.name = r.name;
acc.favorite = r.favorite != null;
acc.impressionData = r.impression_data; acc.impressionData = r.impression_data;
acc.description = r.description; acc.description = r.description;
acc.project = r.project; acc.project = r.project;
@ -362,10 +397,25 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
}; };
} }
async getFeatureOverview( async getFeatureOverview({
projectId: string, projectId,
archived: boolean = false, archived,
): Promise<IFeatureOverview[]> { 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 = [ let selectColumns = [
'features.name as feature_name', 'features.name as feature_name',
'features.type as type', 'features.type as type',
@ -379,36 +429,30 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
]; ];
if (this.flagResolver.isEnabled('toggleTagFiltering')) { if (this.flagResolver.isEnabled('toggleTagFiltering')) {
query = query.leftJoin(
'feature_tag as ft',
'ft.feature_name',
'features.name',
);
selectColumns = [ selectColumns = [
...selectColumns, ...selectColumns,
'ft.tag_value as tag_value', 'ft.tag_value as tag_value',
'ft.tag_type as tag_type', 'ft.tag_type as tag_type',
]; ];
} }
if (userId && this.flagResolver.isEnabled('favorites')) {
let query = this.db('features') query = query.leftJoin(`favorite_features as ff`, function () {
.where({ project: projectId }) this.on('ff.feature', 'features.name').andOnVal(
.select(selectColumns) 'ff.user_id',
.modify(FeatureToggleStore.filterByArchived, archived) '=',
.leftJoin( userId,
'feature_environments', );
'feature_environments.feature_name', });
'features.name', selectColumns = [...selectColumns, 'ff.feature as favorite'];
)
.leftJoin(
'environments',
'feature_environments.environment',
'environments.name',
);
if (this.flagResolver.isEnabled('toggleTagFiltering')) {
query = query.leftJoin(
'feature_tag as ft',
'ft.feature_name',
'features.name',
);
} }
query = query.select(selectColumns);
const rows = await query; const rows = await query;
if (rows.length > 0) { if (rows.length > 0) {
@ -423,6 +467,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
} else { } else {
acc[r.feature_name] = { acc[r.feature_name] = {
type: r.type, type: r.type,
favorite: r.favorite != null,
name: r.feature_name, name: r.feature_name,
createdAt: r.created_at, createdAt: r.created_at,
lastSeenAt: r.last_seen_at, lastSeenAt: r.last_seen_at,

View File

@ -28,6 +28,20 @@ export interface FeaturesTable {
created_at?: Date; 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 export default class FeatureToggleClientStore
implements IFeatureToggleClientStore implements IFeatureToggleClientStore
{ {
@ -59,12 +73,13 @@ export default class FeatureToggleClientStore
this.flagResolver = flagResolver; this.flagResolver = flagResolver;
} }
private async getAll( private async getAll({
featureQuery?: IFeatureToggleQuery, featureQuery,
archived: boolean = false, archived,
isAdmin: boolean = true, isAdmin,
includeStrategyIds?: boolean, includeStrategyIds,
): Promise<IFeatureToggleClient[]> { userId,
}: IGetAllFeatures): Promise<IFeatureToggleClient[]> {
const environment = featureQuery?.environment || DEFAULT_ENV; const environment = featureQuery?.environment || DEFAULT_ENV;
const stopTimer = this.timer('getFeatureAdmin'); const stopTimer = this.timer('getFeatureAdmin');
@ -88,16 +103,7 @@ export default class FeatureToggleClientStore
'segments.constraints as segment_constraints', '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') let query = this.db('features')
.select(selectColumns)
.modify(FeatureToggleStore.filterByArchived, archived) .modify(FeatureToggleStore.filterByArchived, archived)
.leftJoin( .leftJoin(
this.db('feature_strategies') this.db('feature_strategies')
@ -127,14 +133,34 @@ export default class FeatureToggleClientStore
) )
.leftJoin('segments', `segments.id`, `fss.segment_id`); .leftJoin('segments', `segments.id`, `fss.segment_id`);
if (isAdmin && this.flagResolver.isEnabled('toggleTagFiltering')) { if (isAdmin) {
query = query.leftJoin( if (this.flagResolver.isEnabled('toggleTagFiltering')) {
'feature_tag as ft', query = query.leftJoin(
'ft.feature_name', 'feature_tag as ft',
'features.name', '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) {
if (featureQuery.tag) { if (featureQuery.tag) {
const tagQuery = this.db const tagQuery = this.db
@ -181,6 +207,7 @@ export default class FeatureToggleClientStore
feature.impressionData = r.impression_data; feature.impressionData = r.impression_data;
feature.enabled = !!r.enabled; feature.enabled = !!r.enabled;
feature.name = r.name; feature.name = r.name;
feature.favorite = r.favorite != null;
feature.description = r.description; feature.description = r.description;
feature.project = r.project; feature.project = r.project;
feature.stale = r.stale; feature.stale = r.stale;
@ -292,14 +319,20 @@ export default class FeatureToggleClientStore
featureQuery?: IFeatureToggleQuery, featureQuery?: IFeatureToggleQuery,
includeStrategyIds?: boolean, includeStrategyIds?: boolean,
): Promise<IFeatureToggleClient[]> { ): Promise<IFeatureToggleClient[]> {
return this.getAll(featureQuery, false, false, includeStrategyIds); return this.getAll({
featureQuery,
archived: false,
isAdmin: false,
includeStrategyIds,
});
} }
async getAdmin( async getAdmin({
featureQuery?: IFeatureToggleQuery, featureQuery,
archived: boolean = false, userId,
): Promise<IFeatureToggleClient[]> { archived,
return this.getAll(featureQuery, archived, true); }: IGetAdminFeatures): Promise<IFeatureToggleClient[]> {
return this.getAll({ featureQuery, archived, isAdmin: true, userId });
} }
} }

View File

@ -32,6 +32,7 @@ import SegmentStore from './segment-store';
import GroupStore from './group-store'; import GroupStore from './group-store';
import PatStore from './pat-store'; import PatStore from './pat-store';
import { PublicSignupTokenStore } from './public-signup-token-store'; import { PublicSignupTokenStore } from './public-signup-token-store';
import { FavoriteFeaturesStore } from './favorite-features-store';
export const createStores = ( export const createStores = (
config: IUnleashConfig, config: IUnleashConfig,
@ -94,6 +95,11 @@ export const createStores = (
getLogger, getLogger,
), ),
patStore: new PatStore(db, getLogger), patStore: new PatStore(db, getLogger),
favoriteFeaturesStore: new FavoriteFeaturesStore(
db,
eventBus,
getLogger,
),
}; };
}; };

View File

@ -34,6 +34,9 @@ export const featureSchema = {
stale: { stale: {
type: 'boolean', type: 'boolean',
}, },
favorite: {
type: 'boolean',
},
impressionData: { impressionData: {
type: 'boolean', type: 'boolean',
}, },

View 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();
}
}

View File

@ -179,11 +179,12 @@ class FeatureController extends Controller {
} }
async getAllToggles( async getAllToggles(
req: Request, req: IAuthRequest,
res: Response<FeaturesSchema>, res: Response<FeaturesSchema>,
): Promise<void> { ): Promise<void> {
const query = await this.prepQuery(req.query); 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( this.openApiService.respondWithValidation(
200, 200,

View File

@ -27,6 +27,7 @@ import PatController from './user/pat';
import { PublicSignupController } from './public-signup'; import { PublicSignupController } from './public-signup';
import { conditionalMiddleware } from '../../middleware/conditional-middleware'; import { conditionalMiddleware } from '../../middleware/conditional-middleware';
import InstanceAdminController from './instance-admin'; import InstanceAdminController from './instance-admin';
import FavoritesController from './favorites';
class AdminApi extends Controller { class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices) { constructor(config: IUnleashConfig, services: IUnleashServices) {
@ -119,6 +120,10 @@ class AdminApi extends Controller {
'/instance-admin', '/instance-admin',
new InstanceAdminController(config, services).router, new InstanceAdminController(config, services).router,
); );
this.app.use(
`/projects`,
new FavoritesController(config, services).router,
);
} }
} }

View File

@ -63,6 +63,11 @@ interface StrategyIdParams extends FeatureStrategyParams {
strategyId: string; strategyId: string;
} }
export interface IFeatureProjectUserParams extends ProjectParam {
archived?: boolean;
userId?: number;
}
const PATH = '/:projectId/features'; const PATH = '/:projectId/features';
const PATH_FEATURE = `${PATH}/:featureName`; const PATH_FEATURE = `${PATH}/:featureName`;
const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`; const PATH_FEATURE_CLONE = `${PATH_FEATURE}/clone`;
@ -394,13 +399,15 @@ export default class ProjectFeaturesController extends Controller {
} }
async getFeatures( async getFeatures(
req: Request<ProjectParam, any, any, any>, req: IAuthRequest<ProjectParam, any, any, any>,
res: Response<FeaturesSchema>, res: Response<FeaturesSchema>,
): Promise<void> { ): Promise<void> {
const { projectId } = req.params; const { projectId } = req.params;
const features = await this.featureService.getFeatureOverview( const { user } = req;
const features = await this.featureService.getFeatureOverview({
projectId, projectId,
); userId: user.id,
});
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
200, 200,
res, res,
@ -458,17 +465,19 @@ export default class ProjectFeaturesController extends Controller {
} }
async getFeature( async getFeature(
req: Request<FeatureParams, any, any, any>, req: IAuthRequest<FeatureParams, any, any, any>,
res: Response, res: Response,
): Promise<void> { ): Promise<void> {
const { featureName, projectId } = req.params; const { featureName, projectId } = req.params;
const { variantEnvironments } = req.query; const { variantEnvironments } = req.query;
const feature = await this.featureService.getFeature( const { user } = req;
const feature = await this.featureService.getFeature({
featureName, featureName,
false, archived: false,
projectId, projectId,
variantEnvironments === 'true', environmentVariants: variantEnvironments === 'true',
); userId: user.id,
});
res.status(200).json(feature); res.status(200).json(feature);
} }

View 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);
}
}

View File

@ -78,6 +78,7 @@ import { AccessService } from './access-service';
import { User } from '../server-impl'; import { User } from '../server-impl';
import { CREATE_FEATURE_STRATEGY } from '../types/permissions'; import { CREATE_FEATURE_STRATEGY } from '../types/permissions';
import NoAccessError from '../error/no-access-error'; import NoAccessError from '../error/no-access-error';
import { IFeatureProjectUserParams } from '../routes/admin-api/project/features';
interface IFeatureContext { interface IFeatureContext {
featureName: string; featureName: string;
@ -88,6 +89,14 @@ interface IFeatureStrategyContext extends IFeatureContext {
environment: string; environment: string;
} }
export interface IGetFeatureParams {
featureName: string;
archived?: boolean;
projectId?: string;
environmentVariants?: boolean;
userId?: number;
}
const oneOf = (values: string[], match: string) => { const oneOf = (values: string[], match: string) => {
return values.some((value) => value === match); return values.some((value) => value === match);
}; };
@ -608,12 +617,13 @@ class FeatureToggleService {
* @param archived - return archived or non archived toggles * @param archived - return archived or non archived toggles
* @param projectId - provide if you're requesting the feature in the context of a specific project. * @param projectId - provide if you're requesting the feature in the context of a specific project.
*/ */
async getFeature( async getFeature({
featureName: string, featureName,
archived: boolean = false, archived,
projectId?: string, projectId,
environmentVariants: boolean = false, environmentVariants,
): Promise<FeatureToggleWithEnvironment> { userId,
}: IGetFeatureParams): Promise<FeatureToggleWithEnvironment> {
if (projectId) { if (projectId) {
await this.validateFeatureContext({ featureName, projectId }); await this.validateFeatureContext({ featureName, projectId });
} }
@ -621,11 +631,13 @@ class FeatureToggleService {
if (environmentVariants) { if (environmentVariants) {
return this.featureStrategiesStore.getFeatureToggleWithVariantEnvs( return this.featureStrategiesStore.getFeatureToggleWithVariantEnvs(
featureName, featureName,
userId,
archived, archived,
); );
} else { } else {
return this.featureStrategiesStore.getFeatureToggleWithEnvs( return this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName, featureName,
userId,
archived, archived,
); );
} }
@ -674,19 +686,20 @@ class FeatureToggleService {
*/ */
async getFeatureToggles( async getFeatureToggles(
query?: IFeatureToggleQuery, query?: IFeatureToggleQuery,
userId?: number,
archived: boolean = false, archived: boolean = false,
): Promise<FeatureToggle[]> { ): Promise<FeatureToggle[]> {
return this.featureToggleClientStore.getAdmin(query, archived); return this.featureToggleClientStore.getAdmin({
featureQuery: query,
userId,
archived,
});
} }
async getFeatureOverview( async getFeatureOverview(
projectId: string, params: IFeatureProjectUserParams,
archived: boolean = false,
): Promise<IFeatureOverview[]> { ): Promise<IFeatureOverview[]> {
return this.featureStrategiesStore.getFeatureOverview( return this.featureStrategiesStore.getFeatureOverview(params);
projectId,
archived,
);
} }
async getFeatureToggle( async getFeatureToggle(
@ -694,7 +707,6 @@ class FeatureToggleService {
): Promise<FeatureToggleWithEnvironment> { ): Promise<FeatureToggleWithEnvironment> {
return this.featureStrategiesStore.getFeatureToggleWithEnvs( return this.featureStrategiesStore.getFeatureToggleWithEnvs(
featureName, featureName,
false,
); );
} }
@ -1171,7 +1183,7 @@ class FeatureToggleService {
} }
async getArchivedFeatures(): Promise<FeatureToggle[]> { async getArchivedFeatures(): Promise<FeatureToggle[]> {
return this.getFeatureToggles({}, true); return this.getFeatureToggles({}, undefined, true);
} }
// TODO: add project id. // TODO: add project id.

View File

@ -37,6 +37,7 @@ import PatService from './pat-service';
import { PublicSignupTokenService } from './public-signup-token-service'; import { PublicSignupTokenService } from './public-signup-token-service';
import { LastSeenService } from './client-metrics/last-seen-service'; import { LastSeenService } from './client-metrics/last-seen-service';
import { InstanceStatsService } from './instance-stats-service'; import { InstanceStatsService } from './instance-stats-service';
import { FavoritesService } from './favorites-service';
export const createServices = ( export const createServices = (
stores: IUnleashStores, stores: IUnleashStores,
@ -123,6 +124,7 @@ export const createServices = (
config, config,
versionService, versionService,
); );
const favoritesService = new FavoritesService(stores, config);
return { return {
accessService, accessService,
@ -163,6 +165,7 @@ export const createServices = (
publicSignupTokenService, publicSignupTokenService,
lastSeenService, lastSeenService,
instanceStatsService, instanceStatsService,
favoritesService,
}; };
}; };
@ -204,4 +207,5 @@ export {
PublicSignupTokenService, PublicSignupTokenService,
LastSeenService, LastSeenService,
InstanceStatsService, InstanceStatsService,
FavoritesService,
}; };

View File

@ -63,10 +63,10 @@ export default class ProjectHealthService {
const environments = await this.projectStore.getEnvironmentsForProject( const environments = await this.projectStore.getEnvironmentsForProject(
projectId, projectId,
); );
const features = await this.featureToggleService.getFeatureOverview( const features = await this.featureToggleService.getFeatureOverview({
projectId, projectId,
archived, archived,
); });
const members = await this.projectStore.getMembersCountByProject( const members = await this.projectStore.getMembersCountByProject(
projectId, projectId,
); );

View File

@ -592,10 +592,10 @@ export default class ProjectService {
const environments = await this.store.getEnvironmentsForProject( const environments = await this.store.getEnvironmentsForProject(
projectId, projectId,
); );
const features = await this.featureToggleService.getFeatureOverview( const features = await this.featureToggleService.getFeatureOverview({
projectId, projectId,
archived, archived,
); });
const members = await this.store.getMembersCountByProject(projectId); const members = await this.store.getMembersCountByProject(projectId);
return { return {
name: project.name, name: project.name,

View File

@ -50,6 +50,10 @@ export const defaultExperimentalOptions = {
process.env.UNLEASH_EXPERIMENTAL_TOKENS_LAST_SEEN, process.env.UNLEASH_EXPERIMENTAL_TOKENS_LAST_SEEN,
false, false,
), ),
favorites: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_FAVORITES,
false,
),
networkView: parseEnvVarBoolean( networkView: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_NETWORK_VIEW, process.env.UNLEASH_EXPERIMENTAL_NETWORK_VIEW,
false, false,
@ -72,6 +76,7 @@ export interface IExperimentalOptions {
proxyReturnAllToggles?: boolean; proxyReturnAllToggles?: boolean;
variantsPerEnvironment?: boolean; variantsPerEnvironment?: boolean;
tokensLastSeen?: boolean; tokensLastSeen?: boolean;
favorites?: boolean;
networkView?: boolean; networkView?: boolean;
}; };
externalResolver: IExternalFlagResolver; externalResolver: IExternalFlagResolver;

View File

@ -0,0 +1,7 @@
export interface IFavoriteFeature {
feature: string;
}
export interface IFavoriteProject {
project: string;
}

View File

@ -70,6 +70,8 @@ export interface IFeatureToggleClient {
lastSeenAt?: Date; lastSeenAt?: Date;
createdAt?: Date; createdAt?: Date;
tags?: ITag[]; tags?: ITag[];
favorite?: boolean;
} }
export interface IFeatureEnvironmentInfo { export interface IFeatureEnvironmentInfo {

View File

@ -35,6 +35,7 @@ import PatService from '../services/pat-service';
import { PublicSignupTokenService } from '../services/public-signup-token-service'; import { PublicSignupTokenService } from '../services/public-signup-token-service';
import { LastSeenService } from '../services/client-metrics/last-seen-service'; import { LastSeenService } from '../services/client-metrics/last-seen-service';
import { InstanceStatsService } from '../services/instance-stats-service'; import { InstanceStatsService } from '../services/instance-stats-service';
import { FavoritesService } from '../services';
export interface IUnleashServices { export interface IUnleashServices {
accessService: AccessService; accessService: AccessService;
@ -75,4 +76,5 @@ export interface IUnleashServices {
patService: PatService; patService: PatService;
lastSeenService: LastSeenService; lastSeenService: LastSeenService;
instanceStatsService: InstanceStatsService; instanceStatsService: InstanceStatsService;
favoritesService: FavoritesService;
} }

View File

@ -28,6 +28,7 @@ import { ISegmentStore } from './stores/segment-store';
import { IGroupStore } from './stores/group-store'; import { IGroupStore } from './stores/group-store';
import { IPatStore } from './stores/pat-store'; import { IPatStore } from './stores/pat-store';
import { IPublicSignupTokenStore } from './stores/public-signup-token-store'; import { IPublicSignupTokenStore } from './stores/public-signup-token-store';
import { IFavoriteFeaturesStore } from './stores/favorite-features';
export interface IUnleashStores { export interface IUnleashStores {
accessStore: IAccessStore; accessStore: IAccessStore;
@ -60,6 +61,7 @@ export interface IUnleashStores {
segmentStore: ISegmentStore; segmentStore: ISegmentStore;
patStore: IPatStore; patStore: IPatStore;
publicSignupTokenStore: IPublicSignupTokenStore; publicSignupTokenStore: IPublicSignupTokenStore;
favoriteFeaturesStore: IFavoriteFeaturesStore;
} }
export { export {
@ -93,4 +95,5 @@ export {
IUserFeedbackStore, IUserFeedbackStore,
IUserSplashStore, IUserSplashStore,
IUserStore, IUserStore,
IFavoriteFeaturesStore,
}; };

View 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>;
}

View File

@ -6,6 +6,7 @@ import {
IVariant, IVariant,
} from '../model'; } from '../model';
import { Store } from './store'; import { Store } from './store';
import { IFeatureProjectUserParams } from '../../routes/admin-api/project/features';
export interface FeatureConfigurationClient { export interface FeatureConfigurationClient {
name: string; name: string;
@ -32,15 +33,16 @@ export interface IFeatureStrategiesStore
): Promise<IFeatureStrategy[]>; ): Promise<IFeatureStrategy[]>;
getFeatureToggleWithEnvs( getFeatureToggleWithEnvs(
featureName: string, featureName: string,
userId?: number,
archived?: boolean, archived?: boolean,
): Promise<FeatureToggleWithEnvironment>; ): Promise<FeatureToggleWithEnvironment>;
getFeatureToggleWithVariantEnvs( getFeatureToggleWithVariantEnvs(
featureName: string, featureName: string,
userId?: number,
archived?, archived?,
): Promise<FeatureToggleWithEnvironment>; ): Promise<FeatureToggleWithEnvironment>;
getFeatureOverview( getFeatureOverview(
projectId: string, params: IFeatureProjectUserParams,
archived: boolean,
): Promise<IFeatureOverview[]>; ): Promise<IFeatureOverview[]>;
getStrategyById(id: string): Promise<IFeatureStrategy>; getStrategyById(id: string): Promise<IFeatureStrategy>;
updateStrategy( updateStrategy(

View File

@ -1,4 +1,5 @@
import { IFeatureToggleClient, IFeatureToggleQuery } from '../model'; import { IFeatureToggleClient, IFeatureToggleQuery } from '../model';
import { IGetAdminFeatures } from '../../db/feature-toggle-client-store';
export interface IFeatureToggleClientStore { export interface IFeatureToggleClientStore {
getClient( getClient(
@ -7,8 +8,5 @@ export interface IFeatureToggleClientStore {
): Promise<IFeatureToggleClient[]>; ): Promise<IFeatureToggleClient[]>;
// @Deprecated // @Deprecated
getAdmin( getAdmin(params: IGetAdminFeatures): Promise<IFeatureToggleClient[]>;
featureQuery: Partial<IFeatureToggleQuery>,
archived: boolean,
): Promise<IFeatureToggleClient[]>;
} }

View 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,
);
};

View File

@ -42,6 +42,7 @@ process.nextTick(async () => {
changeRequests: true, changeRequests: true,
cloneEnvironment: true, cloneEnvironment: true,
toggleTagFiltering: true, toggleTagFiltering: true,
favorites: true,
variantsPerEnvironment: true, variantsPerEnvironment: true,
}, },
}, },

View File

@ -31,6 +31,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
changeRequests: true, changeRequests: true,
cloneEnvironment: true, cloneEnvironment: true,
variantsPerEnvironment: true, variantsPerEnvironment: true,
favorites: true,
}, },
}, },
}; };

View 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,
});
});

View File

@ -314,7 +314,9 @@ test('Roundtrip with strategies in multiple environments works', async () => {
keepExisting: false, keepExisting: false,
userName: 'export-tester', userName: 'export-tester',
}); });
const f = await app.services.featureToggleServiceV2.getFeature(featureName); const f = await app.services.featureToggleServiceV2.getFeature({
featureName,
});
expect(f.environments).toHaveLength(4); expect(f.environments).toHaveLength(4);
}); });

View File

@ -1186,6 +1186,9 @@ exports[`should serve the OpenAPI spec 1`] = `
}, },
"type": "array", "type": "array",
}, },
"favorite": {
"type": "boolean",
},
"impressionData": { "impressionData": {
"type": "boolean", "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": { "/api/admin/projects/{projectId}/features/{featureName}/variants": {
"get": { "get": {
"operationId": "getFeatureVariants", "operationId": "getFeatureVariants",

View File

@ -1,9 +1,21 @@
import dbInit from './helpers/database-init'; import dbInit from './helpers/database-init';
import { setupAppWithCustomAuth } from './helpers/test-helper'; import { setupAppWithCustomAuth } from './helpers/test-helper';
import { RoleName } from '../../lib/types';
let db; let db;
let stores; 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 () => { beforeAll(async () => {
db = await dbInit('custom_auth_serial'); db = await dbInit('custom_auth_serial');
stores = db.stores; 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 () => { test('If actually configuring a custom middleware should configure the middleware', async () => {
expect.assertions(0); 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 request.get('/api/admin/features').expect(200);
await destroy(); await destroy();
}); });

View File

@ -51,10 +51,9 @@ test('Can connect environment to project', async () => {
stale: false, stale: false,
}); });
await service.addEnvironmentToProject('test-connection', 'default'); await service.addEnvironmentToProject('test-connection', 'default');
const overview = await stores.featureStrategiesStore.getFeatureOverview( const overview = await stores.featureStrategiesStore.getFeatureOverview({
'default', projectId: 'default',
false, });
);
overview.forEach((f) => { overview.forEach((f) => {
expect(f.environments).toEqual([ expect(f.environments).toEqual([
{ {
@ -77,10 +76,9 @@ test('Can remove environment from project', async () => {
}); });
await service.removeEnvironmentFromProject('test-connection', 'default'); await service.removeEnvironmentFromProject('test-connection', 'default');
await service.addEnvironmentToProject('removal-test', 'default'); await service.addEnvironmentToProject('removal-test', 'default');
let overview = await stores.featureStrategiesStore.getFeatureOverview( let overview = await stores.featureStrategiesStore.getFeatureOverview({
'default', projectId: 'default',
false, });
);
expect(overview.length).toBeGreaterThan(0); expect(overview.length).toBeGreaterThan(0);
overview.forEach((f) => { overview.forEach((f) => {
expect(f.environments).toEqual([ expect(f.environments).toEqual([
@ -93,10 +91,9 @@ test('Can remove environment from project', async () => {
]); ]);
}); });
await service.removeEnvironmentFromProject('removal-test', 'default'); await service.removeEnvironmentFromProject('removal-test', 'default');
overview = await stores.featureStrategiesStore.getFeatureOverview( overview = await stores.featureStrategiesStore.getFeatureOverview({
'default', projectId: 'default',
false, });
);
expect(overview.length).toBeGreaterThan(0); expect(overview.length).toBeGreaterThan(0);
overview.forEach((o) => { overview.forEach((o) => {
expect(o.environments).toEqual([]); expect(o.environments).toEqual([]);

View File

@ -167,8 +167,10 @@ test('should ignore name in the body when updating feature toggle', async () =>
}; };
await service.updateFeatureToggle(projectId, update, userName, featureName); await service.updateFeatureToggle(projectId, update, userName, featureName);
const featureOne = await service.getFeature(featureName); const featureOne = await service.getFeature({ featureName });
const featureTwo = await service.getFeature(secondFeatureName); const featureTwo = await service.getFeature({
featureName: secondFeatureName,
});
expect(featureOne.description).toBe(`I'm changed`); expect(featureOne.description).toBe(`I'm changed`);
expect(featureTwo.description).toBe('Second toggle'); 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.removeEnvironmentFromProject(prodEnv, 'default');
await environmentService.addEnvironmentToProject(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); expect(toggle.variants).toHaveLength(1);
}); });
@ -355,7 +361,7 @@ test('Cloning a feature toggle also clones segments correctly', async () => {
'test-user', 'test-user',
); );
let feature = await service.getFeature(clonedFeatureName); let feature = await service.getFeature({ featureName: clonedFeatureName });
expect( expect(
feature.environments.find((x) => x.name === 'default').strategies[0] feature.environments.find((x) => x.name === 'default').strategies[0]
.segments, .segments,

View File

@ -514,7 +514,9 @@ test('should change project when checks pass', async () => {
projectA.id, projectA.id,
); );
const updatedFeature = await featureToggleService.getFeature(toggle.name); const updatedFeature = await featureToggleService.getFeature({
featureName: toggle.name,
});
expect(updatedFeature.project).toBe(projectB.id); expect(updatedFeature.project).toBe(projectB.id);
}); });

View 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([]);
}
}

View File

@ -9,6 +9,7 @@ import {
} from '../../lib/types/model'; } from '../../lib/types/model';
import NotFoundError from '../../lib/error/notfound-error'; import NotFoundError from '../../lib/error/notfound-error';
import { IFeatureStrategiesStore } from '../../lib/types/stores/feature-strategies-store'; import { IFeatureStrategiesStore } from '../../lib/types/stores/feature-strategies-store';
import { IFeatureProjectUserParams } from '../../lib/routes/admin-api/project/features';
interface ProjectEnvironment { interface ProjectEnvironment {
projectName: string; projectName: string;
@ -140,6 +141,7 @@ export default class FakeFeatureStrategiesStore
async getFeatureToggleWithEnvs( async getFeatureToggleWithEnvs(
featureName: string, featureName: string,
userId?: number,
archived: boolean = false, archived: boolean = false,
): Promise<FeatureToggleWithEnvironment> { ): Promise<FeatureToggleWithEnvironment> {
const toggle = this.featureToggles.find( const toggle = this.featureToggles.find(
@ -155,18 +157,10 @@ export default class FakeFeatureStrategiesStore
getFeatureToggleWithVariantEnvs( getFeatureToggleWithVariantEnvs(
featureName: string, featureName: string,
userId?: number,
archived?: boolean, archived?: boolean,
): Promise<FeatureToggleWithEnvironment> { ): Promise<FeatureToggleWithEnvironment> {
return this.getFeatureToggleWithEnvs(featureName, archived); return this.getFeatureToggleWithEnvs(featureName, userId, 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([]);
} }
async getFeatures( async getFeatures(
@ -309,6 +303,13 @@ export default class FakeFeatureStrategiesStore
getStrategiesBySegment(): Promise<IFeatureStrategy[]> { getStrategiesBySegment(): Promise<IFeatureStrategy[]> {
throw new Error('Method not implemented.'); 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; module.exports = FakeFeatureStrategiesStore;

View File

@ -4,6 +4,7 @@ import {
IFeatureToggleQuery, IFeatureToggleQuery,
} from '../../lib/types/model'; } from '../../lib/types/model';
import { IFeatureToggleClientStore } from '../../lib/types/stores/feature-toggle-client-store'; import { IFeatureToggleClientStore } from '../../lib/types/stores/feature-toggle-client-store';
import { IGetAdminFeatures } from '../../lib/db/feature-toggle-client-store';
export default class FakeFeatureToggleClientStore export default class FakeFeatureToggleClientStore
implements IFeatureToggleClientStore implements IFeatureToggleClientStore
@ -52,10 +53,10 @@ export default class FakeFeatureToggleClientStore
return this.getFeatures(query); return this.getFeatures(query);
} }
async getAdmin( async getAdmin({
query?: IFeatureToggleQuery, featureQuery: query,
archived: boolean = false, archived,
): Promise<IFeatureToggleClient[]> { }: IGetAdminFeatures): Promise<IFeatureToggleClient[]> {
return this.getFeatures(query, archived); return this.getFeatures(query, archived);
} }

View File

@ -29,6 +29,7 @@ import FakeSegmentStore from './fake-segment-store';
import FakeGroupStore from './fake-group-store'; import FakeGroupStore from './fake-group-store';
import FakePatStore from './fake-pat-store'; import FakePatStore from './fake-pat-store';
import FakePublicSignupStore from './fake-public-signup-store'; import FakePublicSignupStore from './fake-public-signup-store';
import FakeFavoriteFeaturesStore from './fake-favorite-features-store';
const createStores: () => IUnleashStores = () => { const createStores: () => IUnleashStores = () => {
const db = { const db = {
@ -69,6 +70,7 @@ const createStores: () => IUnleashStores = () => {
groupStore: new FakeGroupStore(), groupStore: new FakeGroupStore(),
patStore: new FakePatStore(), patStore: new FakePatStore(),
publicSignupTokenStore: new FakePublicSignupStore(), publicSignupTokenStore: new FakePublicSignupStore(),
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
}; };
}; };