diff --git a/.github/workflows/check-pr-issue.yaml b/.github/workflows/check-pr-issue.yaml index df1e06240d..9498ac7e6c 100644 --- a/.github/workflows/check-pr-issue.yaml +++ b/.github/workflows/check-pr-issue.yaml @@ -16,9 +16,9 @@ jobs: - name: Check PR linked issue and assignee uses: arkid15r/check-pr-issue-action@a3635191c798f111aae577759b579dc37bb13e02 with: - close_pr_on_failure: 'false' + close_pr_on_failure: 'true' github_token: ${{ secrets.GITHUB_TOKEN }} - no_assignee_message: 'Test: The linked issue must be assigned to the PR author.' - no_issue_message: 'Test: This PR must be linked to an issue.' + no_assignee_message: 'The linked issue must be assigned to the PR author.' + no_issue_message: 'The PR must be linked to an issue assigned to the PR author.' require_assignee: 'true' skip_users: 'arkid15r,kasya' diff --git a/backend/apps/owasp/api/internal/ordering/project_health_metrics.py b/backend/apps/owasp/api/internal/ordering/project_health_metrics.py index 7728c61645..f37c876624 100644 --- a/backend/apps/owasp/api/internal/ordering/project_health_metrics.py +++ b/backend/apps/owasp/api/internal/ordering/project_health_metrics.py @@ -11,8 +11,12 @@ class ProjectHealthMetricsOrder: """Ordering for Project Health Metrics.""" score: strawberry.auto + stars_count: strawberry.auto + forks_count: strawberry.auto + contributors_count: strawberry.auto + created_at: strawberry.auto - # We need to order by another field in case of equal scores + # We need to order by another field in case of equal values # to ensure unique metrics in pagination. # The ORM returns random ordered query set if no order is specified. # We don't do ordering in the model since we order already in the query. diff --git a/backend/poetry.lock b/backend/poetry.lock index 2be96c04e9..d5c702a192 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -3358,14 +3358,14 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, - {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, + {file = "python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61"}, + {file = "python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6"}, ] [package.extras] diff --git a/backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py b/backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py index 784e3ca5cd..6e1f3620c6 100644 --- a/backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py +++ b/backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py @@ -15,11 +15,29 @@ def test_order_fields(self): order_fields = { field.name for field in ProjectHealthMetricsOrder.__strawberry_definition__.fields } - expected_fields = {"score", "project__name"} + expected_fields = { + "score", + "stars_count", + "forks_count", + "contributors_count", + "created_at", + "project__name", + } assert expected_fields == order_fields def test_order_by(self): - """Test ordering by score.""" - order_instance = ProjectHealthMetricsOrder(score="DESC", project__name="ASC") + """Test ordering by various fields.""" + order_instance = ProjectHealthMetricsOrder( + score="DESC", + stars_count="DESC", + forks_count="ASC", + contributors_count="DESC", + created_at="ASC", + project__name="ASC", + ) assert order_instance.score == "DESC" + assert order_instance.stars_count == "DESC" + assert order_instance.forks_count == "ASC" + assert order_instance.contributors_count == "DESC" + assert order_instance.created_at == "ASC" assert order_instance.project__name == "ASC" diff --git a/cspell/package.json b/cspell/package.json index cbe41fa623..a69287b6db 100644 --- a/cspell/package.json +++ b/cspell/package.json @@ -1,12 +1,12 @@ { "devDependencies": { "@cspell/dict-aws": "^4.0.15", - "@cspell/dict-data-science": "^2.0.10", - "@cspell/dict-en_us": "^4.4.21", + "@cspell/dict-data-science": "^2.0.11", + "@cspell/dict-en_us": "^4.4.22", "@cspell/dict-fullstack": "^3.2.7", - "@cspell/dict-golang": "^6.0.23", + "@cspell/dict-golang": "^6.0.24", "@cspell/dict-k8s": "^1.0.12", - "@cspell/dict-people-names": "^1.1.14", + "@cspell/dict-people-names": "^1.1.15", "@cspell/dict-software-terms": "^4.2.5", "@cspell/dict-win32": "^2.0.9", "cspell": "^8.19.4" diff --git a/cspell/pnpm-lock.yaml b/cspell/pnpm-lock.yaml index 2e471c9116..a2ee2cb35e 100644 --- a/cspell/pnpm-lock.yaml +++ b/cspell/pnpm-lock.yaml @@ -12,23 +12,23 @@ importers: specifier: ^4.0.15 version: 4.0.15 '@cspell/dict-data-science': - specifier: ^2.0.10 - version: 2.0.10 + specifier: ^2.0.11 + version: 2.0.11 '@cspell/dict-en_us': - specifier: ^4.4.21 - version: 4.4.21 + specifier: ^4.4.22 + version: 4.4.22 '@cspell/dict-fullstack': specifier: ^3.2.7 version: 3.2.7 '@cspell/dict-golang': - specifier: ^6.0.23 - version: 6.0.23 + specifier: ^6.0.24 + version: 6.0.24 '@cspell/dict-k8s': specifier: ^1.0.12 version: 1.0.12 '@cspell/dict-people-names': - specifier: ^1.1.14 - version: 1.1.14 + specifier: ^1.1.15 + version: 1.1.15 '@cspell/dict-software-terms': specifier: ^4.2.5 version: 4.2.5 @@ -74,14 +74,14 @@ packages: '@cspell/dict-aws@4.0.15': resolution: {integrity: sha512-aPY7VVR5Os4rz36EaqXBAEy14wR4Rqv+leCJ2Ug/Gd0IglJpM30LalF3e2eJChnjje3vWoEC0Rz3+e5gpZG+Kg==} - '@cspell/dict-bash@4.2.1': - resolution: {integrity: sha512-SBnzfAyEAZLI9KFS7DUG6Xc1vDFuLllY3jz0WHvmxe8/4xV3ufFE3fGxalTikc1VVeZgZmxYiABw4iGxVldYEg==} + '@cspell/dict-bash@4.2.2': + resolution: {integrity: sha512-kyWbwtX3TsCf5l49gGQIZkRLaB/P8g73GDRm41Zu8Mv51kjl2H7Au0TsEvHv7jzcsRLS6aUYaZv6Zsvk1fOz+Q==} - '@cspell/dict-companies@3.2.6': - resolution: {integrity: sha512-cVWBk4DSUOthCsgOsoB+5L5F1Wk8lWGHnw5de75YCKSjOEV8/6kskwwDrPTIHkoGVzpIzIIQ/OdXhYwa2G+16A==} + '@cspell/dict-companies@3.2.7': + resolution: {integrity: sha512-fEyr3LmpFKTaD0LcRhB4lfW1AmULYBqzg4gWAV0dQCv06l+TsA+JQ+3pZJbUcoaZirtgsgT3dL3RUjmGPhUH0A==} - '@cspell/dict-cpp@6.0.12': - resolution: {integrity: sha512-N4NsCTttVpMqQEYbf0VQwCj6np+pJESov0WieCN7R/0aByz4+MXEiDieWWisaiVi8LbKzs1mEj4ZTw5K/6O2UQ==} + '@cspell/dict-cpp@6.0.13': + resolution: {integrity: sha512-EFrhN/91tPwadI9m8Rxe65//9gqv+lpZoKtrngzF4DTnw4YAfMLTpykendHps0bz46NZW84/zoY1cxeW2TEPQQ==} '@cspell/dict-cryptocurrencies@5.0.5': resolution: {integrity: sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==} @@ -95,8 +95,8 @@ packages: '@cspell/dict-dart@2.3.1': resolution: {integrity: sha512-xoiGnULEcWdodXI6EwVyqpZmpOoh8RA2Xk9BNdR7DLamV/QMvEYn8KJ7NlRiTSauJKPNkHHQ5EVHRM6sTS7jdg==} - '@cspell/dict-data-science@2.0.10': - resolution: {integrity: sha512-vZSsz7845ugW6mY65966Ki2bMS/ZnAZoTVvpuXQ07a2rYxJhUC+6WuBMD80hFLlKwjC5T/5Llv4F/VlB00swpw==} + '@cspell/dict-data-science@2.0.11': + resolution: {integrity: sha512-Dt+83nVCcF+dQyvFSaZjCKt1H5KbsVJFtH2X7VUfmIzQu8xCnV1fUmkhBzGJ+NiFs99Oy9JA6I9EjeqExzXk7g==} '@cspell/dict-django@4.1.5': resolution: {integrity: sha512-AvTWu99doU3T8ifoMYOMLW2CXKvyKLukPh1auOPwFGHzueWYvBBN+OxF8wF7XwjTBMMeRleVdLh3aWCDEX/ZWg==} @@ -110,14 +110,14 @@ packages: '@cspell/dict-elixir@4.0.8': resolution: {integrity: sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==} - '@cspell/dict-en-common-misspellings@2.1.6': - resolution: {integrity: sha512-xV9yryOqZizbSqxRS7kSVRrxVEyWHUqwdY56IuT7eAWGyTCJNmitXzXa4p+AnEbhL+AB2WLynGVSbNoUC3ceFA==} + '@cspell/dict-en-common-misspellings@2.1.7': + resolution: {integrity: sha512-HAWSOoQ+lxdzLaTALhPofKNJdxZ7HAcTZWQNwb7cvGBiKEy182cb96U35602yBPrBsKY/vLxVs6f0E1JTeQjRQ==} '@cspell/dict-en-gb@1.1.33': resolution: {integrity: sha512-tKSSUf9BJEV+GJQAYGw5e+ouhEe2ZXE620S7BLKe3ZmpnjlNG9JqlnaBhkIMxKnNFkLY2BP/EARzw31AZnOv4g==} - '@cspell/dict-en_us@4.4.21': - resolution: {integrity: sha512-VG5nxhBJeOBCZAKbk6DNFi4oce4mFDNQrQTusFfBvdqLt0VIg8ylUrvAtDJyfYGDUYPSrZQlzME6YVBdowT7Iw==} + '@cspell/dict-en_us@4.4.22': + resolution: {integrity: sha512-i9AJ6z5kyZU5L/b+UOOp/7dfa7RxhibLXWaexSJclf7V7R+TzwCTLoOZd1wf/5PBnNGkP8xOSaflkpUbtVijFA==} '@cspell/dict-filetypes@3.0.14': resolution: {integrity: sha512-KSXaSMYYNMLLdHEnju1DyRRH3eQWPRYRnOXpuHUdOh2jC44VgQoxyMU7oB3NAhDhZKBPCihabzECsAGFbdKfEA==} @@ -140,8 +140,8 @@ packages: '@cspell/dict-git@3.0.7': resolution: {integrity: sha512-odOwVKgfxCQfiSb+nblQZc4ErXmnWEnv8XwkaI4sNJ7cNmojnvogYVeMqkXPjvfrgEcizEEA4URRD2Ms5PDk1w==} - '@cspell/dict-golang@6.0.23': - resolution: {integrity: sha512-oXqUh/9dDwcmVlfUF5bn3fYFqbUzC46lXFQmi5emB0vYsyQXdNWsqi6/yH3uE7bdRE21nP7Yo0mR1jjFNyLamg==} + '@cspell/dict-golang@6.0.24': + resolution: {integrity: sha512-rY7PlC3MsHozmjrZWi0HQPUl0BVCV0+mwK0rnMT7pOIXqOe4tWCYMULDIsEk4F0gbIxb5badd2dkCPDYjLnDgA==} '@cspell/dict-google@1.0.9': resolution: {integrity: sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==} @@ -196,8 +196,8 @@ packages: '@cspell/dict-npm@5.2.19': resolution: {integrity: sha512-fg23oFvKTsGjGB6DkwCUzZrLZPwp+ItSV0UXS+n6JbcH5dj3CP6MDmdwNX6s6oaAovIFKmwFBP73GUqnjMmnpQ==} - '@cspell/dict-people-names@1.1.14': - resolution: {integrity: sha512-iQLh3h7blj2/wIPak3T21qxR0vWVa+ug/hx8PX7prZXy1P2qXzlih2nu48RTJCAlofq8WUk7VbJMNq5QzUQdWA==} + '@cspell/dict-people-names@1.1.15': + resolution: {integrity: sha512-czH7kLsWL2E20bJjMCRPMU9ualOYbSmb3CZuP4GqxjjBWBw3WzEykxhx0Mrl3L7zNywrRQsZ9tv5ErlRCg4Spg==} '@cspell/dict-php@4.1.0': resolution: {integrity: sha512-dTDeabyOj7eFvn2Q4Za3uVXM2+SzeFMqX8ly2P0XTo4AzbCmI2hulFD/QIADwWmwiRrInbbf8cxwFHNIYrXl4w==} @@ -208,8 +208,8 @@ packages: '@cspell/dict-public-licenses@2.0.15': resolution: {integrity: sha512-cJEOs901H13Pfy0fl4dCD1U+xpWIMaEPq8MeYU83FfDZvellAuSo4GqWCripfIqlhns/L6+UZEIJSOZnjgy7Wg==} - '@cspell/dict-python@4.2.20': - resolution: {integrity: sha512-c1wbfb3MDMSY4UTNdGnA18NkrcX6cMlYER0HSpGYh2jLK43gS1QL3j2B49qgnRYfcLUp4xgeA05vzCQsjGbwuQ==} + '@cspell/dict-python@4.2.21': + resolution: {integrity: sha512-M9OgwXWhpZqEZqKU2psB2DFsT8q5SwEahkQeIpNIRWIErjwG7I9yYhhfvPz6s5gMCMhhb3hqcPJTnmdgqGrQyg==} '@cspell/dict-r@2.1.1': resolution: {integrity: sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==} @@ -223,14 +223,14 @@ packages: '@cspell/dict-scala@5.0.8': resolution: {integrity: sha512-YdftVmumv8IZq9zu1gn2U7A4bfM2yj9Vaupydotyjuc+EEZZSqAafTpvW/jKLWji2TgybM1L2IhmV0s/Iv9BTw==} - '@cspell/dict-shell@1.1.1': - resolution: {integrity: sha512-T37oYxE7OV1x/1D4/13Y8JZGa1QgDCXV7AVt3HLXjn0Fe3TaNDvf5sU0fGnXKmBPqFFrHdpD3uutAQb1dlp15g==} + '@cspell/dict-shell@1.1.2': + resolution: {integrity: sha512-WqOUvnwcHK1X61wAfwyXq04cn7KYyskg90j4lLg3sGGKMW9Sq13hs91pqrjC44Q+lQLgCobrTkMDw9Wyl9nRFA==} '@cspell/dict-software-terms@4.2.5': resolution: {integrity: sha512-CaRzkWti3AgcXoxuRcMijaNG7YUk/MH1rHjB8VX34v3UdCxXXeqvRyElRKnxhFeVLB/robb2UdShqh/CpskxRg==} - '@cspell/dict-software-terms@5.1.9': - resolution: {integrity: sha512-lpiSpS1iTF2n8barqVkPmhe5qXs5291IqcDUPr5ttFRxPMZ7pgrMUdvcdNUdkajymjDOyWfUNhdYXW7JndThZw==} + '@cspell/dict-software-terms@5.1.10': + resolution: {integrity: sha512-ffnsKiDL5acUerJ/lDiIT0y/tfO9Jk1yp8RpAl0diOUj5iQuT4hXVfgQSx7ppseXWAGN+UgTRYWiKDb1zM3lqg==} '@cspell/dict-sql@2.2.1': resolution: {integrity: sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==} @@ -450,21 +450,21 @@ snapshots: '@cspell/dict-ada': 4.1.1 '@cspell/dict-al': 1.1.1 '@cspell/dict-aws': 4.0.15 - '@cspell/dict-bash': 4.2.1 - '@cspell/dict-companies': 3.2.6 - '@cspell/dict-cpp': 6.0.12 + '@cspell/dict-bash': 4.2.2 + '@cspell/dict-companies': 3.2.7 + '@cspell/dict-cpp': 6.0.13 '@cspell/dict-cryptocurrencies': 5.0.5 '@cspell/dict-csharp': 4.0.7 '@cspell/dict-css': 4.0.18 '@cspell/dict-dart': 2.3.1 - '@cspell/dict-data-science': 2.0.10 + '@cspell/dict-data-science': 2.0.11 '@cspell/dict-django': 4.1.5 '@cspell/dict-docker': 1.1.16 '@cspell/dict-dotnet': 5.0.10 '@cspell/dict-elixir': 4.0.8 - '@cspell/dict-en-common-misspellings': 2.1.6 + '@cspell/dict-en-common-misspellings': 2.1.7 '@cspell/dict-en-gb': 1.1.33 - '@cspell/dict-en_us': 4.4.21 + '@cspell/dict-en_us': 4.4.22 '@cspell/dict-filetypes': 3.0.14 '@cspell/dict-flutter': 1.1.1 '@cspell/dict-fonts': 4.0.5 @@ -472,7 +472,7 @@ snapshots: '@cspell/dict-fullstack': 3.2.7 '@cspell/dict-gaming-terms': 1.1.2 '@cspell/dict-git': 3.0.7 - '@cspell/dict-golang': 6.0.23 + '@cspell/dict-golang': 6.0.24 '@cspell/dict-google': 1.0.9 '@cspell/dict-haskell': 4.0.6 '@cspell/dict-html': 4.0.12 @@ -492,13 +492,13 @@ snapshots: '@cspell/dict-php': 4.1.0 '@cspell/dict-powershell': 5.0.15 '@cspell/dict-public-licenses': 2.0.15 - '@cspell/dict-python': 4.2.20 + '@cspell/dict-python': 4.2.21 '@cspell/dict-r': 2.1.1 '@cspell/dict-ruby': 5.0.9 '@cspell/dict-rust': 4.0.12 '@cspell/dict-scala': 5.0.8 - '@cspell/dict-shell': 1.1.1 - '@cspell/dict-software-terms': 5.1.9 + '@cspell/dict-shell': 1.1.2 + '@cspell/dict-software-terms': 5.1.10 '@cspell/dict-sql': 2.2.1 '@cspell/dict-svelte': 1.0.7 '@cspell/dict-swift': 2.0.6 @@ -526,13 +526,13 @@ snapshots: '@cspell/dict-aws@4.0.15': {} - '@cspell/dict-bash@4.2.1': + '@cspell/dict-bash@4.2.2': dependencies: - '@cspell/dict-shell': 1.1.1 + '@cspell/dict-shell': 1.1.2 - '@cspell/dict-companies@3.2.6': {} + '@cspell/dict-companies@3.2.7': {} - '@cspell/dict-cpp@6.0.12': {} + '@cspell/dict-cpp@6.0.13': {} '@cspell/dict-cryptocurrencies@5.0.5': {} @@ -542,7 +542,7 @@ snapshots: '@cspell/dict-dart@2.3.1': {} - '@cspell/dict-data-science@2.0.10': {} + '@cspell/dict-data-science@2.0.11': {} '@cspell/dict-django@4.1.5': {} @@ -552,11 +552,11 @@ snapshots: '@cspell/dict-elixir@4.0.8': {} - '@cspell/dict-en-common-misspellings@2.1.6': {} + '@cspell/dict-en-common-misspellings@2.1.7': {} '@cspell/dict-en-gb@1.1.33': {} - '@cspell/dict-en_us@4.4.21': {} + '@cspell/dict-en_us@4.4.22': {} '@cspell/dict-filetypes@3.0.14': {} @@ -572,7 +572,7 @@ snapshots: '@cspell/dict-git@3.0.7': {} - '@cspell/dict-golang@6.0.23': {} + '@cspell/dict-golang@6.0.24': {} '@cspell/dict-google@1.0.9': {} @@ -611,7 +611,7 @@ snapshots: '@cspell/dict-npm@5.2.19': {} - '@cspell/dict-people-names@1.1.14': {} + '@cspell/dict-people-names@1.1.15': {} '@cspell/dict-php@4.1.0': {} @@ -619,9 +619,9 @@ snapshots: '@cspell/dict-public-licenses@2.0.15': {} - '@cspell/dict-python@4.2.20': + '@cspell/dict-python@4.2.21': dependencies: - '@cspell/dict-data-science': 2.0.10 + '@cspell/dict-data-science': 2.0.11 '@cspell/dict-r@2.1.1': {} @@ -631,11 +631,11 @@ snapshots: '@cspell/dict-scala@5.0.8': {} - '@cspell/dict-shell@1.1.1': {} + '@cspell/dict-shell@1.1.2': {} '@cspell/dict-software-terms@4.2.5': {} - '@cspell/dict-software-terms@5.1.9': {} + '@cspell/dict-software-terms@5.1.10': {} '@cspell/dict-sql@2.2.1': {} diff --git a/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx b/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx index 4585df8926..125ab45879 100644 --- a/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx +++ b/frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx @@ -114,7 +114,7 @@ describe('MetricsPage', () => { }) }) }) - test('renders filter and sort dropdowns', async () => { + test('renders filter dropdown and sortable column headers', async () => { render() const filterOptions = [ 'Incubator', @@ -127,7 +127,7 @@ describe('MetricsPage', () => { 'Reset All Filters', ] const filterSectionsLabels = ['Project Level', 'Project Health', 'Reset Filters'] - const sortOptions = ['Ascending', 'Descending'] + const sortableColumns = ['Stars', 'Forks', 'Contributors', 'Health Checked At', 'Score'] await waitFor(() => { filterSectionsLabels.forEach((label) => { @@ -139,14 +139,60 @@ describe('MetricsPage', () => { fireEvent.click(button) expect(button).toBeInTheDocument() }) - sortOptions.forEach((option) => { - expect(screen.getAllByText(option).length).toBeGreaterThan(0) - const button = screen.getByRole('button', { name: option }) - fireEvent.click(button) - expect(button).toBeInTheDocument() + + sortableColumns.forEach((column) => { + const sortButton = screen.getByTitle(`Sort by ${column}`) + expect(sortButton).toBeInTheDocument() }) }) }) + + test('handles sorting state and URL updates', async () => { + const mockReplace = jest.fn() + const { useRouter, useSearchParams } = jest.requireMock('next/navigation') + ;(useRouter as jest.Mock).mockReturnValue({ + push: jest.fn(), + replace: mockReplace, + }) + + // Test unsorted -> descending + ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams()) + const { rerender } = render() + + const sortButton = screen.getByTitle('Sort by Stars') + fireEvent.click(sortButton) + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith(expect.stringContaining('order=-stars')) + }) + + // Test descending -> ascending + mockReplace.mockClear() + ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams('order=-stars')) + rerender() + + const sortButtonDesc = screen.getByTitle('Sort by Stars') + fireEvent.click(sortButtonDesc) + + await waitFor(() => { + const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1][0] + expect(lastCall).toContain('order=stars') + expect(lastCall).not.toContain('order=-stars') + }) + + // Test ascending -> unsorted (removes order param, defaults to -score) + mockReplace.mockClear() + ;(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams('order=stars')) + rerender() + + const sortButtonAsc = screen.getByTitle('Sort by Stars') + fireEvent.click(sortButtonAsc) + + await waitFor(() => { + const lastCall = mockReplace.mock.calls[mockReplace.mock.calls.length - 1][0] + expect(lastCall).not.toContain('order=') + }) + }) test('render health metrics data', async () => { render() const metrics = mockHealthMetricsData.projectHealthMetrics diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 58da36e209..83a0183eea 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -857,8 +857,8 @@ packages: '@parcel/watcher': optional: true - '@graphql-codegen/client-preset@5.1.0': - resolution: {integrity: sha512-MYMy9dIlAgT3q1U8WUys6Y8yt/T9WLsm1DczRtrCpV5N11v4Rlg3hGWQmEvhJtBbWxgzfYoHZHb0TohtbLkJ+g==} + '@graphql-codegen/client-preset@5.1.1': + resolution: {integrity: sha512-d7a4KdZJBOPt/O55JneBz9WwvpWar/P5yyxfjZvvoRErXPRsWtswLp+CBKKPkRcEIz9MXfTdQ1GL3kQg16DLfg==} engines: {node: '>=16'} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -873,8 +873,8 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 - '@graphql-codegen/gql-tag-operations@5.0.2': - resolution: {integrity: sha512-iK+LFGv4ihHKeerADFPTL7Iq4iNr+J1jm2+GUMtwTSAL4nGk+BdfyruV7eR53R7Des8NFdI+9hBzKbbob7VwGQ==} + '@graphql-codegen/gql-tag-operations@5.0.3': + resolution: {integrity: sha512-G6YqeDMMuwMvAtlW+MUaQDoYgQtBuBrfp89IOSnj7YXqSc/TMOma3X5XeXM4/oeNDQyfm2A66j5H8DYf04mJZg==} engines: {node: '>=16'} peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -8478,7 +8478,7 @@ snapshots: '@babel/generator': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 - '@graphql-codegen/client-preset': 5.1.0(graphql@16.11.0) + '@graphql-codegen/client-preset': 5.1.1(graphql@16.11.0) '@graphql-codegen/core': 5.0.0(graphql@16.11.0) '@graphql-codegen/plugin-helpers': 6.0.0(graphql@16.11.0) '@graphql-tools/apollo-engine-loader': 8.0.22(graphql@16.11.0) @@ -8523,12 +8523,12 @@ snapshots: - uWebSockets.js - utf-8-validate - '@graphql-codegen/client-preset@5.1.0(graphql@16.11.0)': + '@graphql-codegen/client-preset@5.1.1(graphql@16.11.0)': dependencies: '@babel/helper-plugin-utils': 7.27.1 '@babel/template': 7.27.2 '@graphql-codegen/add': 6.0.0(graphql@16.11.0) - '@graphql-codegen/gql-tag-operations': 5.0.2(graphql@16.11.0) + '@graphql-codegen/gql-tag-operations': 5.0.3(graphql@16.11.0) '@graphql-codegen/plugin-helpers': 6.0.0(graphql@16.11.0) '@graphql-codegen/typed-document-node': 6.0.2(graphql@16.11.0) '@graphql-codegen/typescript': 5.0.2(graphql@16.11.0) @@ -8550,7 +8550,7 @@ snapshots: graphql: 16.11.0 tslib: 2.6.3 - '@graphql-codegen/gql-tag-operations@5.0.2(graphql@16.11.0)': + '@graphql-codegen/gql-tag-operations@5.0.3(graphql@16.11.0)': dependencies: '@graphql-codegen/plugin-helpers': 6.0.0(graphql@16.11.0) '@graphql-codegen/visitor-plugin-common': 6.1.0(graphql@16.11.0) diff --git a/frontend/src/app/projects/dashboard/metrics/page.tsx b/frontend/src/app/projects/dashboard/metrics/page.tsx index fff7f67915..a3c5b00bc6 100644 --- a/frontend/src/app/projects/dashboard/metrics/page.tsx +++ b/frontend/src/app/projects/dashboard/metrics/page.tsx @@ -1,6 +1,7 @@ 'use client' import { useQuery } from '@apollo/client/react' -import { faFilter } from '@fortawesome/free-solid-svg-icons' +import { faFilter, faSort, faSortUp, faSortDown } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Pagination } from '@heroui/react' import { useSearchParams, useRouter } from 'next/navigation' import { FC, useState, useEffect } from 'react' @@ -16,6 +17,90 @@ import ProjectsDashboardDropDown from 'components/ProjectsDashboardDropDown' const PAGINATION_LIMIT = 10 +const FIELD_MAPPING = { + score: 'score', + stars: 'starsCount', + forks: 'forksCount', + contributors: 'contributorsCount', + createdAt: 'createdAt', +} as const + +type OrderKey = keyof typeof FIELD_MAPPING + +const parseOrderParam = (orderParam: string | null) => { + if (!orderParam) { + return { field: 'score', direction: Ordering.Desc, urlKey: '-score' } + } + + const isDescending = orderParam.startsWith('-') + const fieldKey = isDescending ? orderParam.slice(1) : orderParam + const isValidKey = fieldKey in FIELD_MAPPING + const normalizedKey = isValidKey ? fieldKey : 'score' + const graphqlField = FIELD_MAPPING[normalizedKey as OrderKey] + const direction = isDescending ? Ordering.Desc : Ordering.Asc + const normalizedUrlKey = direction === Ordering.Desc ? `-${normalizedKey}` : normalizedKey + + return { field: graphqlField, direction, urlKey: normalizedUrlKey } +} + +const buildGraphQLOrdering = (field: string, direction: Ordering) => { + return { + [field]: direction, + } +} + +const buildOrderingWithTieBreaker = (primaryOrdering: Record) => [ + primaryOrdering, + { + // eslint-disable-next-line @typescript-eslint/naming-convention + project_Name: Ordering.Asc, + }, +] +const SortableColumnHeader: FC<{ + label: string + fieldKey: OrderKey + currentOrderKey: string + onSort: (orderKey: string | null) => void + align?: 'left' | 'center' | 'right' +}> = ({ label, fieldKey, currentOrderKey, onSort, align = 'left' }) => { + const isActiveSortDesc = currentOrderKey === `-${fieldKey}` + const isActiveSortAsc = currentOrderKey === fieldKey + const isActive = isActiveSortDesc || isActiveSortAsc + + const handleClick = () => { + if (!isActive) { + onSort(`-${fieldKey}`) + } else if (isActiveSortDesc) { + onSort(fieldKey) + } else { + onSort(null) + } + } + + const alignmentClass = + align === 'center' ? 'justify-center' : align === 'right' ? 'justify-end' : 'justify-start' + const textAlignClass = + align === 'center' ? 'text-center' : align === 'right' ? 'text-right' : 'text-left' + + return ( +
+ +
+ ) +} + const MetricsPage: FC = () => { const searchParams = useSearchParams() const router = useRouter() @@ -53,12 +138,12 @@ const MetricsPage: FC = () => { } let currentFilters = {} - let currentOrdering = { - score: Ordering.Desc, - } + const orderingParam = searchParams.get('order') + const { field, direction, urlKey } = parseOrderParam(orderingParam) + const currentOrdering = buildGraphQLOrdering(field, direction) + const healthFilter = searchParams.get('health') const levelFilter = searchParams.get('level') - const orderingParam = searchParams.get('order') as Ordering const currentFilterKeys = [] if (healthFilter) { currentFilters = { @@ -73,25 +158,13 @@ const MetricsPage: FC = () => { } currentFilterKeys.push(levelFilter) } - if (orderingParam) { - currentOrdering = { - score: orderingParam, - } - } const [metrics, setMetrics] = useState([]) const [metricsLength, setMetricsLength] = useState(0) const [pagination, setPagination] = useState({ offset: 0, limit: PAGINATION_LIMIT }) const [filters, setFilters] = useState(currentFilters) - const [ordering, setOrdering] = useState( - currentOrdering || { - score: Ordering.Desc, - } - ) + const [ordering, setOrdering] = useState(currentOrdering) const [activeFilters, setActiveFilters] = useState(currentFilterKeys) - const [activeOrdering, setActiveOrdering] = useState( - orderingParam ? [orderingParam] : [Ordering.Desc] - ) const { data, error: graphQLRequestError, @@ -101,16 +174,19 @@ const MetricsPage: FC = () => { variables: { filters, pagination: { offset: 0, limit: PAGINATION_LIMIT }, - ordering: [ - ordering, - { - // eslint-disable-next-line @typescript-eslint/naming-convention - project_Name: Ordering.Asc, - }, - ], + ordering: buildOrderingWithTieBreaker(ordering), }, }) + useEffect(() => { + const { field: f, direction: d } = parseOrderParam(searchParams.get('order')) + const nextOrdering = buildGraphQLOrdering(f, d) + if (JSON.stringify(nextOrdering) !== JSON.stringify(ordering)) { + setOrdering(nextOrdering) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]) + useEffect(() => { if (data) { setMetrics(data.projectHealthMetrics) @@ -121,15 +197,6 @@ const MetricsPage: FC = () => { } }, [data, graphQLRequestError]) - const orderingSections: DropDownSectionProps[] = [ - { - title: '', - items: [ - { label: 'Descending', key: 'desc' }, - { label: 'Ascending', key: 'asc' }, - ], - }, - ] const filteringSections: DropDownSectionProps[] = [ { title: 'Project Level', @@ -158,6 +225,24 @@ const MetricsPage: FC = () => { return Math.floor(pagination.offset / PAGINATION_LIMIT) + 1 } + const handleSort = (orderKey: string | null) => { + setPagination({ offset: 0, limit: PAGINATION_LIMIT }) + const newParams = new URLSearchParams(searchParams.toString()) + + if (orderKey === null) { + newParams.delete('order') + const defaultOrdering = buildGraphQLOrdering('score', Ordering.Desc) + setOrdering(defaultOrdering) + } else { + newParams.set('order', orderKey) + const { field: newField, direction: newDirection } = parseOrderParam(orderKey) + const newOrdering = buildGraphQLOrdering(newField, newDirection) + setOrdering(newOrdering) + } + + router.replace(`/projects/dashboard/metrics?${newParams.toString()}`) + } + return ( <>
@@ -198,35 +283,45 @@ const MetricsPage: FC = () => { router.replace(`/projects/dashboard/metrics?${newParams.toString()}`) }} /> - - { - // Reset pagination to the first page when changing ordering - setPagination({ offset: 0, limit: PAGINATION_LIMIT }) - const newParams = new URLSearchParams(searchParams.toString()) - newParams.set('order', key) - setOrdering({ - score: key, - }) - setActiveOrdering([key]) - router.replace(`/projects/dashboard/metrics?${newParams.toString()}`) - }} - />
-
+
Project Name
-
Stars
-
Forks
-
Contributors
-
Health Checked At
-
Score
+ + + + +
{loading ? ( @@ -254,13 +349,7 @@ const MetricsPage: FC = () => { variables: { filters, pagination: newPagination, - ordering: [ - ordering, - { - // eslint-disable-next-line @typescript-eslint/naming-convention - project_Name: Ordering.Asc, - }, - ], + ordering: buildOrderingWithTieBreaker(ordering), }, updateQuery: (prev, { fetchMoreResult }) => { if (!fetchMoreResult) return prev diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index 736a223588..88e067fe9b 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -475,8 +475,12 @@ export type ProjectHealthMetricsNode = Node & { }; export type ProjectHealthMetricsOrder = { + contributorsCount?: InputMaybe; + createdAt?: InputMaybe; + forksCount?: InputMaybe; project_Name?: InputMaybe; score?: InputMaybe; + starsCount?: InputMaybe; }; export type ProjectHealthStatsNode = {