Skip to content

Commit c291252

Browse files
ahmedxgoudakasya
andauthored
Implement the detailed metrics dashboard page (#1748)
* Add more fields to the metrics node * Initial page setup * Update page * Update backend * Update backend * Update frontend * Add backend tests and last pull request days * Add tooltip * Add the option to display the valid colors if it is preffered that the actual is bigger than the requirement * Improve styling * Update frontend tests mocking * Add test mocking * Implement charts * Add badges * Update tests * Add error message * Update tests * Apply sonar suggestion * Update tests * Update tests * Update badges styling --------- Co-authored-by: Kate Golovanova <kate@kgthreads.com>
1 parent 8b61020 commit c291252

21 files changed

+586
-41
lines changed

backend/apps/owasp/graphql/nodes/project.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,25 @@ class ProjectNode(GenericEntityNode):
3737
"""Project node."""
3838

3939
@strawberry.field
40-
def health_metrics(self, limit: int = 30) -> list[ProjectHealthMetricsNode]:
40+
def health_metrics_list(self, limit: int = 30) -> list[ProjectHealthMetricsNode]:
4141
"""Resolve project health metrics."""
4242
return ProjectHealthMetrics.objects.filter(
4343
project=self,
4444
).order_by(
4545
"nest_created_at",
4646
)[:limit]
4747

48+
@strawberry.field
49+
def health_metrics_latest(self) -> ProjectHealthMetricsNode | None:
50+
"""Resolve latest project health metrics."""
51+
return (
52+
ProjectHealthMetrics.get_latest_health_metrics()
53+
.filter(
54+
project=self,
55+
)
56+
.first()
57+
)
58+
4859
@strawberry.field
4960
def issues_count(self) -> int:
5061
"""Resolve issues count."""

backend/apps/owasp/graphql/nodes/project_health_metrics.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
"recent_releases_count",
2222
"score",
2323
"stars_count",
24+
"total_issues_count",
25+
"total_releases_count",
2426
"unanswered_issues_count",
2527
"unassigned_issues_count",
2628
],
@@ -35,6 +37,11 @@ def age_days(self) -> int:
3537
"""Resolve project age in days."""
3638
return self.age_days
3739

40+
@strawberry.field
41+
def age_days_requirement(self) -> int:
42+
"""Resolve project age requirement in days."""
43+
return self.age_days_requirement
44+
3845
@strawberry.field
3946
def created_at(self) -> datetime:
4047
"""Resolve metrics creation date."""
@@ -55,6 +62,11 @@ def last_pull_request_days(self) -> int:
5562
"""Resolve last pull request age in days."""
5663
return self.last_pull_request_days
5764

65+
@strawberry.field
66+
def last_pull_request_days_requirement(self) -> int:
67+
"""Resolve last pull request age requirement in days."""
68+
return self.last_pull_request_days_requirement
69+
5870
@strawberry.field
5971
def last_release_days(self) -> int:
6072
"""Resolve last release age in days."""
@@ -74,3 +86,8 @@ def project_name(self) -> str:
7486
def owasp_page_last_update_days(self) -> int:
7587
"""Resolve OWASP page last update age in days."""
7688
return self.owasp_page_last_update_days
89+
90+
@strawberry.field
91+
def owasp_page_last_update_days_requirement(self) -> int:
92+
"""Resolve OWASP page last update age requirement in days."""
93+
return self.owasp_page_last_update_days_requirement

backend/apps/owasp/models/project_health_metrics.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ def age_days(self) -> int:
8787
"""Calculate project age in days."""
8888
return (timezone.now() - self.created_at).days if self.created_at else 0
8989

90+
@property
91+
def age_days_requirement(self) -> int:
92+
"""Get the age requirement for the project."""
93+
return self.project_requirements.age_days
94+
9095
@property
9196
def last_commit_days(self) -> int:
9297
"""Calculate days since last commit."""
@@ -106,6 +111,11 @@ def last_pull_request_days(self) -> int:
106111
else 0
107112
)
108113

114+
@property
115+
def last_pull_request_days_requirement(self) -> int:
116+
"""Get the last pull request requirement for the project."""
117+
return self.project_requirements.last_pull_request_days
118+
109119
@property
110120
def last_release_days(self) -> int:
111121
"""Calculate days since last release."""
@@ -125,6 +135,11 @@ def owasp_page_last_update_days(self) -> int:
125135
else 0
126136
)
127137

