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

Favorite project (#2569)

Adds ability to favorite projects.

1. Can favorite project
2. Can unfavorite project
3. Favorite field is returned on `/api/admin/projects/default`
4. Favorite field is returned on` /api/admin/projects`
This commit is contained in:
sjaanus 2022-11-30 12:41:53 +01:00 committed by GitHub
parent fab6fbb756
commit a22d5f5a43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 507 additions and 42 deletions

View File

@ -0,0 +1,96 @@
import EventEmitter from 'events';
import { Logger, LogProvider } from '../logger';
import { Knex } from 'knex';
import { IFavoriteProject } from '../types/favorites';
import {
IFavoriteProjectKey,
IFavoriteProjectsStore,
} from '../types/stores/favorite-projects';
const T = {
FAVORITE_PROJECTS: 'favorite_projects',
};
interface IFavoriteProjectRow {
user_id: number;
project: string;
created_at: Date;
}
const rowToFavorite = (row: IFavoriteProjectRow) => {
return {
userId: row.user_id,
project: row.project,
createdAt: row.created_at,
};
};
export class FavoriteProjectsStore implements IFavoriteProjectsStore {
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 addFavoriteProject({
userId,
project,
}: IFavoriteProjectKey): Promise<IFavoriteProject> {
const insertedProject = await this.db<IFavoriteProjectRow>(
T.FAVORITE_PROJECTS,
)
.insert({ project, user_id: userId })
.onConflict(['user_id', 'project'])
.merge()
.returning('*');
return rowToFavorite(insertedProject[0]);
}
async delete({ userId, project }: IFavoriteProjectKey): Promise<void> {
return this.db(T.FAVORITE_PROJECTS)
.where({ project, user_id: userId })
.del();
}
async deleteAll(): Promise<void> {
await this.db(T.FAVORITE_PROJECTS).del();
}
destroy(): void {}
async exists({ userId, project }: IFavoriteProjectKey): Promise<boolean> {
const result = await this.db.raw(
`SELECT EXISTS(SELECT 1 FROM ${T.FAVORITE_PROJECTS} WHERE user_id = ? AND project = ?) AS present`,
[userId, project],
);
const { present } = result.rows[0];
return present;
}
async get({
userId,
project,
}: IFavoriteProjectKey): Promise<IFavoriteProject> {
const favorite = await this.db
.table<IFavoriteProjectRow>(T.FAVORITE_PROJECTS)
.select()
.where({ project, user_id: userId })
.first();
return rowToFavorite(favorite);
}
async getAll(): Promise<IFavoriteProject[]> {
const groups = await this.db<IFavoriteProjectRow>(
T.FAVORITE_PROJECTS,
).select();
return groups.map(rowToFavorite);
}
}

View File

@ -22,6 +22,7 @@ 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'; import { IFeatureProjectUserParams } from '../routes/admin-api/project/features';
import Raw = Knex.Raw;
const COLUMNS = [ const COLUMNS = [
'id', 'id',
@ -257,7 +258,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
.where('name', featureName) .where('name', featureName)
.modify(FeatureToggleStore.filterByArchived, archived); .modify(FeatureToggleStore.filterByArchived, archived);
let selectColumns = ['features_view.*']; let selectColumns = ['features_view.*'] as (string | Raw<any>)[];
if (userId && this.flagResolver.isEnabled('favorites')) { if (userId && this.flagResolver.isEnabled('favorites')) {
query = query.leftJoin(`favorite_features`, function () { query = query.leftJoin(`favorite_features`, function () {
this.on( this.on(
@ -267,7 +268,9 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
}); });
selectColumns = [ selectColumns = [
...selectColumns, ...selectColumns,
'favorite_features.feature as favorite', this.db.raw(
'favorite_features.feature is not null as favorite',
),
]; ];
} }
const rows = await query.select(selectColumns); const rows = await query.select(selectColumns);
@ -279,7 +282,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
} }
acc.name = r.name; acc.name = r.name;
acc.favorite = r.favorite != null; acc.favorite = r.favorite;
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;
@ -428,7 +431,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
'feature_environments.environment as environment', 'feature_environments.environment as environment',
'environments.type as environment_type', 'environments.type as environment_type',
'environments.sort_order as environment_sort_order', 'environments.sort_order as environment_sort_order',
]; ] as (string | Raw<any>)[];
if (this.flagResolver.isEnabled('toggleTagFiltering')) { if (this.flagResolver.isEnabled('toggleTagFiltering')) {
query = query.leftJoin( query = query.leftJoin(
@ -443,14 +446,19 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
]; ];
} }
if (userId && this.flagResolver.isEnabled('favorites')) { if (userId && this.flagResolver.isEnabled('favorites')) {
query = query.leftJoin(`favorite_features as ff`, function () { query = query.leftJoin(`favorite_features`, function () {
this.on('ff.feature', 'features.name').andOnVal( this.on('favorite_features.feature', 'features.name').andOnVal(
'ff.user_id', 'favorite_features.user_id',
'=', '=',
userId, userId,
); );
}); });
selectColumns = [...selectColumns, 'ff.feature as favorite']; selectColumns = [
...selectColumns,
this.db.raw(
'favorite_features.feature is not null as favorite',
),
];
} }
query = query.select(selectColumns); query = query.select(selectColumns);
@ -469,7 +477,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, favorite: r.favorite,
name: r.feature_name, name: r.feature_name,
createdAt: r.created_at, createdAt: r.created_at,
lastSeenAt: r.last_seen_at, lastSeenAt: r.last_seen_at,

View File

@ -16,6 +16,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 Raw = Knex.Raw;
export interface FeaturesTable { export interface FeaturesTable {
name: string; name: string;
@ -101,7 +102,7 @@ export default class FeatureToggleClientStore
'fs.constraints as constraints', 'fs.constraints as constraints',
'segments.id as segment_id', 'segments.id as segment_id',
'segments.constraints as segment_constraints', 'segments.constraints as segment_constraints',
]; ] as (string | Raw<any>)[];
let query = this.db('features') let query = this.db('features')
.modify(FeatureToggleStore.filterByArchived, archived) .modify(FeatureToggleStore.filterByArchived, archived)
@ -148,14 +149,18 @@ export default class FeatureToggleClientStore
} }
if (userId && this.flagResolver.isEnabled('favorites')) { if (userId && this.flagResolver.isEnabled('favorites')) {
query = query.leftJoin(`favorite_features as ff`, function () { query = query.leftJoin(`favorite_features`, function () {
this.on('ff.feature', 'features.name').andOnVal( this.on(
'ff.user_id', 'favorite_features.feature',
'=', 'features.name',
userId, ).andOnVal('favorite_features.user_id', '=', userId);
);
}); });
selectColumns = [...selectColumns, 'ff.feature as favorite']; selectColumns = [
...selectColumns,
this.db.raw(
'favorite_features.feature is not null as favorite',
),
];
} }
} }
@ -207,7 +212,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.favorite = r.favorite;
feature.description = r.description; feature.description = r.description;
feature.project = r.project; feature.project = r.project;
feature.stale = r.stale; feature.stale = r.stale;

