diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 423d1de6cd..11ce82b51d 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -41,6 +41,7 @@ export interface IFlags { ENABLE_DARK_MODE_SUPPORT?: boolean; embedProxyFrontend?: boolean; syncSSOGroups?: boolean; + favorites?: boolean; changeRequests?: boolean; cloneEnvironment?: boolean; variantsPerEnvironment?: boolean; diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index bda240f70f..8fbbd9b8f7 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -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, diff --git a/src/lib/db/favorite-features-store.ts b/src/lib/db/favorite-features-store.ts new file mode 100644 index 0000000000..32cf50b28c --- /dev/null +++ b/src/lib/db/favorite-features-store.ts @@ -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 { + const insertedFeature = await this.db( + T.FAVORITE_FEATURES, + ) + .insert({ feature, user_id: userId }) + .onConflict(['user_id', 'feature']) + .merge() + .returning('*'); + + return rowToFavorite(insertedFeature[0]); + } + + async delete({ userId, feature }: IFavoriteFeatureKey): Promise { + return this.db(T.FAVORITE_FEATURES) + .where({ feature, user_id: userId }) + .del(); + } + + async deleteAll(): Promise { + await this.db(T.FAVORITE_FEATURES).del(); + } + + destroy(): void {} + + async exists({ userId, feature }: IFavoriteFeatureKey): Promise { + 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 { + const favorite = await this.db + .table(T.FAVORITE_FEATURES) + .select() + .where({ feature, user_id: userId }) + .first(); + + return rowToFavorite(favorite); + } + + async getAll(): Promise { + const groups = await this.db( + T.FAVORITE_FEATURES, + ).select(); + return groups.map(rowToFavorite); + } +} diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index d0b6a0327f..8d62a2dc7c 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -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 { - return this.loadFeatureToggleWithEnvs(featureName, archived, false); + return this.loadFeatureToggleWithEnvs({ + featureName, + archived, + withEnvironmentVariants: false, + userId, + }); } async getFeatureToggleWithVariantEnvs( featureName: string, + userId?: number, archived: boolean = false, ): Promise { - return this.loadFeatureToggleWithEnvs(featureName, archived, true); + return this.loadFeatureToggleWithEnvs({ + featureName, + archived, + withEnvironmentVariants: true, + userId, + }); } - async loadFeatureToggleWithEnvs( - featureName: string, - archived: boolean, - withEnvironmentVariants: boolean, - ): Promise { + async loadFeatureToggleWithEnvs({ + featureName, + archived, + withEnvironmentVariants, + userId, + }: ILoadFeatureToggleWithEnvsParams): Promise { 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 { + async getFeatureOverview({ + projectId, + archived, + userId, + }: IFeatureProjectUserParams): Promise { + 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, diff --git a/src/lib/db/feature-toggle-client-store.ts b/src/lib/db/feature-toggle-client-store.ts index 79775c1c2b..a3908feb0f 100644 --- a/src/lib/db/feature-toggle-client-store.ts +++ b/src/lib/db/feature-toggle-client-store.ts @@ -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 { + private async getAll({ + featureQuery, + archived, + isAdmin, + includeStrategyIds, + userId, + }: IGetAllFeatures): Promise { 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 { - return this.getAll(featureQuery, false, false, includeStrategyIds); + return this.getAll({ + featureQuery, + archived: false, + isAdmin: false, + includeStrategyIds, + }); } - async getAdmin( - featureQuery?: IFeatureToggleQuery, - archived: boolean = false, - ): Promise { - return this.getAll(featureQuery, archived, true); + async getAdmin({ + featureQuery, + userId, + archived, + }: IGetAdminFeatures): Promise { + return this.getAll({ featureQuery, archived, isAdmin: true, userId }); } } diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index cc4633f471..1f8e4e3542 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -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, + ), }; }; diff --git a/src/lib/openapi/spec/feature-schema.ts b/src/lib/openapi/spec/feature-schema.ts index 8ae695d643..b00f52ab1b 100644 --- a/src/lib/openapi/spec/feature-schema.ts +++ b/src/lib/openapi/spec/feature-schema.ts @@ -34,6 +34,9 @@ export const featureSchema = { stale: { type: 'boolean', }, + favorite: { + type: 'boolean', + }, impressionData: { type: 'boolean', }, diff --git a/src/lib/routes/admin-api/favorites.ts b/src/lib/routes/admin-api/favorites.ts new file mode 100644 index 0000000000..091a20fd18 --- /dev/null +++ b/src/lib/routes/admin-api/favorites.ts @@ -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, + ) { + 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 { + 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 { + const { featureName } = req.params; + const { user } = req; + await this.favoritesService.removeFavoriteFeature({ + feature: featureName, + userId: user.id, + }); + res.status(200).end(); + } +} diff --git a/src/lib/routes/admin-api/feature.ts b/src/lib/routes/admin-api/feature.ts index d88f98d80d..ea6b827359 100644 --- a/src/lib/routes/admin-api/feature.ts +++ b/src/lib/routes/admin-api/feature.ts @@ -179,11 +179,12 @@ class FeatureController extends Controller { } async getAllToggles( - req: Request, + req: IAuthRequest, res: Response, ): Promise { 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, diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index 1792c8085f..ba67e3c45f 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -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, + ); } } diff --git a/src/lib/routes/admin-api/project/features.ts b/src/lib/routes/admin-api/project/features.ts index c547b877cb..294fdcd37a 100644 --- a/src/lib/routes/admin-api/project/features.ts +++ b/src/lib/routes/admin-api/project/features.ts @@ -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, + req: IAuthRequest, res: Response, ): Promise { 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, + req: IAuthRequest, res: Response, ): Promise { 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); } diff --git a/src/lib/services/favorites-service.ts b/src/lib/services/favorites-service.ts new file mode 100644 index 0000000000..c459f882bd --- /dev/null +++ b/src/lib/services/favorites-service.ts @@ -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, + config: IUnleashConfig, + ) { + this.config = config; + this.logger = config.getLogger('services/favorites-service.ts'); + this.favoriteFeaturesStore = favoriteFeaturesStore; + } + + async addFavoriteFeature( + favorite: IFavoriteFeatureKey, + ): Promise { + return this.favoriteFeaturesStore.addFavoriteFeature(favorite); + } + + async removeFavoriteFeature(favorite: IFavoriteFeatureKey): Promise { + return this.favoriteFeaturesStore.delete(favorite); + } +} diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 9971747aa3..d9eba9060b 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -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 { + async getFeature({ + featureName, + archived, + projectId, + environmentVariants, + userId, + }: IGetFeatureParams): Promise { 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 { - return this.featureToggleClientStore.getAdmin(query, archived); + return this.featureToggleClientStore.getAdmin({ + featureQuery: query, + userId, + archived, + }); } async getFeatureOverview( - projectId: string, - archived: boolean = false, + params: IFeatureProjectUserParams, ): Promise { - return this.featureStrategiesStore.getFeatureOverview( - projectId, - archived, - ); + return this.featureStrategiesStore.getFeatureOverview(params); } async getFeatureToggle( @@ -694,7 +707,6 @@ class FeatureToggleService { ): Promise { return this.featureStrategiesStore.getFeatureToggleWithEnvs( featureName, - false, ); } @@ -1171,7 +1183,7 @@ class FeatureToggleService { } async getArchivedFeatures(): Promise { - return this.getFeatureToggles({}, true); + return this.getFeatureToggles({}, undefined, true); } // TODO: add project id. diff --git a/src/lib/services/index.ts b/src/lib/services/index.ts index f0b3bdd4a4..8ae9d88b86 100644 --- a/src/lib/services/index.ts +++ b/src/lib/services/index.ts @@ -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, }; diff --git a/src/lib/services/project-health-service.ts b/src/lib/services/project-health-service.ts index df5d371305..aacf14a685 100644 --- a/src/lib/services/project-health-service.ts +++ b/src/lib/services/project-health-service.ts @@ -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, ); diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index b11f7abb35..037880538c 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -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, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index c90a2206e2..4c5a41a4aa 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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; diff --git a/src/lib/types/favorites.ts b/src/lib/types/favorites.ts new file mode 100644 index 0000000000..51e654afe4 --- /dev/null +++ b/src/lib/types/favorites.ts @@ -0,0 +1,7 @@ +export interface IFavoriteFeature { + feature: string; +} + +export interface IFavoriteProject { + project: string; +} diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 3955d7d332..2288830a0c 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -70,6 +70,8 @@ export interface IFeatureToggleClient { lastSeenAt?: Date; createdAt?: Date; tags?: ITag[]; + + favorite?: boolean; } export interface IFeatureEnvironmentInfo { diff --git a/src/lib/types/services.ts b/src/lib/types/services.ts index 5c2f32e020..8d2115c61e 100644 --- a/src/lib/types/services.ts +++ b/src/lib/types/services.ts @@ -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; } diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 2ee05af156..828e3f92cd 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -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, }; diff --git a/src/lib/types/stores/favorite-features.ts b/src/lib/types/stores/favorite-features.ts new file mode 100644 index 0000000000..8da6d9ab27 --- /dev/null +++ b/src/lib/types/stores/favorite-features.ts @@ -0,0 +1,14 @@ +import { IFavoriteFeature } from '../favorites'; +import { Store } from './store'; + +export interface IFavoriteFeatureKey { + userId: number; + feature: string; +} + +export interface IFavoriteFeaturesStore + extends Store { + addFavoriteFeature( + favorite: IFavoriteFeatureKey, + ): Promise; +} diff --git a/src/lib/types/stores/feature-strategies-store.ts b/src/lib/types/stores/feature-strategies-store.ts index fca2299aa6..3cd124b8b2 100644 --- a/src/lib/types/stores/feature-strategies-store.ts +++ b/src/lib/types/stores/feature-strategies-store.ts @@ -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; getFeatureToggleWithEnvs( featureName: string, + userId?: number, archived?: boolean, ): Promise; getFeatureToggleWithVariantEnvs( featureName: string, + userId?: number, archived?, ): Promise; getFeatureOverview( - projectId: string, - archived: boolean, + params: IFeatureProjectUserParams, ): Promise; getStrategyById(id: string): Promise; updateStrategy( diff --git a/src/lib/types/stores/feature-toggle-client-store.ts b/src/lib/types/stores/feature-toggle-client-store.ts index 3e9c65da92..2902ca9d83 100644 --- a/src/lib/types/stores/feature-toggle-client-store.ts +++ b/src/lib/types/stores/feature-toggle-client-store.ts @@ -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; // @Deprecated - getAdmin( - featureQuery: Partial, - archived: boolean, - ): Promise; + getAdmin(params: IGetAdminFeatures): Promise; } diff --git a/src/migrations/20221124123914-add-favorites.js b/src/migrations/20221124123914-add-favorites.js new file mode 100644 index 0000000000..9209e2098c --- /dev/null +++ b/src/migrations/20221124123914-add-favorites.js @@ -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, + ); +}; diff --git a/src/server-dev.ts b/src/server-dev.ts index c2b3b0393f..deeb886652 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -42,6 +42,7 @@ process.nextTick(async () => { changeRequests: true, cloneEnvironment: true, toggleTagFiltering: true, + favorites: true, variantsPerEnvironment: true, }, }, diff --git a/src/test/config/test-config.ts b/src/test/config/test-config.ts index 646f67735a..4e305cecfb 100644 --- a/src/test/config/test-config.ts +++ b/src/test/config/test-config.ts @@ -31,6 +31,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig { changeRequests: true, cloneEnvironment: true, variantsPerEnvironment: true, + favorites: true, }, }, }; diff --git a/src/test/e2e/api/admin/favorites.e2e.test.ts b/src/test/e2e/api/admin/favorites.e2e.test.ts new file mode 100644 index 0000000000..6736c6baec --- /dev/null +++ b/src/test/e2e/api/admin/favorites.e2e.test.ts @@ -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, + }); +}); diff --git a/src/test/e2e/api/admin/state.e2e.test.ts b/src/test/e2e/api/admin/state.e2e.test.ts index 5376654585..c5436e0cc0 100644 --- a/src/test/e2e/api/admin/state.e2e.test.ts +++ b/src/test/e2e/api/admin/state.e2e.test.ts @@ -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); }); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index c865f86f9d..90117cf899 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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", diff --git a/src/test/e2e/custom-auth.test.ts b/src/test/e2e/custom-auth.test.ts index 77b3c5725f..d8f9594488 100644 --- a/src/test/e2e/custom-auth.test.ts +++ b/src/test/e2e/custom-auth.test.ts @@ -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(); }); diff --git a/src/test/e2e/services/environment-service.test.ts b/src/test/e2e/services/environment-service.test.ts index ac21c74df5..23a33268fe 100644 --- a/src/test/e2e/services/environment-service.test.ts +++ b/src/test/e2e/services/environment-service.test.ts @@ -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([]); diff --git a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts index 050443539d..f79dd8f8f7 100644 --- a/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts +++ b/src/test/e2e/services/feature-toggle-service-v2.e2e.test.ts @@ -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, diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index 89ece175f9..85fcb07398 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -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); }); diff --git a/src/test/fixtures/fake-favorite-features-store.ts b/src/test/fixtures/fake-favorite-features-store.ts new file mode 100644 index 0000000000..2a31b1f23c --- /dev/null +++ b/src/test/fixtures/fake-favorite-features-store.ts @@ -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 { + return Promise.resolve(undefined); + } + + delete(key: IFavoriteFeatureKey): Promise { + return Promise.resolve(undefined); + } + + deleteAll(): Promise { + return Promise.resolve(undefined); + } + + destroy(): void {} + + exists(key: IFavoriteFeatureKey): Promise { + return Promise.resolve(false); + } + + get(key: IFavoriteFeatureKey): Promise { + return Promise.resolve(undefined); + } + + getAll(query?: Object): Promise { + return Promise.resolve([]); + } +} diff --git a/src/test/fixtures/fake-feature-strategies-store.ts b/src/test/fixtures/fake-feature-strategies-store.ts index 718ae66b2c..0f7f5a20f3 100644 --- a/src/test/fixtures/fake-feature-strategies-store.ts +++ b/src/test/fixtures/fake-feature-strategies-store.ts @@ -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 { const toggle = this.featureToggles.find( @@ -155,18 +157,10 @@ export default class FakeFeatureStrategiesStore getFeatureToggleWithVariantEnvs( featureName: string, + userId?: number, archived?: boolean, ): Promise { - 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 { - return Promise.resolve([]); + return this.getFeatureToggleWithEnvs(featureName, userId, archived); } async getFeatures( @@ -309,6 +303,13 @@ export default class FakeFeatureStrategiesStore getStrategiesBySegment(): Promise { throw new Error('Method not implemented.'); } + + getFeatureOverview( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + params: IFeatureProjectUserParams, + ): Promise { + return Promise.resolve([]); + } } module.exports = FakeFeatureStrategiesStore; diff --git a/src/test/fixtures/fake-feature-toggle-client-store.ts b/src/test/fixtures/fake-feature-toggle-client-store.ts index 892ddf10e4..d765b8bcfd 100644 --- a/src/test/fixtures/fake-feature-toggle-client-store.ts +++ b/src/test/fixtures/fake-feature-toggle-client-store.ts @@ -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 { + async getAdmin({ + featureQuery: query, + archived, + }: IGetAdminFeatures): Promise { return this.getFeatures(query, archived); } diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index e5506cabdf..e1c3b00df5 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -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(), }; };