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:
parent
071f62c606
commit
b32d3d0fee
@ -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;
|
||||||
|
@ -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,
|
||||||
|
94
src/lib/db/favorite-features-store.ts
Normal file
94
src/lib/db/favorite-features-store.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import EventEmitter from 'events';
|
||||||
|
import { IFavoriteFeaturesStore } from '../types';
|
||||||
|
import { Logger, LogProvider } from '../logger';
|
||||||
|
import { Knex } from 'knex';
|
||||||
|
import { IFavoriteFeatureKey } from '../types/stores/favorite-features';
|
||||||
|
import { IFavoriteFeature } from '../types/favorites';
|
||||||
|
|
||||||
|
const T = {
|
||||||
|
FAVORITE_FEATURES: 'favorite_features',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IFavoriteFeatureRow {
|
||||||
|
user_id: number;
|
||||||
|
feature: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowToFavorite = (row: IFavoriteFeatureRow) => {
|
||||||
|
return {
|
||||||
|
userId: row.user_id,
|
||||||
|
feature: row.feature,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FavoriteFeaturesStore implements IFavoriteFeaturesStore {
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
private eventBus: EventEmitter;
|
||||||
|
|
||||||
|
private db: Knex;
|
||||||
|
|
||||||
|
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||||
|
this.db = db;
|
||||||
|
this.eventBus = eventBus;
|
||||||
|
this.logger = getLogger('lib/db/favorites-store.ts');
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFavoriteFeature({
|
||||||
|
userId,
|
||||||
|
feature,
|
||||||
|
}: IFavoriteFeatureKey): Promise<IFavoriteFeature> {
|
||||||
|
const insertedFeature = await this.db<IFavoriteFeatureRow>(
|
||||||
|
T.FAVORITE_FEATURES,
|
||||||
|
)
|
||||||
|
.insert({ feature, user_id: userId })
|
||||||
|
.onConflict(['user_id', 'feature'])
|
||||||
|
.merge()
|
||||||
|
.returning('*');
|
||||||
|
|
||||||
|
return rowToFavorite(insertedFeature[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete({ userId, feature }: IFavoriteFeatureKey): Promise<void> {
|
||||||
|
return this.db(T.FAVORITE_FEATURES)
|
||||||
|
.where({ feature, user_id: userId })
|
||||||
|
.del();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAll(): Promise<void> {
|
||||||
|
await this.db(T.FAVORITE_FEATURES).del();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {}
|
||||||
|
|
||||||
|
async exists({ userId, feature }: IFavoriteFeatureKey): Promise<boolean> {
|
||||||
|
const result = await this.db.raw(
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM ${T.FAVORITE_FEATURES} WHERE user_id = ? AND feature = ?) AS present`,
|
||||||
|
[userId, feature],
|
||||||
|
);
|
||||||
|
const { present } = result.rows[0];
|
||||||
|
return present;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get({
|
||||||
|
userId,
|
||||||
|
feature,
|
||||||
|
}: IFavoriteFeatureKey): Promise<IFavoriteFeature> {
|
||||||
|
const favorite = await this.db
|
||||||
|
.table<IFavoriteFeatureRow>(T.FAVORITE_FEATURES)
|
||||||
|
.select()
|
||||||
|
.where({ feature, user_id: userId })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return rowToFavorite(favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<IFavoriteFeature[]> {
|
||||||
|
const groups = await this.db<IFavoriteFeatureRow>(
|
||||||
|
T.FAVORITE_FEATURES,
|
||||||
|
).select();
|
||||||
|
return groups.map(rowToFavorite);
|
||||||
|
}
|
||||||
|
}
|
@ -21,6 +21,7 @@ import FeatureToggleStore from './feature-toggle-store';
|
|||||||
import { ensureStringValue } from '../util/ensureStringValue';
|
import { 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,
|
||||||
|
@ -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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -34,6 +34,9 @@ export const featureSchema = {
|
|||||||
stale: {
|
stale: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
},
|
},
|
||||||
|
favorite: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
impressionData: {
|
impressionData: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
},
|
},
|
||||||
|
82
src/lib/routes/admin-api/favorites.ts
Normal file
82
src/lib/routes/admin-api/favorites.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import Controller from '../controller';
|
||||||
|
import { FavoritesService, OpenApiService } from '../../services';
|
||||||
|
import { Logger } from '../../logger';
|
||||||
|
import { IUnleashConfig, IUnleashServices, NONE } from '../../types';
|
||||||
|
import { emptyResponse } from '../../openapi';
|
||||||
|
import { IAuthRequest } from '../unleash-types';
|
||||||
|
|
||||||
|
export default class FavoritesController extends Controller {
|
||||||
|
private favoritesService: FavoritesService;
|
||||||
|
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
private openApiService: OpenApiService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
config: IUnleashConfig,
|
||||||
|
{
|
||||||
|
favoritesService,
|
||||||
|
openApiService,
|
||||||
|
}: Pick<IUnleashServices, 'favoritesService' | 'openApiService'>,
|
||||||
|
) {
|
||||||
|
super(config);
|
||||||
|
this.logger = config.getLogger('/routes/favorites-controller');
|
||||||
|
this.favoritesService = favoritesService;
|
||||||
|
this.openApiService = openApiService;
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'post',
|
||||||
|
path: '/:projectId/features/:featureName/favorites',
|
||||||
|
handler: this.addFavoriteFeature,
|
||||||
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Features'],
|
||||||
|
operationId: 'addFavoriteFeature',
|
||||||
|
responses: { 200: emptyResponse },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'delete',
|
||||||
|
path: '/:projectId/features/:featureName/favorites',
|
||||||
|
handler: this.removeFavoriteFeature,
|
||||||
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Features'],
|
||||||
|
operationId: 'removeFavoriteFeature',
|
||||||
|
responses: { 200: emptyResponse },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFavoriteFeature(
|
||||||
|
req: IAuthRequest<{ featureName: string }>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { featureName } = req.params;
|
||||||
|
const { user } = req;
|
||||||
|
await this.favoritesService.addFavoriteFeature({
|
||||||
|
feature: featureName,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
res.status(200).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFavoriteFeature(
|
||||||
|
req: IAuthRequest<{ featureName: string }>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { featureName } = req.params;
|
||||||
|
const { user } = req;
|
||||||
|
await this.favoritesService.removeFavoriteFeature({
|
||||||
|
feature: featureName,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
res.status(200).end();
|
||||||
|
}
|
||||||
|
}
|
@ -179,11 +179,12 @@ class FeatureController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAllToggles(
|
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,
|
||||||
|
@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
37
src/lib/services/favorites-service.ts
Normal file
37
src/lib/services/favorites-service.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { IUnleashConfig } from '../types/option';
|
||||||
|
import { IUnleashStores } from '../types/stores';
|
||||||
|
import { Logger } from '../logger';
|
||||||
|
import {
|
||||||
|
IFavoriteFeatureKey,
|
||||||
|
IFavoriteFeaturesStore,
|
||||||
|
} from '../types/stores/favorite-features';
|
||||||
|
import { IFavoriteFeature } from '../types/favorites';
|
||||||
|
|
||||||
|
export class FavoritesService {
|
||||||
|
private config: IUnleashConfig;
|
||||||
|
|
||||||
|
private logger: Logger;
|
||||||
|
|
||||||
|
private favoriteFeaturesStore: IFavoriteFeaturesStore;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
{
|
||||||
|
favoriteFeaturesStore,
|
||||||
|
}: Pick<IUnleashStores, 'favoriteFeaturesStore'>,
|
||||||
|
config: IUnleashConfig,
|
||||||
|
) {
|
||||||
|
this.config = config;
|
||||||
|
this.logger = config.getLogger('services/favorites-service.ts');
|
||||||
|
this.favoriteFeaturesStore = favoriteFeaturesStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFavoriteFeature(
|
||||||
|
favorite: IFavoriteFeatureKey,
|
||||||
|
): Promise<IFavoriteFeature> {
|
||||||
|
return this.favoriteFeaturesStore.addFavoriteFeature(favorite);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFavoriteFeature(favorite: IFavoriteFeatureKey): Promise<void> {
|
||||||
|
return this.favoriteFeaturesStore.delete(favorite);
|
||||||
|
}
|
||||||
|
}
|
@ -78,6 +78,7 @@ import { AccessService } from './access-service';
|
|||||||
import { User } from '../server-impl';
|
import { 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.
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
);
|
);
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
7
src/lib/types/favorites.ts
Normal file
7
src/lib/types/favorites.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export interface IFavoriteFeature {
|
||||||
|
feature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFavoriteProject {
|
||||||
|
project: string;
|
||||||
|
}
|
@ -70,6 +70,8 @@ export interface IFeatureToggleClient {
|
|||||||
lastSeenAt?: Date;
|
lastSeenAt?: Date;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
tags?: ITag[];
|
tags?: ITag[];
|
||||||
|
|
||||||
|
favorite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeatureEnvironmentInfo {
|
export interface IFeatureEnvironmentInfo {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
|
14
src/lib/types/stores/favorite-features.ts
Normal file
14
src/lib/types/stores/favorite-features.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { IFavoriteFeature } from '../favorites';
|
||||||
|
import { Store } from './store';
|
||||||
|
|
||||||
|
export interface IFavoriteFeatureKey {
|
||||||
|
userId: number;
|
||||||
|
feature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IFavoriteFeaturesStore
|
||||||
|
extends Store<IFavoriteFeature, IFavoriteFeatureKey> {
|
||||||
|
addFavoriteFeature(
|
||||||
|
favorite: IFavoriteFeatureKey,
|
||||||
|
): Promise<IFavoriteFeature>;
|
||||||
|
}
|
@ -6,6 +6,7 @@ import {
|
|||||||
IVariant,
|
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(
|
||||||
|
@ -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[]>;
|
|
||||||
}
|
}
|
||||||
|
34
src/migrations/20221124123914-add-favorites.js
Normal file
34
src/migrations/20221124123914-add-favorites.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
exports.up = function (db, callback) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_features
|
||||||
|
(
|
||||||
|
feature VARCHAR(255) NOT NULL REFERENCES features (name) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (feature, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorite_projects
|
||||||
|
(
|
||||||
|
project VARCHAR(255) NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (project, user_id)
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function (db, callback) {
|
||||||
|
db.runSql(
|
||||||
|
`
|
||||||
|
DROP TABLE IF EXISTS favorite_features;
|
||||||
|
DROP TABLE IF EXISTS favorite_projects;
|
||||||
|
`,
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
};
|
@ -42,6 +42,7 @@ process.nextTick(async () => {
|
|||||||
changeRequests: true,
|
changeRequests: true,
|
||||||
cloneEnvironment: true,
|
cloneEnvironment: true,
|
||||||
toggleTagFiltering: true,
|
toggleTagFiltering: true,
|
||||||
|
favorites: true,
|
||||||
variantsPerEnvironment: true,
|
variantsPerEnvironment: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -31,6 +31,7 @@ export function createTestConfig(config?: IUnleashOptions): IUnleashConfig {
|
|||||||
changeRequests: true,
|
changeRequests: true,
|
||||||
cloneEnvironment: true,
|
cloneEnvironment: true,
|
||||||
variantsPerEnvironment: true,
|
variantsPerEnvironment: true,
|
||||||
|
favorites: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
171
src/test/e2e/api/admin/favorites.e2e.test.ts
Normal file
171
src/test/e2e/api/admin/favorites.e2e.test.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper';
|
||||||
|
import { IUnleashStores, RoleName } from '../../../../lib/types';
|
||||||
|
import { AccessService } from '../../../../lib/services';
|
||||||
|
import dbInit, { ITestDb } from '../../helpers/database-init';
|
||||||
|
import getLogger from '../../../fixtures/no-logger';
|
||||||
|
|
||||||
|
let app: IUnleashTest;
|
||||||
|
let db: ITestDb;
|
||||||
|
let stores: IUnleashStores;
|
||||||
|
let accessService: AccessService;
|
||||||
|
let editorRole;
|
||||||
|
|
||||||
|
const regularUserName = 'favorites-user';
|
||||||
|
|
||||||
|
const createFeature = async (featureName: string) => {
|
||||||
|
await app.request
|
||||||
|
.post('/api/admin/projects/default/features')
|
||||||
|
.send({
|
||||||
|
name: featureName,
|
||||||
|
})
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// await projectService.addEnvironmentToProject('default', environment);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loginRegularUser = () =>
|
||||||
|
app.request
|
||||||
|
.post(`/auth/demo/login`)
|
||||||
|
.send({
|
||||||
|
email: `${regularUserName}@getunleash.io`,
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const createUserEditorAccess = async (name, email) => {
|
||||||
|
const { userStore } = stores;
|
||||||
|
const user = await userStore.insert({ name, email });
|
||||||
|
await accessService.addUserToRole(user.id, editorRole.id, 'default');
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = await dbInit('favorites_api_serial', getLogger);
|
||||||
|
app = await setupAppWithAuth(db.stores);
|
||||||
|
stores = db.stores;
|
||||||
|
accessService = app.services.accessService;
|
||||||
|
|
||||||
|
const roles = await accessService.getRootRoles();
|
||||||
|
editorRole = roles.find((role) => role.name === RoleName.EDITOR);
|
||||||
|
|
||||||
|
await createUserEditorAccess(
|
||||||
|
regularUserName,
|
||||||
|
`${regularUserName}@getunleash.io`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.stores.favoriteFeaturesStore.deleteAll();
|
||||||
|
await db.stores.featureToggleStore.deleteAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await loginRegularUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have favorites true in project endpoint', async () => {
|
||||||
|
const featureName = 'test-feature';
|
||||||
|
await createFeature(featureName);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.post(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get(`/api/admin/projects/default/features`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body.features).toHaveLength(1);
|
||||||
|
expect(body.features[0]).toMatchObject({
|
||||||
|
name: featureName,
|
||||||
|
favorite: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have favorites false by default', async () => {
|
||||||
|
const featureName = 'test-feature';
|
||||||
|
await createFeature(featureName);
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get(`/api/admin/projects/default/features`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body.features).toHaveLength(1);
|
||||||
|
expect(body.features[0]).toMatchObject({
|
||||||
|
name: featureName,
|
||||||
|
favorite: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have favorites true in admin endpoint', async () => {
|
||||||
|
const featureName = 'test-feature';
|
||||||
|
await createFeature(featureName);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.post(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get(`/api/admin/features`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body.features).toHaveLength(1);
|
||||||
|
expect(body.features[0]).toMatchObject({
|
||||||
|
name: featureName,
|
||||||
|
favorite: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have favorites true in project single feature endpoint', async () => {
|
||||||
|
const featureName = 'test-feature';
|
||||||
|
await createFeature(featureName);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.post(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${featureName}`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
name: featureName,
|
||||||
|
favorite: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should have favorites false after deleting favorite', async () => {
|
||||||
|
const featureName = 'test-feature';
|
||||||
|
await createFeature(featureName);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.post(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await app.request
|
||||||
|
.delete(`/api/admin/projects/default/features/${featureName}/favorites`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get(`/api/admin/projects/default/features/${featureName}`)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
name: featureName,
|
||||||
|
favorite: false,
|
||||||
|
});
|
||||||
|
});
|
@ -314,7 +314,9 @@ test('Roundtrip with strategies in multiple environments works', async () => {
|
|||||||
keepExisting: false,
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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",
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
|
@ -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([]);
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
35
src/test/fixtures/fake-favorite-features-store.ts
vendored
Normal file
35
src/test/fixtures/fake-favorite-features-store.ts
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { IFavoriteFeaturesStore } from '../../lib/types';
|
||||||
|
import { IFavoriteFeatureKey } from '../../lib/types/stores/favorite-features';
|
||||||
|
import { IFavoriteFeature } from '../../lib/types/favorites';
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
export default class FakeFavoriteFeaturesStore
|
||||||
|
implements IFavoriteFeaturesStore
|
||||||
|
{
|
||||||
|
addFavoriteFeature(
|
||||||
|
favorite: IFavoriteFeatureKey,
|
||||||
|
): Promise<IFavoriteFeature> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: IFavoriteFeatureKey): Promise<void> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteAll(): Promise<void> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {}
|
||||||
|
|
||||||
|
exists(key: IFavoriteFeatureKey): Promise<boolean> {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: IFavoriteFeatureKey): Promise<IFavoriteFeature> {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(query?: Object): Promise<IFavoriteFeature[]> {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
}
|
@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../lib/types/model';
|
} 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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -29,6 +29,7 @@ import FakeSegmentStore from './fake-segment-store';
|
|||||||
import FakeGroupStore from './fake-group-store';
|
import 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(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user