View File

@ -33,6 +33,7 @@ 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'; import { FavoriteFeaturesStore } from './favorite-features-store';
import { FavoriteProjectsStore } from './favorite-projects-store';
export const createStores = ( export const createStores = (
config: IUnleashConfig, config: IUnleashConfig,
@ -56,7 +57,12 @@ export const createStores = (
contextFieldStore: new ContextFieldStore(db, getLogger), contextFieldStore: new ContextFieldStore(db, getLogger),
settingStore: new SettingStore(db, getLogger), settingStore: new SettingStore(db, getLogger),
userStore: new UserStore(db, getLogger), userStore: new UserStore(db, getLogger),
projectStore: new ProjectStore(db, eventBus, getLogger), projectStore: new ProjectStore(
db,
eventBus,
getLogger,
config.flagResolver,
),
tagStore: new TagStore(db, eventBus, getLogger), tagStore: new TagStore(db, eventBus, getLogger),
tagTypeStore: new TagTypeStore(db, eventBus, getLogger), tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
addonStore: new AddonStore(db, eventBus, getLogger), addonStore: new AddonStore(db, eventBus, getLogger),
@ -100,6 +106,11 @@ export const createStores = (
eventBus, eventBus,
getLogger, getLogger,
), ),
favoriteProjectsStore: new FavoriteProjectsStore(
db,
eventBus,
getLogger,
),
}; };
}; };

View File

