mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01: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> | ||||
|                 <ConditionallyRender | ||||
|                     condition={change !== 0} | ||||
|                     show={ | ||||
|                         <StyledBoxChangeContainer> | ||||
|                             <Box sx={{ ...flexRow }}> | ||||
|                                 {resolveIcon(change)} | ||||
|                         <StyledTypographyChange color={resolveColor(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