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

Favorite features (#2550)

This commit is contained in:
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;
embedProxyFrontend?: boolean;
syncSSOGroups?: boolean;
favorites?: boolean;
changeRequests?: boolean;
cloneEnvironment?: boolean;
variantsPerEnvironment?: boolean;

View File

@ -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,

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

View File

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

View File

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

View File

@ -34,6 +34,9 @@ export const featureSchema = {
stale: {
type: 'boolean',
},
favorite: {
type: 'boolean',
},
impressionData: {
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(
req: Request,
req: IAuthRequest,
res: Response<FeaturesSchema>,
): Promise<void> {
const query = await this.prepQuery(req.query);
const features = await this.service.getFeatureToggles(query);
const { user } = req;
const features = await this.service.getFeatureToggles(query, user.id);
this.openApiService.respondWithValidation(
200,

View File

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

View File

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

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

View File

@ -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,
};

View File

@ -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,
);

View File

@ -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,

View File

@ -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;

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;
createdAt?: Date;
tags?: ITag[];
favorite?: boolean;
}
export interface IFeatureEnvironmentInfo {

View File

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

View File

@ -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,
};

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

View File

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

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,
cloneEnvironment: true,
toggleTagFiltering: true,
favorites: true,
variantsPerEnvironment: true,
},
},

View File

@ -31,6 +31,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
changeRequests: true,
cloneEnvironment: 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,
userName: 'export-tester',
});
const f = await app.services.featureToggleServiceV2.getFeature(featureName);
const f = await app.services.featureToggleServiceV2.getFeature({
featureName,
});
expect(f.environments).toHaveLength(4);
});

View File

@ -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",

View File

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

View File

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

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

View File

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

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

View File

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

View File

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