@ -13,6 +13,8 @@ import { DEFAULT_ENV } from '../util/constants';
import metricsHelper from '../util/metrics-helper'; import metricsHelper from '../util/metrics-helper';
import { DB_TIME } from '../metric-events'; import { DB_TIME } from '../metric-events';
import EventEmitter from 'events'; import EventEmitter from 'events';
import { IFlagResolver } from '../types';
import Raw = Knex.Raw;
const COLUMNS = [ const COLUMNS = [
'id', 'id',
@ -39,9 +41,16 @@ class ProjectStore implements IProjectStore {
private logger: Logger; private logger: Logger;
private flagResolver: IFlagResolver;
private timer: Function; private timer: Function;
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) { constructor(
db: Knex,
eventBus: EventEmitter,
getLogger: LogProvider,
flagResolver: IFlagResolver,
) {
this.db = db; this.db = db;
this.logger = getLogger('project-store.ts'); this.logger = getLogger('project-store.ts');
this.timer = (action) => this.timer = (action) =>
@ -49,6 +58,7 @@ class ProjectStore implements IProjectStore {
store: 'project', store: 'project',
action, action,
}); });
this.flagResolver = flagResolver;
} }
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@ -73,21 +83,43 @@ class ProjectStore implements IProjectStore {
async getProjectsWithCounts( async getProjectsWithCounts(
query?: IProjectQuery, query?: IProjectQuery,
userId?: number,
): Promise<IProjectWithCount[]> { ): Promise<IProjectWithCount[]> {
const projectTimer = this.timer('getProjectsWithCount'); const projectTimer = this.timer('getProjectsWithCount');
let projects = this.db(TABLE) let projects = this.db(TABLE)
.select(
this.db.raw(
'projects.id, projects.name, projects.description, projects.health, projects.updated_at, count(features.name) AS number_of_features',
),
)
.leftJoin('features', 'features.project', 'projects.id') .leftJoin('features', 'features.project', 'projects.id')
.groupBy('projects.id')
.orderBy('projects.name', 'asc'); .orderBy('projects.name', 'asc');
if (query) { if (query) {
projects = projects.where(query); projects = projects.where(query);
} }
const projectAndFeatureCount = await projects; let selectColumns = [
this.db.raw(
'projects.id, projects.name, projects.description, projects.health, projects.updated_at, count(features.name) AS number_of_features',
),
] as (string | Raw<any>)[];
let groupByColumns = ['projects.id'];
if (userId && this.flagResolver.isEnabled('favorites')) {
projects = projects.leftJoin(`favorite_projects`, function () {
this.on('favorite_projects.project', 'projects.id').andOnVal(
'favorite_projects.user_id',
'=',
userId,
);
});
selectColumns = [
...selectColumns,
this.db.raw(
'favorite_projects.project is not null as favorite',
),
];
groupByColumns = [...groupByColumns, 'favorite_projects.project'];
}
const projectAndFeatureCount = await projects
.select(selectColumns)
.groupBy(groupByColumns);
const projectsWithFeatureCount = projectAndFeatureCount.map( const projectsWithFeatureCount = projectAndFeatureCount.map(
this.mapProjectWithCountRow, this.mapProjectWithCountRow,
@ -112,6 +144,7 @@ class ProjectStore implements IProjectStore {
id: row.id, id: row.id,
description: row.description, description: row.description,
health: row.health, health: row.health,
favorite: row.favorite,
featureCount: Number(row.number_of_features) || 0, featureCount: Number(row.number_of_features) || 0,
memberCount: Number(row.number_of_users) || 0, memberCount: Number(row.number_of_users) || 0,
updatedAt: row.updated_at, updatedAt: row.updated_at,

View File

@ -45,6 +45,9 @@ export const healthOverviewSchema = {
format: 'date-time', format: 'date-time',
nullable: true, nullable: true,
}, },
favorite: {
type: 'boolean',
},
}, },
components: { components: {
schemas: { schemas: {

View File

@ -36,6 +36,9 @@ export const projectSchema = {
changeRequestsEnabled: { changeRequestsEnabled: {
type: 'boolean', type: 'boolean',
}, },
favorite: {
type: 'boolean',
},
}, },
components: {}, components: {},
} as const; } as const;

View File

@ -52,6 +52,34 @@ export default class FavoritesController extends Controller {
}), }),
], ],
}); });
this.route({
method: 'post',
path: '/:projectId/favorites',
handler: this.addFavoriteProject,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Features'],
operationId: 'addFavoriteProject',
responses: { 200: emptyResponse },
}),
],
});
this.route({
method: 'delete',
path: '/:projectId/favorites',
handler: this.removeFavoriteProject,
permission: NONE,
middleware: [
openApiService.validPath({
tags: ['Features'],
operationId: 'removeFavoriteProject',
responses: { 200: emptyResponse },
}),
],
});
} }
async addFavoriteFeature( async addFavoriteFeature(
@ -79,4 +107,30 @@ export default class FavoritesController extends Controller {
}); });
res.status(200).end(); res.status(200).end();
} }
async addFavoriteProject(
req: IAuthRequest<{ projectId: string }>,
res: Response,
): Promise<void> {
const { projectId } = req.params;
const { user } = req;
await this.favoritesService.addFavoriteProject({
project: projectId,
userId: user.id,
});
res.status(200).end();
}
async removeFavoriteProject(
req: IAuthRequest<{ projectId: string }>,
res: Response,
): Promise<void> {
const { projectId } = req.params;
const { user } = req;
await this.favoritesService.removeFavoriteProject({
project: projectId,
userId: user.id,
});
res.status(200).end();
}
} }