138+
@property
139+
def owasp_page_last_update_days_requirement(self) -> int:
140+
"""Get the OWASP page last update requirement for the project."""
141+
return self.project_requirements.owasp_page_last_update_days
142+
128143
@property
129144
def project_requirements(self) -> ProjectHealthRequirements:
130145
"""Get the project health requirements for the project's level."""

backend/tests/apps/owasp/graphql/nodes/project_health_metrics_test.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ def test_meta_configuration(self):
3434
"recent_releases_count",
3535
"score",
3636
"stars_count",
37+
"total_issues_count",
38+
"total_releases_count",
3739
"unanswered_issues_count",
3840
"unassigned_issues_count",
3941
}
@@ -67,8 +69,10 @@ def _get_field_by_name(self, name):
6769
("open_pull_requests_count", int),
6870
("owasp_page_last_update_days", int),
6971
("project_name", str),
70-
("stars_count", int),
7172
("recent_releases_count", int),
73+
("stars_count", int),
74+
("total_issues_count", int),
75+
("total_releases_count", int),
7276
("unanswered_issues_count", int),
7377
("unassigned_issues_count", int),
7478
],

backend/tests/apps/owasp/graphql/nodes/project_test.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,13 @@ def _get_field_by_name(self, name):
4444
(f for f in ProjectNode.__strawberry_definition__.fields if f.name == name), None
4545
)
4646

47-
def test_resolve_health_metrics(self):
48-
field = self._get_field_by_name("health_metrics")
47+
def test_resolve_health_metrics_latest(self):
48+
field = self._get_field_by_name("health_metrics_latest")
49+
assert field is not None
50+
assert field.type.of_type is ProjectHealthMetricsNode
51+
52+
def test_resolve_health_metrics_list(self):
53+
field = self._get_field_by_name("health_metrics_list")
4954
assert field is not None
5055
assert field.type.of_type is ProjectHealthMetricsNode
5156

backend/tests/apps/owasp/graphql/queries/project_health_metrics_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def test_resolve_project_health_metrics(self):
8484
is_funding_requirements_compliant=True,
8585
is_leader_requirements_compliant=True,
8686
recent_releases_count=3,
87+
total_issues_count=15,
88+
total_releases_count=5,
8789
)
8890
]
8991
query = ProjectHealthMetricsQuery(project_health_metrics=metrics)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { expect, test } from '@playwright/test'
2+
import { mockProjectsDashboardMetricsDetailsData } from '@unit/data/mockProjectsDashboardMetricsDetailsData'
3+
4+
test.describe('Project Health Metrics Details Page', () => {
5+
test.beforeEach(async ({ page }) => {
6+
await page.route('**/graphql/', async (route) => {
7+
await route.fulfill({
8+
status: 200,
9+
json: { data: mockProjectsDashboardMetricsDetailsData },
10+
})
11+
})
12+
await page.context().addCookies([
13+
{
14+
name: 'csrftoken',
15+
value: 'abc123',
16+
domain: 'localhost',
17+
path: '/',
18+
},
19+
])
20+
await page.goto('/projects/dashboard/metrics/test-project')
21+
})
22+
23+
test('renders project health metrics details', async ({ page }) => {
24+
const metricsLatest = mockProjectsDashboardMetricsDetailsData.project.healthMetricsLatest
25+
const headers = [
26+
'Days Metrics',
27+
'Issues',
28+
'Stars',
29+
'Forks',
30+
'Contributors',
31+
'Releases',
32+
'Open Pull Requests',
33+
'Health',
34+
'Score',
35+
]
36+
await expect(page.getByText(metricsLatest.projectName)).toBeVisible()
37+
await expect(page.getByText(metricsLatest.score.toString())).toBeVisible()
38+
for (const header of headers) {
39+
await expect(page.getByText(header, { exact: true })).toBeVisible()
40+
}
41+
})
42+
})

