mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-03 01:18:43 +02:00
Feat/project stats members (#3009)
This PR adds project members to the project stats and connects the stats to the UI.
This commit is contained in:
parent
b27ca26770
commit
897e97330a
@ -31,7 +31,7 @@ export const StyledProjectInfoWidgetContainer = styled('div')(({ theme }) => ({
|
||||
width: '100%',
|
||||
padding: theme.spacing(3, 2, 3, 2),
|
||||
[theme.breakpoints.down('md')]: {
|
||||
margin: theme.spacing(0, 0.5),
|
||||
margin: theme.spacing(0, 1),
|
||||
...flexRow,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
|
@ -10,7 +10,7 @@ import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { useLastViewedProject } from '../../../hooks/useLastViewedProject';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { ProjectStatus } from './ProjectStatus/ProjectStatus';
|
||||
import { ProjectStats } from './ProjectStats/ProjectStats';
|
||||
|
||||
const refreshInterval = 15 * 1000;
|
||||
|
||||
@ -36,7 +36,9 @@ const StyledContentContainer = styled(Box)(() => ({
|
||||
const ProjectOverview = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const projectName = useProjectNameOrId(projectId);
|
||||
const { project, loading } = useProject(projectId, { refreshInterval });
|
||||
const { project, loading } = useProject(projectId, {
|
||||
refreshInterval,
|
||||
});
|
||||
const { members, features, health, description, environments } = project;
|
||||
usePageTitle(`Project overview – ${projectName}`);
|
||||
const { setLastViewed } = useLastViewedProject();
|
||||
@ -58,7 +60,7 @@ const ProjectOverview = () => {
|
||||
<StyledContentContainer>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(uiConfig?.flags.newProjectOverview)}
|
||||
show={<ProjectStatus />}
|
||||
show={<ProjectStats stats={project.stats} />}
|
||||
/>
|
||||
<StyledProjectToggles>
|
||||
<ProjectFeatureToggles
|
||||
|
@ -0,0 +1,72 @@
|
||||
import { Box, styled } from '@mui/material';
|
||||
import { StatusBox } from './StatusBox';
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(0, 0, 2, 2),
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}));
|
||||
|
||||
interface IProjectStatsProps {
|
||||
stats: any; // awaiting type generation
|
||||
}
|
||||
|
||||
export const ProjectStats = ({ stats }: IProjectStatsProps) => {
|
||||
const {
|
||||
avgTimeToProdCurrentWindow,
|
||||
avgTimeToProdPastWindow,
|
||||
projectActivityCurrentWindow,
|
||||
projectActivityPastWindow,
|
||||
createdCurrentWindow,
|
||||
createdPastWindow,
|
||||
archivedCurrentWindow,
|
||||
archivedPastWindow,
|
||||
} = stats;
|
||||
|
||||
const calculatePercentage = (partial: number, total: number) => {
|
||||
const percentage = (partial * 100) / total;
|
||||
|
||||
if (Number.isInteger(percentage)) {
|
||||
return percentage;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledBox>
|
||||
<StatusBox
|
||||
title="Total changes"
|
||||
boxText={projectActivityCurrentWindow}
|
||||
change={
|
||||
projectActivityCurrentWindow - projectActivityPastWindow
|
||||
}
|
||||
/>
|
||||
<StatusBox
|
||||
title="Avg. time to production"
|
||||
boxText={`${avgTimeToProdCurrentWindow} days`}
|
||||
change={calculatePercentage(
|
||||
avgTimeToProdCurrentWindow,
|
||||
avgTimeToProdPastWindow
|
||||
)}
|
||||
percentage
|
||||
/>{' '}
|
||||
<StatusBox
|
||||
title="Features created"
|
||||
boxText={createdCurrentWindow}
|
||||
change={createdCurrentWindow - createdPastWindow}
|
||||
/>
|
||||
<StatusBox
|
||||
title="Features archived"
|
||||
boxText={archivedCurrentWindow}
|
||||
change={archivedCurrentWindow - archivedPastWindow}
|
||||
/>
|
||||
</StyledBox>
|
||||
);
|
||||
};
|
@ -1,15 +1,28 @@
|
||||
import { ArrowOutward, SouthEast } from '@mui/icons-material';
|
||||
import { Box, Typography, styled } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { flexRow } from 'themes/themeStyles';
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(4, 2),
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
minWidth: '240px',
|
||||
minWidth: '24%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
borderRadius: `${theme.shape.borderRadiusLarge}px`,
|
||||
[theme.breakpoints.down('lg')]: {
|
||||
minWidth: '49%',
|
||||
padding: theme.spacing(2),
|
||||
':nth-child(n+3)': {
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
},
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
':nth-child(n+2)': {
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledTypographyHeader = styled(Typography)(({ theme }) => ({
|
||||
@ -42,6 +55,7 @@ interface IStatusBoxProps {
|
||||
title: string;
|
||||
boxText: string;
|
||||
change: number;
|
||||
percentage?: boolean;
|
||||
}
|
||||
|
||||
const resolveIcon = (change: number) => {
|
||||
@ -62,23 +76,43 @@ const resolveColor = (change: number) => {
|
||||
return 'error.main';
|
||||
};
|
||||
|
||||
export const StatusBox = ({ title, boxText, change }: IStatusBoxProps) => {
|
||||
export const StatusBox = ({
|
||||
title,
|
||||
boxText,
|
||||
change,
|
||||
percentage,
|
||||
}: IStatusBoxProps) => {
|
||||
return (
|
||||
<StyledBox>
|
||||
<StyledTypographyHeader>{title}</StyledTypographyHeader>
|
||||
<Box sx={{ ...flexRow }}>
|
||||
<StyledTypographyCount>{boxText}</StyledTypographyCount>
|
||||
<StyledBoxChangeContainer>
|
||||
<Box sx={{ ...flexRow }}>
|
||||
{resolveIcon(change)}
|
||||
<StyledTypographyChange color={resolveColor(change)}>
|
||||
{change}
|
||||
</StyledTypographyChange>
|
||||
</Box>
|
||||
<StyledTypographySubtext>
|
||||
this month
|
||||
</StyledTypographySubtext>
|
||||
</StyledBoxChangeContainer>
|
||||
<ConditionallyRender
|
||||
condition={change !== 0}
|
||||
show={
|
||||
<StyledBoxChangeContainer>
|
||||
<Box sx={{ ...flexRow }}>
|
||||
{resolveIcon(change)}
|
||||
<StyledTypographyChange
|
||||
color={resolveColor(change)}
|
||||
>
|
||||
{change}
|
||||
{percentage ? '%' : ''}
|
||||
</StyledTypographyChange>
|
||||
</Box>
|
||||
<StyledTypographySubtext>
|
||||
this month
|
||||
</StyledTypographySubtext>
|
||||
</StyledBoxChangeContainer>
|
||||
}
|
||||
elseShow={
|
||||
<StyledBoxChangeContainer>
|
||||
<StyledTypographySubtext>
|
||||
No change
|
||||
</StyledTypographySubtext>
|
||||
</StyledBoxChangeContainer>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</StyledBox>
|
||||
);
|
@ -1,24 +0,0 @@
|
||||
import { Box, styled } from '@mui/material';
|
||||
import { StatusBox } from './StatusBox';
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(0, 0, 2, 2),
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
}));
|
||||
|
||||
export const ProjectStatus = () => {
|
||||
return (
|
||||
<StyledBox>
|
||||
<StatusBox title="Total changes" boxText={'86'} change={-24} />
|
||||
<StatusBox
|
||||
title="Total changes"
|
||||
boxText={'6 days'}
|
||||
change={-12}
|
||||
/>{' '}
|
||||
<StatusBox title="Total changes" boxText={'86'} change={-24} />
|
||||
<StatusBox title="Total changes" boxText={'86'} change={-24} />
|
||||
</StyledBox>
|
||||
);
|
||||
};
|
@ -12,6 +12,7 @@ const fallbackProject: IProject = {
|
||||
version: '1',
|
||||
description: 'Default',
|
||||
favorite: false,
|
||||
stats: {},
|
||||
};
|
||||
|
||||
const useProject = (id: string, options: SWRConfiguration = {}) => {
|
||||
|
@ -19,7 +19,7 @@ export interface IProject {
|
||||
description?: string;
|
||||
environments: string[];
|
||||
health: number;
|
||||
|
||||
stats: object;
|
||||
favorite: boolean;
|
||||
features: IFeatureToggleListItem[];
|
||||
}
|
||||
|
@ -9,6 +9,31 @@ import { IProjectStatsStore } from 'lib/types/stores/project-stats-store-type';
|
||||
|
||||
const TABLE = 'project_stats';
|
||||
|
||||
const PROJECT_STATS_COLUMNS = [
|
||||
'avg_time_to_prod_current_window',
|
||||
'avg_time_to_prod_past_window',
|
||||
'project',
|
||||
'features_created_current_window',
|
||||
'features_created_past_window',
|
||||
'features_archived_current_window',
|
||||
'features_archived_past_window',
|
||||
'project_changes_current_window',
|
||||
'project_changes_past_window',
|
||||
'project_members_added_current_window',
|
||||
];
|
||||
|
||||
interface IProjectStatsRow {
|
||||
avg_time_to_prod_current_window: number;
|
||||
avg_time_to_prod_past_window: number;
|
||||
features_created_current_window: number;
|
||||
features_created_past_window: number;
|
||||
features_archived_current_window: number;
|
||||
features_archived_past_window: number;
|
||||
project_changes_current_window: number;
|
||||
project_changes_past_window: number;
|
||||
project_members_added_current_window: number;
|
||||
}
|
||||
|
||||
class ProjectStatsStore implements IProjectStatsStore {
|
||||
private db: Knex;
|
||||
|
||||
@ -43,10 +68,40 @@ class ProjectStatsStore implements IProjectStatsStore {
|
||||
project_changes_current_window:
|
||||
status.projectActivityCurrentWindow,
|
||||
project_changes_past_window: status.projectActivityPastWindow,
|
||||
project_members_added_current_window:
|
||||
status.projectMembersAddedCurrentWindow,
|
||||
})
|
||||
.onConflict('project')
|
||||
.merge();
|
||||
}
|
||||
|
||||
async getProjectStats(projectId: string): Promise<IProjectStats> {
|
||||
const row = await this.db(TABLE)
|
||||
.select(PROJECT_STATS_COLUMNS)
|
||||
.where({ project: projectId })
|
||||
.first();
|
||||
|
||||
return this.mapRow(row);
|
||||
}
|
||||
|
||||
mapRow(row: IProjectStatsRow): IProjectStats | undefined {
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
avgTimeToProdCurrentWindow: row.avg_time_to_prod_current_window,
|
||||
avgTimeToProdPastWindow: row.avg_time_to_prod_past_window,
|
||||
createdCurrentWindow: row.features_created_current_window,
|
||||
createdPastWindow: row.features_created_past_window,
|
||||
archivedCurrentWindow: row.features_archived_current_window,
|
||||
archivedPastWindow: row.features_archived_past_window,
|
||||
projectActivityCurrentWindow: row.project_changes_current_window,
|
||||
projectActivityPastWindow: row.project_changes_past_window,
|
||||
projectMembersAddedCurrentWindow:
|
||||
row.project_members_added_current_window,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ProjectStatsStore;
|
||||
|
@ -394,6 +394,40 @@ class ProjectStore implements IProjectStore {
|
||||
return Number(members.count);
|
||||
}
|
||||
|
||||
async getMembersCountByProjectAfterDate(
|
||||
projectId: string,
|
||||
date: string,
|
||||
): Promise<number> {
|
||||
const members = await this.db
|
||||
.from((db) => {
|
||||
db.select('user_id')
|
||||
.from('role_user')
|
||||
.leftJoin('roles', 'role_user.role_id', 'roles.id')
|
||||
.where((builder) =>
|
||||
builder
|
||||
.where('project', projectId)
|
||||
.whereNot('type', 'root')
|
||||
.andWhere('role_user.created_at', '>=', date),
|
||||
)
|
||||
.union((queryBuilder) => {
|
||||
queryBuilder
|
||||
.select('user_id')
|
||||
.from('group_role')
|
||||
.leftJoin(
|
||||
'group_user',
|
||||
'group_user.group_id',
|
||||
'group_role.group_id',
|
||||
)
|
||||
.where('project', projectId)
|
||||
.andWhere('group_role.created_at', '>=', date);
|
||||
})
|
||||
.as('query');
|
||||
})
|
||||
.count()
|
||||
.first();
|
||||
return Number(members.count);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return this.db
|
||||
.from(TABLE)
|
||||
|
@ -84,6 +84,7 @@ import {
|
||||
proxyFeaturesSchema,
|
||||
proxyMetricsSchema,
|
||||
publicSignupTokenCreateSchema,
|
||||
projectStatsSchema,
|
||||
publicSignupTokenSchema,
|
||||
publicSignupTokensSchema,
|
||||
publicSignupTokenUpdateSchema,
|
||||
@ -225,6 +226,7 @@ export const schemas = {
|
||||
publicSignupTokensSchema,
|
||||
publicSignupTokenUpdateSchema,
|
||||
pushVariantsSchema,
|
||||
projectStatsSchema,
|
||||
resetPasswordSchema,
|
||||
requestsPerSecondSchema,
|
||||
requestsPerSecondSegmentedSchema,
|
||||
|
@ -7,6 +7,7 @@ import { featureSchema } from './feature-schema';
|
||||
import { constraintSchema } from './constraint-schema';
|
||||
import { environmentSchema } from './environment-schema';
|
||||
import { featureEnvironmentSchema } from './feature-environment-schema';
|
||||
import { projectStatsSchema } from './project-stats-schema';
|
||||
|
||||
export const healthOverviewSchema = {
|
||||
$id: '#/components/schemas/healthOverviewSchema',
|
||||
@ -14,6 +15,9 @@ export const healthOverviewSchema = {
|
||||
additionalProperties: false,
|
||||
required: ['version', 'name'],
|
||||
properties: {
|
||||
stats: {
|
||||
$ref: '#/components/schemas/projectStatsSchema',
|
||||
},
|
||||
version: {
|
||||
type: 'number',
|
||||
},
|
||||
@ -60,6 +64,7 @@ export const healthOverviewSchema = {
|
||||
parametersSchema,
|
||||
featureStrategySchema,
|
||||
variantSchema,
|
||||
projectStatsSchema,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@ -125,3 +125,4 @@ export * from './requests-per-second-segmented-schema';
|
||||
export * from './export-result-schema';
|
||||
export * from './export-query-schema';
|
||||
export * from './push-variants-schema';
|
||||
export * from './project-stats-schema';
|
||||
|
39
src/lib/openapi/spec/project-stats-schema.ts
Normal file
39
src/lib/openapi/spec/project-stats-schema.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { FromSchema } from 'json-schema-to-ts';
|
||||
|
||||
export const projectStatsSchema = {
|
||||
$id: '#/components/schemas/projectStatsSchema',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
avgTimeToProdCurrentWindow: {
|
||||
type: 'number',
|
||||
},
|
||||
avgTimeToProdPastWindow: {
|
||||
type: 'number',
|
||||
},
|
||||
createdCurrentWindow: {
|
||||
type: 'number',
|
||||
},
|
||||
createdPastWindow: {
|
||||
type: 'number',
|
||||
},
|
||||
archivedCurrentWindow: {
|
||||
type: 'number',
|
||||
},
|
||||
archivedPastWindow: {
|
||||
type: 'number',
|
||||
},
|
||||
projectActivityCurrentWindow: {
|
||||
type: 'number',
|
||||
},
|
||||
projectActivityPastWindow: {
|
||||
type: 'number',
|
||||
},
|
||||
projectMembersAddedCurrentWindow: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
components: {},
|
||||
} as const;
|
||||
|
||||
export type ProjectStatsSchema = FromSchema<typeof projectStatsSchema>;
|
@ -71,6 +71,7 @@ export interface IProjectStats {
|
||||
archivedPastWindow: Count;
|
||||
projectActivityCurrentWindow: Count;
|
||||
projectActivityPastWindow: Count;
|
||||
projectMembersAddedCurrentWindow: Count;
|
||||
}
|
||||
|
||||
interface ICalculateStatus {
|
||||
@ -757,6 +758,12 @@ export default class ProjectService {
|
||||
eventsPastWindow,
|
||||
);
|
||||
|
||||
const projectMembersAddedCurrentWindow =
|
||||
await this.store.getMembersCountByProjectAfterDate(
|
||||
projectId,
|
||||
dateMinusThirtyDays,
|
||||
);
|
||||
|
||||
return {
|
||||
projectId,
|
||||
updates: {
|
||||
@ -771,6 +778,8 @@ export default class ProjectService {
|
||||
projectActivityCurrentWindow:
|
||||
projectActivityCurrentWindow.length,
|
||||
projectActivityPastWindow: projectActivityPastWindow.length,
|
||||
projectMembersAddedCurrentWindow:
|
||||
projectMembersAddedCurrentWindow,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -795,7 +804,13 @@ export default class ProjectService {
|
||||
project: projectId,
|
||||
userId,
|
||||
});
|
||||
|
||||
const projectStats = await this.projectStatsStore.getProjectStats(
|
||||
projectId,
|
||||
);
|
||||
|
||||
return {
|
||||
stats: projectStats || {},
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
health: project.health,
|
||||
|
@ -3,6 +3,7 @@ import { LogProvider } from '../logger';
|
||||
import { IRole } from './stores/access-store';
|
||||
import { IUser } from './user';
|
||||
import { ALL_OPERATORS } from '../util/constants';
|
||||
import { IProjectStats } from 'lib/services/project-service';
|
||||
|
||||
export type Operator = typeof ALL_OPERATORS[number];
|
||||
|
||||
@ -182,6 +183,7 @@ export interface IProjectOverview {
|
||||
health: number;
|
||||
favorite?: boolean;
|
||||
updatedAt?: Date;
|
||||
stats: IProjectStats | {};
|
||||
}
|
||||
|
||||
export interface IProjectHealthReport extends IProjectOverview {
|
||||
|
@ -2,4 +2,5 @@ import { IProjectStats } from 'lib/services/project-service';
|
||||
|
||||
export interface IProjectStatsStore {
|
||||
updateProjectStats(projectId: string, status: IProjectStats): Promise<void>;
|
||||
getProjectStats(projectId: string): Promise<IProjectStats>;
|
||||
}
|
||||
|
@ -54,6 +54,11 @@ export interface IProjectStore extends Store<IProject, string> {
|
||||
|
||||
getMembersCountByProject(projectId: string): Promise<number>;
|
||||
|
||||
getMembersCountByProjectAfterDate(
|
||||
projectId: string,
|
||||
date: string,
|
||||
): Promise<number>;
|
||||
|
||||
getProjectsByUser(userId: number): Promise<string[]>;
|
||||
|
||||
getMembersCount(): Promise<IProjectMembersCount[]>;
|
||||
|
19
src/migrations/20230127111638-new-project-stats-field.js
Normal file
19
src/migrations/20230127111638-new-project-stats-field.js
Normal file
@ -0,0 +1,19 @@
|
||||
exports.up = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
ALTER table project_stats
|
||||
ADD COLUMN IF NOT EXISTS project_members_added_current_window INTEGER DEFAULT 0
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = function (db, cb) {
|
||||
db.runSql(
|
||||
`
|
||||
ALTER table project_stats
|
||||
DROP COLUMN project_members_added_current_window
|
||||
`,
|
||||
cb,
|
||||
);
|
||||
};
|
@ -1627,6 +1627,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"name": {
|
||||
"type": "string",
|
||||
},
|
||||
"stats": {
|
||||
"$ref": "#/components/schemas/projectStatsSchema",
|
||||
},
|
||||
"updatedAt": {
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
@ -1681,6 +1684,9 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
"staleCount": {
|
||||
"type": "number",
|
||||
},
|
||||
"stats": {
|
||||
"$ref": "#/components/schemas/projectStatsSchema",
|
||||
},
|
||||
"updatedAt": {
|
||||
"format": "date-time",
|
||||
"nullable": true,
|
||||
@ -2460,6 +2466,39 @@ exports[`should serve the OpenAPI spec 1`] = `
|
||||
],
|
||||
"type": "object",
|
||||
},
|
||||
"projectStatsSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"archivedCurrentWindow": {
|
||||
"type": "number",
|
||||
},
|
||||
"archivedPastWindow": {
|
||||
"type": "number",
|
||||
},
|
||||
"avgTimeToProdCurrentWindow": {
|
||||
"type": "number",
|
||||
},
|
||||
"avgTimeToProdPastWindow": {
|
||||
"type": "number",
|
||||
},
|
||||
"createdCurrentWindow": {
|
||||
"type": "number",
|
||||
},
|
||||
"createdPastWindow": {
|
||||
"type": "number",
|
||||
},
|
||||
"projectActivityCurrentWindow": {
|
||||
"type": "number",
|
||||
},
|
||||
"projectActivityPastWindow": {
|
||||
"type": "number",
|
||||
},
|
||||
"projectMembersAddedCurrentWindow": {
|
||||
"type": "number",
|
||||
},
|
||||
},
|
||||
"type": "object",
|
||||
},
|
||||
"projectsSchema": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
|
@ -1212,3 +1212,39 @@ test('should get correct amount of features archived in current and past window'
|
||||
expect(result.updates.archivedCurrentWindow).toBe(2);
|
||||
expect(result.updates.archivedPastWindow).toBe(2);
|
||||
});
|
||||
|
||||
test('should get correct amount of project members for current and past window', async () => {
|
||||
const project = {
|
||||
id: 'features-members',
|
||||
name: 'features-members',
|
||||
};
|
||||
|
||||
await projectService.createProject(project, user.id);
|
||||
|
||||
const users = [
|
||||
{ name: 'memberOne', email: 'memberOne@getunleash.io' },
|
||||
{ name: 'memberTwo', email: 'memberTwo@getunleash.io' },
|
||||
{ name: 'memberThree', email: 'memberThree@getunleash.io' },
|
||||
{ name: 'memberFour', email: 'memberFour@getunleash.io' },
|
||||
{ name: 'memberFive', email: 'memberFive@getunleash.io' },
|
||||
];
|
||||
|
||||
const createdUsers = await Promise.all(
|
||||
users.map((userObj) => stores.userStore.insert(userObj)),
|
||||
);
|
||||
const memberRole = await stores.roleStore.getRoleByName(RoleName.MEMBER);
|
||||
|
||||
await Promise.all(
|
||||
createdUsers.map((createdUser) =>
|
||||
projectService.addUser(
|
||||
project.id,
|
||||
memberRole.id,
|
||||
createdUser.id,
|
||||
'test',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await projectService.getStatusUpdates(project.id);
|
||||
expect(result.updates.projectMembersAddedCurrentWindow).toBe(5);
|
||||
});
|
||||
|
@ -9,4 +9,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore {
|
||||
): Promise<void> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
|
||||
getProjectStats(projectId: string): Promise<IProjectStats> {
|
||||
throw new Error('not implemented');
|
||||
}
|
||||
}
|
||||
|
9
src/test/fixtures/fake-project-store.ts
vendored
9
src/test/fixtures/fake-project-store.ts
vendored
@ -150,4 +150,13 @@ export default class FakeProjectStore implements IProjectStore {
|
||||
): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
getMembersCountByProjectAfterDate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
projectId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
date: string,
|
||||
): Promise<number> {
|
||||
throw new Error('Method not implemented');
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user