View File

@ -17,6 +17,7 @@ import {
healthReportSchema, healthReportSchema,
HealthReportSchema, HealthReportSchema,
} from '../../../openapi/spec/health-report-schema'; } from '../../../openapi/spec/health-report-schema';
import { IAuthRequest } from '../../unleash-types';
export default class ProjectHealthReport extends Controller { export default class ProjectHealthReport extends Controller {
private projectHealthService: ProjectHealthService; private projectHealthService: ProjectHealthService;
@ -71,14 +72,16 @@ export default class ProjectHealthReport extends Controller {
} }
async getProjectHealthOverview( async getProjectHealthOverview(
req: Request<IProjectParam, unknown, unknown, IArchivedQuery>, req: IAuthRequest<IProjectParam, unknown, unknown, IArchivedQuery>,
res: Response<HealthOverviewSchema>, res: Response<HealthOverviewSchema>,
): Promise<void> { ): Promise<void> {
const { projectId } = req.params; const { projectId } = req.params;
const { archived } = req.query; const { archived } = req.query;
const { user } = req;
const overview = await this.projectHealthService.getProjectOverview( const overview = await this.projectHealthService.getProjectOverview(
projectId, projectId,
archived, archived,
user.id,
); );
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
200, 200,

View File

@ -1,4 +1,4 @@
import { Request, Response } from 'express'; import { Response } from 'express';
import Controller from '../../controller'; import Controller from '../../controller';
import { IUnleashConfig } from '../../../types/option'; import { IUnleashConfig } from '../../../types/option';
import { IUnleashServices } from '../../../types/services'; import { IUnleashServices } from '../../../types/services';
@ -15,6 +15,7 @@ import {
import { OpenApiService } from '../../../services/openapi-service'; import { OpenApiService } from '../../../services/openapi-service';
import { serializeDates } from '../../../types/serialize-dates'; import { serializeDates } from '../../../types/serialize-dates';
import { createResponseSchema } from '../../../openapi/util/create-response-schema'; import { createResponseSchema } from '../../../openapi/util/create-response-schema';
import { IAuthRequest } from '../../unleash-types';
export default class ProjectApi extends Controller { export default class ProjectApi extends Controller {
private projectService: ProjectService; private projectService: ProjectService;
@ -49,12 +50,16 @@ export default class ProjectApi extends Controller {
} }
async getProjects( async getProjects(
req: Request, req: IAuthRequest,
res: Response<ProjectsSchema>, res: Response<ProjectsSchema>,
): Promise<void> { ): Promise<void> {
const projects = await this.projectService.getProjects({ const { user } = req;
id: 'default', const projects = await this.projectService.getProjects(
}); {
id: 'default',
},
user.id,
);
this.openApiService.respondWithValidation( this.openApiService.respondWithValidation(
200, 200,

View File

@ -5,7 +5,11 @@ import {
IFavoriteFeatureKey, IFavoriteFeatureKey,
IFavoriteFeaturesStore, IFavoriteFeaturesStore,
} from '../types/stores/favorite-features'; } from '../types/stores/favorite-features';
import { IFavoriteFeature } from '../types/favorites'; import { IFavoriteFeature, IFavoriteProject } from '../types/favorites';
import {
IFavoriteProjectKey,
IFavoriteProjectsStore,
} from '../types/stores/favorite-projects';
export class FavoritesService { export class FavoritesService {
private config: IUnleashConfig; private config: IUnleashConfig;
@ -14,15 +18,22 @@ export class FavoritesService {
private favoriteFeaturesStore: IFavoriteFeaturesStore; private favoriteFeaturesStore: IFavoriteFeaturesStore;
private favoriteProjectsStore: IFavoriteProjectsStore;
constructor( constructor(
{ {
favoriteFeaturesStore, favoriteFeaturesStore,
}: Pick<IUnleashStores, 'favoriteFeaturesStore'>, favoriteProjectsStore,
}: Pick<
IUnleashStores,
'favoriteFeaturesStore' | 'favoriteProjectsStore'
>,
config: IUnleashConfig, config: IUnleashConfig,
) { ) {
this.config = config; this.config = config;
this.logger = config.getLogger('services/favorites-service.ts'); this.logger = config.getLogger('services/favorites-service.ts');
this.favoriteFeaturesStore = favoriteFeaturesStore; this.favoriteFeaturesStore = favoriteFeaturesStore;
this.favoriteProjectsStore = favoriteProjectsStore;
} }
async addFavoriteFeature( async addFavoriteFeature(
@ -34,4 +45,27 @@ export class FavoritesService {
async removeFavoriteFeature(favorite: IFavoriteFeatureKey): Promise<void> { async removeFavoriteFeature(favorite: IFavoriteFeatureKey): Promise<void> {
return this.favoriteFeaturesStore.delete(favorite); return this.favoriteFeaturesStore.delete(favorite);
} }
async addFavoriteProject(
favorite: IFavoriteProjectKey,
): Promise<IFavoriteProject> {
return this.favoriteProjectsStore.addFavoriteProject(favorite);
}
async removeFavoriteProject(favorite: IFavoriteProjectKey): Promise<void> {
return this.favoriteProjectsStore.delete(favorite);
}
async isFavoriteProject(
projectId: string,
userId?: number,
): Promise<boolean> {
if (userId) {
return this.favoriteProjectsStore.exists({
project: projectId,
userId,
});
}
return Promise.resolve(false);
}
} }

View File

@ -84,10 +84,12 @@ export const createServices = (
); );
const environmentService = new EnvironmentService(stores, config); const environmentService = new EnvironmentService(stores, config);
const featureTagService = new FeatureTagService(stores, config); const featureTagService = new FeatureTagService(stores, config);
const favoritesService = new FavoritesService(stores, config);
const projectHealthService = new ProjectHealthService( const projectHealthService = new ProjectHealthService(
stores, stores,
config, config,
featureToggleServiceV2, featureToggleServiceV2,
favoritesService,
); );
const projectService = new ProjectService( const projectService = new ProjectService(
stores, stores,
@ -124,7 +126,6 @@ export const createServices = (
config, config,
versionService, versionService,
); );
const favoritesService = new FavoritesService(stores, config);
return { return {
accessService, accessService,

View File

@ -14,6 +14,7 @@ import { IProjectStore } from '../types/stores/project-store';
import FeatureToggleService from './feature-toggle-service'; import FeatureToggleService from './feature-toggle-service';
import { hoursToMilliseconds } from 'date-fns'; import { hoursToMilliseconds } from 'date-fns';
import Timer = NodeJS.Timer; import Timer = NodeJS.Timer;
import { FavoritesService } from './favorites-service';
export default class ProjectHealthService { export default class ProjectHealthService {
private logger: Logger; private logger: Logger;
@ -30,6 +31,8 @@ export default class ProjectHealthService {
private featureToggleService: FeatureToggleService; private featureToggleService: FeatureToggleService;
private favoritesService: FavoritesService;
constructor( constructor(
{ {
projectStore, projectStore,
@ -41,6 +44,7 @@ export default class ProjectHealthService {
>, >,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>, { getLogger }: Pick<IUnleashConfig, 'getLogger'>,
featureToggleService: FeatureToggleService, featureToggleService: FeatureToggleService,
favoritesService: FavoritesService,
) { ) {
this.logger = getLogger('services/project-health-service.ts'); this.logger = getLogger('services/project-health-service.ts');
this.projectStore = projectStore; this.projectStore = projectStore;
@ -52,12 +56,14 @@ export default class ProjectHealthService {
hoursToMilliseconds(1), hoursToMilliseconds(1),
).unref(); ).unref();
this.featureToggleService = featureToggleService; this.featureToggleService = featureToggleService;
this.favoritesService = favoritesService;
} }
// TODO: duplicate from project-service. // TODO: duplicate from project-service.
async getProjectOverview( async getProjectOverview(
projectId: string, projectId: string,
archived: boolean = false, archived: boolean = false,
userId?: number,
): Promise<IProjectOverview> { ): Promise<IProjectOverview> {
const project = await this.projectStore.get(projectId); const project = await this.projectStore.get(projectId);
const environments = await this.projectStore.getEnvironmentsForProject( const environments = await this.projectStore.getEnvironmentsForProject(
@ -70,10 +76,16 @@ export default class ProjectHealthService {
const members = await this.projectStore.getMembersCountByProject( const members = await this.projectStore.getMembersCountByProject(
projectId, projectId,
); );
const favorite = await this.favoritesService.isFavoriteProject(
projectId,
userId,
);
return { return {
name: project.name, name: project.name,
description: project.description, description: project.description,
health: project.health, health: project.health,
favorite: favorite,
updatedAt: project.updatedAt, updatedAt: project.updatedAt,
environments, environments,
features, features,
@ -85,7 +97,11 @@ export default class ProjectHealthService {
async getProjectHealthReport( async getProjectHealthReport(
projectId: string, projectId: string,
): Promise<IProjectHealthReport> { ): Promise<IProjectHealthReport> {
const overview = await this.getProjectOverview(projectId, false); const overview = await this.getProjectOverview(
projectId,
false,
undefined,
);
return { return {
...overview, ...overview,
potentiallyStaleCount: await this.potentiallyStaleCount( potentiallyStaleCount: await this.potentiallyStaleCount(

View File

@ -120,8 +120,11 @@ export default class ProjectService {
this.logger = config.getLogger('services/project-service.js'); this.logger = config.getLogger('services/project-service.js');
} }
async getProjects(query?: IProjectQuery): Promise<IProjectWithCount[]> { async getProjects(
return this.store.getProjectsWithCounts(query); query?: IProjectQuery,
userId?: number,
): Promise<IProjectWithCount[]> {
return this.store.getProjectsWithCounts(query, userId);
} }
async getProject(id: string): Promise<IProject> { async getProject(id: string): Promise<IProject> {

View File

@ -70,7 +70,6 @@ export interface IFeatureToggleClient {
lastSeenAt?: Date; lastSeenAt?: Date;
createdAt?: Date; createdAt?: Date;
tags?: ITag[]; tags?: ITag[];
favorite?: boolean; favorite?: boolean;
} }
@ -176,6 +175,7 @@ export interface IProjectOverview {
members: number; members: number;
version: number; version: number;
health: number; health: number;
favorite?: boolean;
updatedAt?: Date; updatedAt?: Date;
} }
@ -371,6 +371,7 @@ export interface ICustomRole {
export interface IProjectWithCount extends IProject { export interface IProjectWithCount extends IProject {
featureCount: number; featureCount: number;
memberCount: number; memberCount: number;
favorite?: boolean;
} }
export interface ISegment { export interface ISegment {

View File

@ -29,6 +29,7 @@ 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'; import { IFavoriteFeaturesStore } from './stores/favorite-features';
import { IFavoriteProjectsStore } from './stores/favorite-projects';
export interface IUnleashStores { export interface IUnleashStores {
accessStore: IAccessStore; accessStore: IAccessStore;
@ -62,6 +63,7 @@ export interface IUnleashStores {
patStore: IPatStore; patStore: IPatStore;
publicSignupTokenStore: IPublicSignupTokenStore; publicSignupTokenStore: IPublicSignupTokenStore;
favoriteFeaturesStore: IFavoriteFeaturesStore; favoriteFeaturesStore: IFavoriteFeaturesStore;
favoriteProjectsStore: IFavoriteProjectsStore;
} }
export { export {
@ -96,4 +98,5 @@ export {
IUserSplashStore, IUserSplashStore,
IUserStore, IUserStore,
IFavoriteFeaturesStore, IFavoriteFeaturesStore,
IFavoriteProjectsStore,
}; };

View File

@ -0,0 +1,14 @@
import { IFavoriteProject } from '../favorites';
import { Store } from './store';
export interface IFavoriteProjectKey {
userId: number;
project: string;
}
export interface IFavoriteProjectsStore
extends Store<IFavoriteProject, IFavoriteProjectKey> {
addFavoriteProject(
favorite: IFavoriteProjectKey,
): Promise<IFavoriteProject>;
}

View File

@ -34,21 +34,37 @@ export interface IProjectEnvironmentWithChangeRequests {
export interface IProjectStore extends Store<IProject, string> { export interface IProjectStore extends Store<IProject, string> {
hasProject(id: string): Promise<boolean>; hasProject(id: string): Promise<boolean>;
updateHealth(healthUpdate: IProjectHealthUpdate): Promise<void>; updateHealth(healthUpdate: IProjectHealthUpdate): Promise<void>;
create(project: IProjectInsert): Promise<IProject>; create(project: IProjectInsert): Promise<IProject>;
update(update: IProjectInsert): Promise<void>; update(update: IProjectInsert): Promise<void>;
importProjects( importProjects(
projects: IProjectInsert[], projects: IProjectInsert[],
environments?: IEnvironment[], environments?: IEnvironment[],
): Promise<IProject[]>; ): Promise<IProject[]>;
addEnvironmentToProject(id: string, environment: string): Promise<void>; addEnvironmentToProject(id: string, environment: string): Promise<void>;
deleteEnvironmentForProject(id: string, environment: string): Promise<void>; deleteEnvironmentForProject(id: string, environment: string): Promise<void>;
getEnvironmentsForProject(id: string): Promise<string[]>; getEnvironmentsForProject(id: string): Promise<string[]>;
getMembersCountByProject(projectId: string): Promise<number>; getMembersCountByProject(projectId: string): Promise<number>;
getProjectsByUser(userId: number): Promise<string[]>; getProjectsByUser(userId: number): Promise<string[]>;
getMembersCount(): Promise<IProjectMembersCount[]>; getMembersCount(): Promise<IProjectMembersCount[]>;
getProjectsWithCounts(query?: IProjectQuery): Promise<IProjectWithCount[]>;
getProjectsWithCounts(
query?: IProjectQuery,
userId?: number,
): Promise<IProjectWithCount[]>;
count(): Promise<number>; count(): Promise<number>;
getAll(query?: IProjectQuery): Promise<IProject[]>; getAll(query?: IProjectQuery): Promise<IProject[]>;
getProjectLinksForEnvironments( getProjectLinksForEnvironments(

View File

@ -51,6 +51,34 @@ const unfavoriteFeature = async (featureName: string) => {
.expect(200); .expect(200);
}; };
const favoriteProject = async (projectName = 'default') => {
await app.request
.post(`/api/admin/projects/${projectName}/favorites`)
.set('Content-Type', 'application/json')
.expect(200);
};
const unfavoriteProject = async (projectName = 'default') => {
await app.request
.delete(`/api/admin/projects/${projectName}/favorites`)
.set('Content-Type', 'application/json')
.expect(200);
};
const getProject = async (projectName = 'default') => {
return app.request
.get(`/api/admin/projects/${projectName}`)
.set('Content-Type', 'application/json')
.expect(200);
};
const getProjects = async () => {
return app.request
.get(`/api/admin/projects`)
.set('Content-Type', 'application/json')
.expect(200);
};
beforeAll(async () => { beforeAll(async () => {
db = await dbInit('favorites_api_serial', getLogger); db = await dbInit('favorites_api_serial', getLogger);
app = await setupAppWithAuth(db.stores); app = await setupAppWithAuth(db.stores);
@ -148,7 +176,6 @@ test('should be favorited in project single feature endpoint', async () => {
test('should be able to unfavorite feature', async () => { test('should be able to unfavorite feature', async () => {
const featureName = 'test-feature'; const featureName = 'test-feature';
await createFeature(featureName); await createFeature(featureName);
await favoriteFeature(featureName); await favoriteFeature(featureName);
await unfavoriteFeature(featureName); await unfavoriteFeature(featureName);
@ -162,3 +189,38 @@ test('should be able to unfavorite feature', async () => {
favorite: false, favorite: false,
}); });
}); });
test('should be favorited in projects list', async () => {
await favoriteProject();
const { body } = await getProjects();
expect(body.projects).toHaveLength(1);
expect(body.projects[0]).toMatchObject({
name: 'Default',
favorite: true,
});
});
test('should be favorited in single project endpoint', async () => {
await favoriteProject();
const { body } = await getProject();
expect(body).toMatchObject({
name: 'Default',
favorite: true,
});
});
test('project should not be favorited by default', async () => {
await favoriteProject();
await unfavoriteProject();
const { body } = await getProject();
expect(body).toMatchObject({
name: 'Default',
favorite: false,
});
});

View File

@ -1549,6 +1549,9 @@ exports[`should serve the OpenAPI spec 1`] = `
}, },
"type": "array", "type": "array",
}, },
"favorite": {
"type": "boolean",
},
"features": { "features": {
"items": { "items": {
"$ref": "#/components/schemas/featureSchema", "$ref": "#/components/schemas/featureSchema",
@ -1594,6 +1597,9 @@ exports[`should serve the OpenAPI spec 1`] = `
}, },
"type": "array", "type": "array",
}, },
"favorite": {
"type": "boolean",
},
"features": { "features": {
"items": { "items": {
"$ref": "#/components/schemas/featureSchema", "$ref": "#/components/schemas/featureSchema",
@ -2352,6 +2358,9 @@ exports[`should serve the OpenAPI spec 1`] = `
"description": { "description": {
"type": "string", "type": "string",
}, },
"favorite": {
"type": "boolean",
},
"featureCount": { "featureCount": {
"type": "number", "type": "number",
}, },
@ -5028,6 +5037,50 @@ If the provided project does not exist, the list of events will be empty.",
], ],
}, },
}, },
"/api/admin/projects/{projectId}/favorites": {
"delete": {
"operationId": "removeFavoriteProject",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Features",
],
},
"post": {
"operationId": "addFavoriteProject",
"parameters": [
{
"in": "path",
"name": "projectId",
"required": true,
"schema": {
"type": "string",
},
},
],
"responses": {
"200": {
"description": "This response has no body.",
},
},
"tags": [
"Features",
],
},
},
"/api/admin/projects/{projectId}/features": { "/api/admin/projects/{projectId}/features": {
"get": { "get": {
"operationId": "getFeatures", "operationId": "getFeatures",

View File

@ -9,6 +9,7 @@ import { IUnleashStores } from '../../../lib/types';
import { IUser } from '../../../lib/server-impl'; import { IUser } from '../../../lib/server-impl';
import { SegmentService } from '../../../lib/services/segment-service'; import { SegmentService } from '../../../lib/services/segment-service';
import { GroupService } from '../../../lib/services/group-service'; import { GroupService } from '../../../lib/services/group-service';
import { FavoritesService } from '../../../lib/services';
let stores: IUnleashStores; let stores: IUnleashStores;
let db: ITestDb; let db: ITestDb;
@ -17,6 +18,7 @@ let groupService;
let accessService; let accessService;
let projectHealthService; let projectHealthService;
let featureToggleService; let featureToggleService;
let favoritesService;
let user: IUser; let user: IUser;
beforeAll(async () => { beforeAll(async () => {
@ -42,10 +44,12 @@ beforeAll(async () => {
featureToggleService, featureToggleService,
groupService, groupService,
); );
favoritesService = new FavoritesService(stores, config);
projectHealthService = new ProjectHealthService( projectHealthService = new ProjectHealthService(
stores, stores,
config, config,
featureToggleService, featureToggleService,
favoritesService,
); );
}); });

View File

@ -0,0 +1,35 @@
import { IFavoriteProjectsStore } from '../../lib/types';
import { IFavoriteProjectKey } from '../../lib/types/stores/favorite-projects';
import { IFavoriteProject } from '../../lib/types/favorites';
/* eslint-disable @typescript-eslint/no-unused-vars */
export default class FakeFavoriteProjectsStore
implements IFavoriteProjectsStore
{
addFavoriteProject(
favorite: IFavoriteProjectKey,
): Promise<IFavoriteProject> {
return Promise.resolve(undefined);
}
delete(key: IFavoriteProjectKey): Promise<void> {
return Promise.resolve(undefined);
}
deleteAll(): Promise<void> {
return Promise.resolve(undefined);
}
destroy(): void {}
exists(key: IFavoriteProjectKey): Promise<boolean> {
return Promise.resolve(false);
}
get(key: IFavoriteProjectKey): Promise<IFavoriteProject> {
return Promise.resolve(undefined);
}
getAll(query?: Object): Promise<IFavoriteProject[]> {
return Promise.resolve([]);
}
}

View File

@ -30,6 +30,7 @@ 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'; import FakeFavoriteFeaturesStore from './fake-favorite-features-store';
import FakeFavoriteProjectsStore from './fake-favorite-projects-store';
const createStores: () => IUnleashStores = () => { const createStores: () => IUnleashStores = () => {
const db = { const db = {
@ -71,6 +72,7 @@ const createStores: () => IUnleashStores = () => {
patStore: new FakePatStore(), patStore: new FakePatStore(),
publicSignupTokenStore: new FakePublicSignupStore(), publicSignupTokenStore: new FakePublicSignupStore(),
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(), favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
}; };
}; };