frontend/__tests__/unit/data/mockProjectDetailsData.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ export const mockProjectDetailsData = {
22
project: {
33
contributorsCount: 1200,
44
forksCount: 10,
5-
healthMetrics: [
5+
healthMetricsList: [
66
{
77
openIssuesCount: 5,
88
unassignedIssuesCount: 2,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export const mockProjectsDashboardMetricsDetailsData = {
2+
project: {
3+
healthMetricsLatest: {
4+
ageDays: 200,
5+
ageDaysRequirement: 100,
6+
isFundingRequirementsCompliant: true,
7+
isLeaderRequirementsCompliant: false,
8+
lastCommitDays: 22,
9+
lastCommitDaysRequirement: 30,
10+
lastPullRequestDays: 55,
11+
lastPullRequestDaysRequirement: 56,
12+
lastReleaseDays: 57,
13+
lastReleaseDaysRequirement: 58,
14+
owaspPageLastUpdateDays: 52,
15+
owaspPageLastUpdateDaysRequirement: 53,
16+
projectName: 'Test',
17+
score: 41,
18+
},
19+
healthMetricsList: [
20+
{
21+
contributorsCount: 50,
22+
forksCount: 60,
23+
openIssuesCount: 59,
24+
openPullRequestsCount: 51,
25+
recentReleasesCount: 75,
26+
starsCount: 77,
27+
totalIssuesCount: 78,
28+
totalReleasesCount: 79,
29+
unassignedIssuesCount: 85,
30+
unansweredIssuesCount: 86,
31+
},
32+
],
33+
},
34+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useQuery } from '@apollo/client'
2+
import { render, screen, waitFor } from '@testing-library/react'
3+
import { mockProjectsDashboardMetricsDetailsData } from '@unit/data/mockProjectsDashboardMetricsDetailsData'
4+
import ProjectHealthMetricsDetails from 'app/projects/dashboard/metrics/[projectKey]/page'
5+
6+
jest.mock('react-apexcharts', () => {
7+
return {
8+
__esModule: true,
9+
default: () => {
10+
return <div data-testid="mock-apexcharts">Mock ApexChart</div>
11+
},
12+
}
13+
})
14+
jest.mock('@apollo/client', () => ({
15+
...jest.requireActual('@apollo/client'),
16+
useQuery: jest.fn(),
17+
}))
18+
jest.mock('next/navigation', () => ({
19+
useParams: jest.fn(() => ({
20+
projectKey: 'test-project',
21+
})),
22+
}))
23+
24+
jest.mock('@fortawesome/react-fontawesome', () => ({
25+
FontAwesomeIcon: () => <span data-testid="mock-icon"></span>,
26+
}))
27+
28+
const mockError = {
29+
error: new Error('GraphQL error'),
30+
}
31+
32+
describe('ProjectHealthMetricsDetails', () => {
33+
beforeEach(() => {
34+
;(useQuery as jest.Mock).mockReturnValue({
35+
data: mockProjectsDashboardMetricsDetailsData,
36+
loading: false,
37+
error: null,
38+
})
39+
})
40+
41+
afterEach(() => {
42+
jest.clearAllMocks()
43+
})
44+
45+
test('renders loading state', async () => {
46+
;(useQuery as jest.Mock).mockReturnValue({
47+
data: null,
48+
loading: true,
49+
error: null,
50+
})
51+
render(<ProjectHealthMetricsDetails />)
52+
const loadingSpinner = screen.getAllByAltText('Loading indicator')
53+
await waitFor(() => {
54+
expect(loadingSpinner.length).toBeGreaterThan(0)
55+
})
56+
})
57+
58+
test('renders error state', async () => {
59+
;(useQuery as jest.Mock).mockReturnValue({
60+
data: null,
61+
loading: false,
62+
error: mockError,
63+
})
64+
render(<ProjectHealthMetricsDetails />)
65+
const errorMessage = screen.getByText('No metrics data available for this project.')
66+
await waitFor(() => {
67+
expect(errorMessage).toBeInTheDocument()
68+
})
69+
})
70+
71+
test('renders project health metrics details', async () => {
72+
const headers = [
73+
'Days Metrics',
74+
'Issues',
75+
'Stars',
76+
'Forks',
77+
'Contributors',
78+
'Releases',
79+
'Open Pull Requests',
80+
'Health',
81+
'Score',
82+
]
83+
const metrics = mockProjectsDashboardMetricsDetailsData.project.healthMetricsLatest
84+
render(<ProjectHealthMetricsDetails />)
85+
await waitFor(() => {
86+
headers.forEach((header) => {
87+
expect(screen.getByText(header)).toBeInTheDocument()
88+
})
89+
expect(screen.getByText(metrics.projectName)).toBeInTheDocument()
90+
expect(screen.getByText(metrics.score.toString())).toBeInTheDocument()
91+
})
92+
})
93+
})

0 commit comments

Comments
 (0)