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:
parent
fab6fbb756
commit
a22d5f5a43
96
src/lib/db/favorite-projects-store.ts
Normal file
96
src/lib/db/favorite-projects-store.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -22,6 +22,7 @@ import { ensureStringValue } from '../util/ensureStringValue';
|
||||
import { mapValues } from '../util/map-values';
|
||||
import { IFlagResolver } from '../types/experimental';
|
||||
import { IFeatureProjectUserParams } from '../routes/admin-api/project/features';
|
||||
import Raw = Knex.Raw;
|
||||
|
||||
const COLUMNS = [
|
||||
'id',
|
||||
@ -257,7 +258,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
.where('name', featureName)
|
||||
.modify(FeatureToggleStore.filterByArchived, archived);
|
||||
|
||||
let selectColumns = ['features_view.*'];
|
||||
let selectColumns = ['features_view.*'] as (string | Raw<any>)[];
|
||||
if (userId && this.flagResolver.isEnabled('favorites')) {
|
||||
query = query.leftJoin(`favorite_features`, function () {
|
||||
this.on(
|
||||
@ -267,7 +268,9 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
});
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
'favorite_features.feature as favorite',
|
||||
this.db.raw(
|
||||
'favorite_features.feature is not null as favorite',
|
||||
),
|
||||
];
|
||||
}
|
||||
const rows = await query.select(selectColumns);
|
||||
@ -279,7 +282,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
}
|
||||
|
||||
acc.name = r.name;
|
||||
acc.favorite = r.favorite != null;
|
||||
acc.favorite = r.favorite;
|
||||
acc.impressionData = r.impression_data;
|
||||
acc.description = r.description;
|
||||
acc.project = r.project;
|
||||
@ -428,7 +431,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
'feature_environments.environment as environment',
|
||||
'environments.type as environment_type',
|
||||
'environments.sort_order as environment_sort_order',
|
||||
];
|
||||
] as (string | Raw<any>)[];
|
||||
|
||||
if (this.flagResolver.isEnabled('toggleTagFiltering')) {
|
||||
query = query.leftJoin(
|
||||
@ -443,14 +446,19 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
];
|
||||
}
|
||||
if (userId && this.flagResolver.isEnabled('favorites')) {
|
||||
query = query.leftJoin(`favorite_features as ff`, function () {
|
||||
this.on('ff.feature', 'features.name').andOnVal(
|
||||
'ff.user_id',
|
||||
query = query.leftJoin(`favorite_features`, function () {
|
||||
this.on('favorite_features.feature', 'features.name').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',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
query = query.select(selectColumns);
|
||||
@ -469,7 +477,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
} else {
|
||||
acc[r.feature_name] = {
|
||||
type: r.type,
|
||||
favorite: r.favorite != null,
|
||||
favorite: r.favorite,
|
||||
name: r.feature_name,
|
||||
createdAt: r.created_at,
|
||||
lastSeenAt: r.last_seen_at,
|
||||
|
@ -16,6 +16,7 @@ import FeatureToggleStore from './feature-toggle-store';
|
||||
import { ensureStringValue } from '../util/ensureStringValue';
|
||||
import { mapValues } from '../util/map-values';
|
||||
import { IFlagResolver } from '../types/experimental';
|
||||
import Raw = Knex.Raw;
|
||||
|
||||
export interface FeaturesTable {
|
||||
name: string;
|
||||
@ -101,7 +102,7 @@ export default class FeatureToggleClientStore
|
||||
'fs.constraints as constraints',
|
||||
'segments.id as segment_id',
|
||||
'segments.constraints as segment_constraints',
|
||||
];
|
||||
] as (string | Raw<any>)[];
|
||||
|
||||
let query = this.db('features')
|
||||
.modify(FeatureToggleStore.filterByArchived, archived)
|
||||
@ -148,14 +149,18 @@ export default class FeatureToggleClientStore
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
query = query.leftJoin(`favorite_features`, function () {
|
||||
this.on(
|
||||
'favorite_features.feature',
|
||||
'features.name',
|
||||
).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.enabled = !!r.enabled;
|
||||
feature.name = r.name;
|
||||
feature.favorite = r.favorite != null;
|
||||
feature.favorite = r.favorite;
|
||||
feature.description = r.description;
|
||||
feature.project = r.project;
|
||||
feature.stale = r.stale;
|
||||
|
@ -33,6 +33,7 @@ import GroupStore from './group-store';
|
||||
import PatStore from './pat-store';
|
||||
import { PublicSignupTokenStore } from './public-signup-token-store';
|
||||
import { FavoriteFeaturesStore } from './favorite-features-store';
|
||||
import { FavoriteProjectsStore } from './favorite-projects-store';
|
||||
|
||||
export const createStores = (
|
||||
config: IUnleashConfig,
|
||||
@ -56,7 +57,12 @@ export const createStores = (
|
||||
contextFieldStore: new ContextFieldStore(db, getLogger),
|
||||
settingStore: new SettingStore(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),
|
||||
tagTypeStore: new TagTypeStore(db, eventBus, getLogger),
|
||||
addonStore: new AddonStore(db, eventBus, getLogger),
|
||||
@ -100,6 +106,11 @@ export const createStores = (
|
||||
eventBus,
|
||||
getLogger,
|
||||
),
|
||||
favoriteProjectsStore: new FavoriteProjectsStore(
|
||||
db,
|
||||
eventBus,
|
||||
getLogger,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -13,6 +13,8 @@ import { DEFAULT_ENV } from '../util/constants';
|
||||
import metricsHelper from '../util/metrics-helper';
|
||||
import { DB_TIME } from '../metric-events';
|
||||
import EventEmitter from 'events';
|
||||
import { IFlagResolver } from '../types';
|
||||
import Raw = Knex.Raw;
|
||||
|
||||
const COLUMNS = [
|
||||
'id',
|
||||
@ -39,9 +41,16 @@ class ProjectStore implements IProjectStore {
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
private flagResolver: IFlagResolver;
|
||||
|
||||
private timer: Function;
|
||||
|
||||
constructor(db: Knex, eventBus: EventEmitter, getLogger: LogProvider) {
|
||||
constructor(
|
||||
db: Knex,
|
||||
eventBus: EventEmitter,
|
||||
getLogger: LogProvider,
|
||||
flagResolver: IFlagResolver,
|
||||
) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('project-store.ts');
|
||||
this.timer = (action) =>
|
||||
@ -49,6 +58,7 @@ class ProjectStore implements IProjectStore {
|
||||
store: 'project',
|
||||
action,
|
||||
});
|
||||
this.flagResolver = flagResolver;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
@ -73,21 +83,43 @@ class ProjectStore implements IProjectStore {
|
||||
|
||||
async getProjectsWithCounts(
|
||||
query?: IProjectQuery,
|
||||
userId?: number,
|
||||
): Promise<IProjectWithCount[]> {
|
||||
const projectTimer = this.timer('getProjectsWithCount');
|
||||
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')
|
||||
.groupBy('projects.id')
|
||||
.orderBy('projects.name', 'asc');
|
||||
if (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(
|
||||
this.mapProjectWithCountRow,
|
||||
@ -112,6 +144,7 @@ class ProjectStore implements IProjectStore {
|
||||
id: row.id,
|
||||
description: row.description,
|
||||
health: row.health,
|
||||
favorite: row.favorite,
|
||||
featureCount: Number(row.number_of_features) || 0,
|
||||
memberCount: Number(row.number_of_users) || 0,
|
||||
updatedAt: row.updated_at,
|
||||
|
@ -45,6 +45,9 @@ export const healthOverviewSchema = {
|
||||
format: 'date-time',
|
||||
nullable: true,
|
||||
},
|
||||
favorite: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
|
@ -36,6 +36,9 @@ export const projectSchema = {
|
||||
changeRequestsEnabled: {
|
||||
type: 'boolean',
|
||||
},
|
||||
favorite: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
components: {},
|
||||
} as const;
|
||||
|
@ -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(
|
||||
@ -79,4 +107,30 @@ export default class FavoritesController extends Controller {
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
healthReportSchema,
|
||||
HealthReportSchema,
|
||||
} from '../../../openapi/spec/health-report-schema';
|
||||
import { IAuthRequest } from '../../unleash-types';
|
||||
|
||||
export default class ProjectHealthReport extends Controller {
|
||||
private projectHealthService: ProjectHealthService;
|
||||
@ -71,14 +72,16 @@ export default class ProjectHealthReport extends Controller {
|
||||
}
|
||||
|
||||
async getProjectHealthOverview(
|
||||
req: Request<IProjectParam, unknown, unknown, IArchivedQuery>,
|
||||
req: IAuthRequest<IProjectParam, unknown, unknown, IArchivedQuery>,
|
||||
res: Response<HealthOverviewSchema>,
|
||||
): Promise<void> {
|
||||
const { projectId } = req.params;
|
||||
const { archived } = req.query;
|
||||
const { user } = req;
|
||||
const overview = await this.projectHealthService.getProjectOverview(
|
||||
projectId,
|
||||
archived,
|
||||
user.id,
|
||||
);
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { Response } from 'express';
|
||||
import Controller from '../../controller';
|
||||
import { IUnleashConfig } from '../../../types/option';
|
||||
import { IUnleashServices } from '../../../types/services';
|
||||
@ -15,6 +15,7 @@ import {
|
||||
import { OpenApiService } from '../../../services/openapi-service';
|
||||
import { serializeDates } from '../../../types/serialize-dates';
|
||||
import { createResponseSchema } from '../../../openapi/util/create-response-schema';
|
||||
import { IAuthRequest } from '../../unleash-types';
|
||||
|
||||
export default class ProjectApi extends Controller {
|
||||
private projectService: ProjectService;
|
||||
@ -49,12 +50,16 @@ export default class ProjectApi extends Controller {
|
||||
}
|
||||
|
||||
async getProjects(
|
||||
req: Request,
|
||||
req: IAuthRequest,
|
||||
res: Response<ProjectsSchema>,
|
||||
): Promise<void> {
|
||||
const projects = await this.projectService.getProjects({
|
||||
id: 'default',
|
||||
});
|
||||
const { user } = req;
|
||||
const projects = await this.projectService.getProjects(
|
||||
{
|
||||
id: 'default',
|
||||
},
|
||||
user.id,
|
||||
);
|
||||
|
||||
this.openApiService.respondWithValidation(
|
||||
200,
|
||||
|
@ -5,7 +5,11 @@ import {
|
||||
IFavoriteFeatureKey,
|
||||
IFavoriteFeaturesStore,
|
||||
} 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 {
|
||||
private config: IUnleashConfig;
|
||||
@ -14,15 +18,22 @@ export class FavoritesService {
|
||||
|
||||
private favoriteFeaturesStore: IFavoriteFeaturesStore;
|
||||
|
||||
private favoriteProjectsStore: IFavoriteProjectsStore;
|
||||
|
||||
constructor(
|
||||
{
|
||||
favoriteFeaturesStore,
|
||||
}: Pick<IUnleashStores, 'favoriteFeaturesStore'>,
|
||||
favoriteProjectsStore,
|
||||
}: Pick<
|
||||
IUnleashStores,
|
||||
'favoriteFeaturesStore' | 'favoriteProjectsStore'
|
||||
>,
|
||||
config: IUnleashConfig,
|
||||
) {
|
||||
this.config = config;
|
||||
this.logger = config.getLogger('services/favorites-service.ts');
|
||||
this.favoriteFeaturesStore = favoriteFeaturesStore;
|
||||
this.favoriteProjectsStore = favoriteProjectsStore;
|
||||
}
|
||||
|
||||
async addFavoriteFeature(
|
||||
@ -34,4 +45,27 @@ export class FavoritesService {
|
||||
async removeFavoriteFeature(favorite: IFavoriteFeatureKey): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -84,10 +84,12 @@ export const createServices = (
|
||||
);
|
||||
const environmentService = new EnvironmentService(stores, config);
|
||||
const featureTagService = new FeatureTagService(stores, config);
|
||||
const favoritesService = new FavoritesService(stores, config);
|
||||
const projectHealthService = new ProjectHealthService(
|
||||
stores,
|
||||
config,
|
||||
featureToggleServiceV2,
|
||||
favoritesService,
|
||||
);
|
||||
const projectService = new ProjectService(
|
||||
stores,
|
||||
@ -124,7 +126,6 @@ export const createServices = (
|
||||
config,
|
||||
versionService,
|
||||
);
|
||||
const favoritesService = new FavoritesService(stores, config);
|
||||
|
||||
return {
|
||||
accessService,
|
||||
|
@ -14,6 +14,7 @@ import { IProjectStore } from '../types/stores/project-store';
|
||||
import FeatureToggleService from './feature-toggle-service';
|
||||
import { hoursToMilliseconds } from 'date-fns';
|
||||
import Timer = NodeJS.Timer;
|
||||
import { FavoritesService } from './favorites-service';
|
||||
|
||||
export default class ProjectHealthService {
|
||||
private logger: Logger;
|
||||
@ -30,6 +31,8 @@ export default class ProjectHealthService {
|
||||
|
||||
private featureToggleService: FeatureToggleService;
|
||||
|
||||
private favoritesService: FavoritesService;
|
||||
|
||||
constructor(
|
||||
{
|
||||
projectStore,
|
||||
@ -41,6 +44,7 @@ export default class ProjectHealthService {
|
||||
>,
|
||||
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
|
||||
featureToggleService: FeatureToggleService,
|
||||
favoritesService: FavoritesService,
|
||||
) {
|
||||
this.logger = getLogger('services/project-health-service.ts');
|
||||
this.projectStore = projectStore;
|
||||
@ -52,12 +56,14 @@ export default class ProjectHealthService {
|
||||
hoursToMilliseconds(1),
|
||||
).unref();
|
||||
this.featureToggleService = featureToggleService;
|
||||
this.favoritesService = favoritesService;
|
||||
}
|
||||
|
||||
// TODO: duplicate from project-service.
|
||||
async getProjectOverview(
|
||||
projectId: string,
|
||||
archived: boolean = false,
|
||||
userId?: number,
|
||||
): Promise<IProjectOverview> {
|
||||
const project = await this.projectStore.get(projectId);
|
||||
const environments = await this.projectStore.getEnvironmentsForProject(
|
||||
@ -70,10 +76,16 @@ export default class ProjectHealthService {
|
||||
const members = await this.projectStore.getMembersCountByProject(
|
||||
projectId,
|
||||
);
|
||||
|
||||
const favorite = await this.favoritesService.isFavoriteProject(
|
||||
projectId,
|
||||
userId,
|
||||
);
|
||||
return {
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
health: project.health,
|
||||
favorite: favorite,
|
||||
updatedAt: project.updatedAt,
|
||||
environments,
|
||||
features,
|
||||
@ -85,7 +97,11 @@ export default class ProjectHealthService {
|
||||
async getProjectHealthReport(
|
||||
projectId: string,
|
||||
): Promise<IProjectHealthReport> {
|
||||
const overview = await this.getProjectOverview(projectId, false);
|
||||
const overview = await this.getProjectOverview(
|
||||
projectId,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
return {
|
||||
...overview,
|
||||
potentiallyStaleCount: await this.potentiallyStaleCount(
|
||||
|
@ -120,8 +120,11 @@ export default class ProjectService {
|
||||
this.logger = config.getLogger('services/project-service.js');
|
||||
}
|
||||
|
||||
async getProjects(query?: IProjectQuery): Promise<IProjectWithCount[]> {
|
||||
return this.store.getProjectsWithCounts(query);
|
||||
async getProjects(
|
||||
query?: IProjectQuery,
|
||||
userId?: number,
|
||||
): Promise<IProjectWithCount[]> {
|
||||
return this.store.getProjectsWithCounts(query, userId);
|
||||
}
|
||||
|
||||
async getProject(id: string): Promise<IProject> {
|
||||
|
@ -70,7 +70,6 @@ export interface IFeatureToggleClient {
|
||||
lastSeenAt?: Date;
|
||||
createdAt?: Date;
|
||||
tags?: ITag[];
|
||||
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
@ -176,6 +175,7 @@ export interface IProjectOverview {
|
||||
members: number;
|
||||
version: number;
|
||||
health: number;
|
||||
favorite?: boolean;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
@ -371,6 +371,7 @@ export interface ICustomRole {
|
||||
export interface IProjectWithCount extends IProject {
|
||||
featureCount: number;
|
||||
memberCount: number;
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface ISegment {
|
||||
|
@ -29,6 +29,7 @@ import { IGroupStore } from './stores/group-store';
|
||||
import { IPatStore } from './stores/pat-store';
|
||||
import { IPublicSignupTokenStore } from './stores/public-signup-token-store';
|
||||
import { IFavoriteFeaturesStore } from './stores/favorite-features';
|
||||
import { IFavoriteProjectsStore } from './stores/favorite-projects';
|
||||
|
||||
export interface IUnleashStores {
|
||||
accessStore: IAccessStore;
|
||||
@ -62,6 +63,7 @@ export interface IUnleashStores {
|
||||
patStore: IPatStore;
|
||||
publicSignupTokenStore: IPublicSignupTokenStore;
|
||||
favoriteFeaturesStore: IFavoriteFeaturesStore;
|
||||
favoriteProjectsStore: IFavoriteProjectsStore;
|
||||
}
|
||||
|
||||
export {
|
||||
@ -96,4 +98,5 @@ export {
|
||||
IUserSplashStore,
|
||||
IUserStore,
|
||||
IFavoriteFeaturesStore,
|
||||
IFavoriteProjectsStore,
|
||||
};
|
||||
|
14
src/lib/types/stores/favorite-projects.ts
Normal file
14
src/lib/types/stores/favorite-projects.ts
Normal 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>;
|
||||
}
|
@ -34,21 +34,37 @@ export interface IProjectEnvironmentWithChangeRequests {
|
||||
|
||||
export interface IProjectStore extends Store<IProject, string> {
|
||||
hasProject(id: string): Promise<boolean>;
|
||||
|
||||
updateHealth(healthUpdate: IProjectHealthUpdate): Promise<void>;
|
||||
|
||||
create(project: IProjectInsert): Promise<IProject>;
|
||||
|
||||
update(update: IProjectInsert): Promise<void>;
|
||||
|
||||
importProjects(
|
||||
projects: IProjectInsert[],
|
||||
environments?: IEnvironment[],
|
||||
): Promise<IProject[]>;
|
||||
|
||||
addEnvironmentToProject(id: string, environment: string): Promise<void>;
|
||||
|
||||
deleteEnvironmentForProject(id: string, environment: string): Promise<void>;
|
||||
|
||||
getEnvironmentsForProject(id: string): Promise<string[]>;
|
||||
|
||||
getMembersCountByProject(projectId: string): Promise<number>;
|
||||
|
||||
getProjectsByUser(userId: number): Promise<string[]>;
|
||||
|
||||
getMembersCount(): Promise<IProjectMembersCount[]>;
|
||||
getProjectsWithCounts(query?: IProjectQuery): Promise<IProjectWithCount[]>;
|
||||
|
||||
getProjectsWithCounts(
|
||||
query?: IProjectQuery,
|
||||
userId?: number,
|
||||
): Promise<IProjectWithCount[]>;
|
||||
|
||||
count(): Promise<number>;
|
||||
|
||||
getAll(query?: IProjectQuery): Promise<IProject[]>;
|
||||
|
||||
getProjectLinksForEnvironments(
|
||||
|
@ -51,6 +51,34 @@ const unfavoriteFeature = async (featureName: string) => {
|
||||
.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 () => {
|
||||
db = await dbInit('favorites_api_serial', getLogger);
|
||||
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 () => {
|
||||
const featureName = 'test-feature';
|
||||
await createFeature(featureName);
|
||||
|
||||
await favoriteFeature(featureName);
|
||||
await unfavoriteFeature(featureName);
|
||||
|
||||
@ -162,3 +189,38 @@ test('should be able to unfavorite feature', async () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
@ -1549,6 +1549,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"favorite": {
|
||||
"type": "boolean",
|
||||
},
|
||||
"features": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/featureSchema",
|
||||
@ -1594,6 +1597,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
"favorite": {
|
||||
"type": "boolean",
|
||||
},
|
||||
"features": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/featureSchema",
|
||||
@ -2352,6 +2358,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"description": {
|
||||
"type": "string",
|
||||
},
|
||||
"favorite": {
|
||||
"type": "boolean",
|
||||
},
|
||||
"featureCount": {
|
||||
"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": {
|
||||
"get": {
|
||||
"operationId": "getFeatures",
|
||||
|
@ -9,6 +9,7 @@ import { IUnleashStores } from '../../../lib/types';
|
||||
import { IUser } from '../../../lib/server-impl';
|
||||
import { SegmentService } from '../../../lib/services/segment-service';
|
||||
import { GroupService } from '../../../lib/services/group-service';
|
||||
import { FavoritesService } from '../../../lib/services';
|
||||
|
||||
let stores: IUnleashStores;
|
||||
let db: ITestDb;
|
||||
@ -17,6 +18,7 @@ let groupService;
|
||||
let accessService;
|
||||
let projectHealthService;
|
||||
let featureToggleService;
|
||||
let favoritesService;
|
||||
let user: IUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
@ -42,10 +44,12 @@ beforeAll(async () => {
|
||||
featureToggleService,
|
||||
groupService,
|
||||
);
|
||||
favoritesService = new FavoritesService(stores, config);
|
||||
projectHealthService = new ProjectHealthService(
|
||||
stores,
|
||||
config,
|
||||
featureToggleService,
|
||||
favoritesService,
|
||||
);
|
||||
});
|
||||
|
||||
|
35
src/test/fixtures/fake-favorite-projects-store.ts
vendored
Normal file
35
src/test/fixtures/fake-favorite-projects-store.ts
vendored
Normal 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([]);
|
||||
}
|
||||
}
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -30,6 +30,7 @@ import FakeGroupStore from './fake-group-store';
|
||||
import FakePatStore from './fake-pat-store';
|
||||
import FakePublicSignupStore from './fake-public-signup-store';
|
||||
import FakeFavoriteFeaturesStore from './fake-favorite-features-store';
|
||||
import FakeFavoriteProjectsStore from './fake-favorite-projects-store';
|
||||
|
||||
const createStores: () => IUnleashStores = () => {
|
||||
const db = {
|
||||
@ -71,6 +72,7 @@ const createStores: () => IUnleashStores = () => {
|
||||
patStore: new FakePatStore(),
|
||||
publicSignupTokenStore: new FakePublicSignupStore(),
|
||||
favoriteFeaturesStore: new FakeFavoriteFeaturesStore(),
|
||||
favoriteProjectsStore: new FakeFavoriteProjectsStore(),
|
||||
};
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user