From 897e97330a3fc5e25f700fa2aa86edb9ee5cbc21 Mon Sep 17 00:00:00 2001 From: Fredrik Strand Oseberg Date: Fri, 27 Jan 2023 13:13:41 +0100 Subject: [PATCH] Feat/project stats members (#3009) This PR adds project members to the project stats and connects the stats to the UI. --- .../Project/ProjectInfo/ProjectInfo.styles.ts | 2 +- .../project/Project/ProjectOverview.tsx | 8 ++- .../Project/ProjectStats/ProjectStats.tsx | 72 +++++++++++++++++++ .../StatusBox.tsx | 60 ++++++++++++---- .../Project/ProjectStatus/ProjectStatus.tsx | 24 ------- .../api/getters/useProject/useProject.ts | 1 + frontend/src/interfaces/project.ts | 2 +- src/lib/db/project-stats-store.ts | 55 ++++++++++++++ src/lib/db/project-store.ts | 34 +++++++++ src/lib/openapi/index.ts | 2 + .../openapi/spec/health-overview-schema.ts | 5 ++ src/lib/openapi/spec/index.ts | 1 + src/lib/openapi/spec/project-stats-schema.ts | 39 ++++++++++ src/lib/services/project-service.ts | 15 ++++ src/lib/types/model.ts | 2 + .../types/stores/project-stats-store-type.ts | 1 + src/lib/types/stores/project-store.ts | 5 ++ .../20230127111638-new-project-stats-field.js | 19 +++++ .../__snapshots__/openapi.e2e.test.ts.snap | 39 ++++++++++ .../e2e/services/project-service.e2e.test.ts | 36 ++++++++++ src/test/fixtures/fake-project-stats-store.ts | 4 ++ src/test/fixtures/fake-project-store.ts | 9 +++ 22 files changed, 393 insertions(+), 42 deletions(-) create mode 100644 frontend/src/component/project/Project/ProjectStats/ProjectStats.tsx rename frontend/src/component/project/Project/{ProjectStatus => ProjectStats}/StatusBox.tsx (52%) delete mode 100644 frontend/src/component/project/Project/ProjectStatus/ProjectStatus.tsx create mode 100644 src/lib/openapi/spec/project-stats-schema.ts create mode 100644 src/migrations/20230127111638-new-project-stats-field.js diff --git a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts index 5cb88c0783..1f326346aa 100644 --- a/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts +++ b/frontend/src/component/project/Project/ProjectInfo/ProjectInfo.styles.ts @@ -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', diff --git a/frontend/src/component/project/Project/ProjectOverview.tsx b/frontend/src/component/project/Project/ProjectOverview.tsx index d48f67f8bd..1101d4e94f 100644 --- a/frontend/src/component/project/Project/ProjectOverview.tsx +++ b/frontend/src/component/project/Project/ProjectOverview.tsx @@ -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 = () => { } + show={} /> ({ + 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 ( + + + {' '} + + + + ); +}; diff --git a/frontend/src/component/project/Project/ProjectStatus/StatusBox.tsx b/frontend/src/component/project/Project/ProjectStats/StatusBox.tsx similarity index 52% rename from frontend/src/component/project/Project/ProjectStatus/StatusBox.tsx rename to frontend/src/component/project/Project/ProjectStats/StatusBox.tsx index 3c9ce882f0..0fa11ac87d 100644 --- a/frontend/src/component/project/Project/ProjectStatus/StatusBox.tsx +++ b/frontend/src/component/project/Project/ProjectStats/StatusBox.tsx @@ -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 ( {title} {boxText} - - - {resolveIcon(change)} - - {change} - - - - this month - - + + + {resolveIcon(change)} + + {change} + {percentage ? '%' : ''} + + + + this month + + + } + elseShow={ + + + No change + + + } + /> ); diff --git a/frontend/src/component/project/Project/ProjectStatus/ProjectStatus.tsx b/frontend/src/component/project/Project/ProjectStatus/ProjectStatus.tsx deleted file mode 100644 index 71bbe27276..0000000000 --- a/frontend/src/component/project/Project/ProjectStatus/ProjectStatus.tsx +++ /dev/null @@ -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 ( - - - {' '} - - - - ); -}; diff --git a/frontend/src/hooks/api/getters/useProject/useProject.ts b/frontend/src/hooks/api/getters/useProject/useProject.ts index 3228f1156b..0a3c0292dd 100644 --- a/frontend/src/hooks/api/getters/useProject/useProject.ts +++ b/frontend/src/hooks/api/getters/useProject/useProject.ts @@ -12,6 +12,7 @@ const fallbackProject: IProject = { version: '1', description: 'Default', favorite: false, + stats: {}, }; const useProject = (id: string, options: SWRConfiguration = {}) => { diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index 17a6840dda..35219be963 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -19,7 +19,7 @@ export interface IProject { description?: string; environments: string[]; health: number; - + stats: object; favorite: boolean; features: IFeatureToggleListItem[]; } diff --git a/src/lib/db/project-stats-store.ts b/src/lib/db/project-stats-store.ts index d9a4de3e6f..ef8d3afade 100644 --- a/src/lib/db/project-stats-store.ts +++ b/src/lib/db/project-stats-store.ts @@ -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 { + 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; diff --git a/src/lib/db/project-store.ts b/src/lib/db/project-store.ts index 0663d59688..51c0372a62 100644 --- a/src/lib/db/project-store.ts +++ b/src/lib/db/project-store.ts @@ -394,6 +394,40 @@ class ProjectStore implements IProjectStore { return Number(members.count); } + async getMembersCountByProjectAfterDate( + projectId: string, + date: string, + ): Promise { + 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 { return this.db .from(TABLE) diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 3d6faa4579..e83150bd28 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -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, diff --git a/src/lib/openapi/spec/health-overview-schema.ts b/src/lib/openapi/spec/health-overview-schema.ts index eadde2b1aa..4ff0610de7 100644 --- a/src/lib/openapi/spec/health-overview-schema.ts +++ b/src/lib/openapi/spec/health-overview-schema.ts @@ -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; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 30c41fa3bd..709867176b 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -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'; diff --git a/src/lib/openapi/spec/project-stats-schema.ts b/src/lib/openapi/spec/project-stats-schema.ts new file mode 100644 index 0000000000..0d87c03ff7 --- /dev/null +++ b/src/lib/openapi/spec/project-stats-schema.ts @@ -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; diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 0c5e039d59..572f8cb999 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -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, diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 7bd91f983a..5bb0775b24 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -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 { diff --git a/src/lib/types/stores/project-stats-store-type.ts b/src/lib/types/stores/project-stats-store-type.ts index c8747134ce..56c59c21b2 100644 --- a/src/lib/types/stores/project-stats-store-type.ts +++ b/src/lib/types/stores/project-stats-store-type.ts @@ -2,4 +2,5 @@ import { IProjectStats } from 'lib/services/project-service'; export interface IProjectStatsStore { updateProjectStats(projectId: string, status: IProjectStats): Promise; + getProjectStats(projectId: string): Promise; } diff --git a/src/lib/types/stores/project-store.ts b/src/lib/types/stores/project-store.ts index 6a3de1e2c0..09f3fac258 100644 --- a/src/lib/types/stores/project-store.ts +++ b/src/lib/types/stores/project-store.ts @@ -54,6 +54,11 @@ export interface IProjectStore extends Store { getMembersCountByProject(projectId: string): Promise; + getMembersCountByProjectAfterDate( + projectId: string, + date: string, + ): Promise; + getProjectsByUser(userId: number): Promise; getMembersCount(): Promise; diff --git a/src/migrations/20230127111638-new-project-stats-field.js b/src/migrations/20230127111638-new-project-stats-field.js new file mode 100644 index 0000000000..0f8f2a570a --- /dev/null +++ b/src/migrations/20230127111638-new-project-stats-field.js @@ -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, + ); +}; diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 3d0acb9fa2..54fdf55b9f 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -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": { diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index af7970f97a..eefc86bf87 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -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); +}); diff --git a/src/test/fixtures/fake-project-stats-store.ts b/src/test/fixtures/fake-project-stats-store.ts index 38b3d319b7..e518a49eb0 100644 --- a/src/test/fixtures/fake-project-stats-store.ts +++ b/src/test/fixtures/fake-project-stats-store.ts @@ -9,4 +9,8 @@ export default class FakeProjectStatsStore implements IProjectStatsStore { ): Promise { throw new Error('not implemented'); } + + getProjectStats(projectId: string): Promise { + throw new Error('not implemented'); + } } diff --git a/src/test/fixtures/fake-project-store.ts b/src/test/fixtures/fake-project-store.ts index 856f8c4efa..b0033cf923 100644 --- a/src/test/fixtures/fake-project-store.ts +++ b/src/test/fixtures/fake-project-store.ts @@ -150,4 +150,13 @@ export default class FakeProjectStore implements IProjectStore { ): Promise { 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 { + throw new Error('Method not implemented'); + } }