Skip to content

Conversation

@EclipseAditya
Copy link
Contributor

Proposed change

Resolves #2002

Implemented multi-column sorting functionality for the Project Health Metrics Dashboard, allowing users to sort by any visible column (Stars, Forks, Contributors, Health Check Date, Score) with ascending/descending order controlled via clickable column headers.

Changes:

Backend:

  • Added 4 new sortable fields to ProjectHealthMetricsOrder: stars_count, forks_count, contributors_count, created_at
  • Updated ordering tests to validate all 6 sortable fields (including existing score and project__name)

Frontend:

  • Replaced dropdown sort UI with clickable column headers
  • Implemented 3-state sorting cycle: unsorted → descending → ascending → unsorted
  • Added SortableColumnHeader component with visual feedback (sort icons and active state highlighting)
  • Integrated URL parameter handling with format: -fieldName (descending) or fieldName (ascending)
  • Default sorting: -score (score descending)
  • Added helper functions: parseOrderParam(), buildGraphQLOrdering(), buildOrderingWithTieBreaker()
  • Updated frontend tests to validate sortable column headers

Files modified: 4 files

  • backend/apps/owasp/api/internal/ordering/project_health_metrics.py
  • backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py
  • frontend/src/app/projects/dashboard/metrics/page.tsx
  • frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx

Features:

  • ✅ Sort by Stars, Forks, Contributors, Health Checked At, or Score
  • ✅ Visual indicators: Blue ascending (▲) / descending (▼) arrows, gray neutral (⬍) icon
  • ✅ URL reflects current sort: ?order=-stars, ?order=forks, etc.
  • ✅ Backend-side sorting with tie-breaker for consistent pagination
  • ✅ Pagination resets to page 1 when sort changes
  • ✅ Works seamlessly with existing health/level filters

Checklist

  • I've read and followed the contributing guidelines.
  • I've run make check-test locally; all checks and tests passed.

##Images
no_sorting_defualt_by_score
sorting_eg_on_stars(sort button clicked sorts desc)
sorting_eg_on_stars(sort button clicked 2nd time, asc)
sorting_eg_on_stars(sort button clicked 3rd time goes back to default)
LightBackground eg

- Add sortable fields: stars, forks, contributors, health checked date
- Replace dropdown UI with clickable column headers
- Implement 3-state sorting: unsorted → desc → asc → unsorted
- URL format: -forks, stars, -contributors, etc.
- Visual feedback with sort icons (▼ ▲ ⬍)
- Default sorting: -score (score descending)
- Backend: expose new ordering fields in GraphQL schema
- Tests: update to verify all new ordering fields
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 26, 2025

Caution

Review failed

The pull request is closed.

Summary by CodeRabbit

  • New Features

    • Added interactive sortable column headers to the metrics dashboard, enabling sorting by Stars, Forks, Contributors, Health Check Date, and Score
    • Sorting state now persists in URL for shareable filter configurations and improved navigation
  • Chores

    • Updated spell-check dictionary dependencies
    • Enhanced PR validation feedback messaging

Walkthrough

Adds backend ordering fields (stars_count, forks_count, contributors_count, created_at), updates backend tests for multi-field ordering, and implements frontend sortable column headers with URL-synced multi-field ordering and tie-breaker behavior; updates corresponding frontend tests and CI workflow messages.

Changes

Cohort / File(s) Summary
Backend Ordering API
backend/apps/owasp/api/internal/ordering/project_health_metrics.py
Added four strawberry.auto fields to ProjectHealthMetricsOrder: stars_count, forks_count, contributors_count, created_at. Minor comment text change (“equal scores” → “equal values”).
Backend Tests
backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py
Expanded test_order_by to expect the new ordering fields and updated assertions for multi-field ordering.
Frontend Metrics Page
frontend/src/app/projects/dashboard/metrics/page.tsx
Replaced dropdown ordering with sortable column headers; added FIELD_MAPPING, parseOrderParam, buildGraphQLOrdering, buildOrderingWithTieBreaker; URL-synced ordering state and tie-breaker (project_Name Asc); updated GraphQL query ordering usage and header UI.
Frontend Tests
frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx
Updated tests to assert sortable column headers, three-state sorting cycle (unsorted → desc → asc → unsorted), and URL/router interactions; replaced static sort-option assertions with dynamic checks for Stars, Forks, Contributors, Health Checked At, Score.
CI Workflow
.github/workflows/check-pr-issue.yaml
Changed close_pr_on_failure to true and adjusted user-facing messages for missing issue/assignee.
Spellcheck deps
cspell/package.json
Bumped several @cspell dictionary devDependency versions (minor patch updates).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Areas to focus on:
    • parseOrderParam / buildGraphQLOrdering / buildOrderingWithTieBreaker correctness (parsing, mapping, direction, and tie-breaker ordering).
    • Integration of ordering into initial query and fetchMore usage.
    • SortableColumnHeader behaviour: click state cycle, aria/title usage, and router.replace URL sync.
    • Tests: ensure Next.js router/useSearchParams mocks match runtime and three-state transitions are correctly asserted.
    • Backend: ensure new strawberry fields align with GraphQL schema and do not conflict with existing ordering enums/serializers.

Possibly related PRs

Suggested reviewers

  • arkid15r
  • kasya

Pre-merge checks and finishing touches

✅ Passed checks (5 passed)
Check name Status Explanation
Title Check ✅ Passed The title "feat: add multi-column sorting to project health metrics dashboard" directly and specifically describes the primary change in this pull request. It is clear, concise, and accurately summarizes the main feature being implemented. A reader scanning the history would immediately understand that this PR adds sorting capabilities across multiple columns to the metrics dashboard.
Linked Issues Check ✅ Passed The pull request successfully implements all coding requirements from issue #2002. Backend changes add the four required sortable fields (stars_count, forks_count, contributors_count, created_at) to ProjectHealthMetricsOrder [#2002]. Frontend changes implement the 3-state sorting cycle with visual feedback, URL parameter handling using the -fieldName format for descending and fieldName for ascending [#2002], and establish -score as the default sorting [#2002]. Backend-side sorting is confirmed through the ordering field additions and updated tests that validate all six sortable fields [#2002].
Out of Scope Changes Check ✅ Passed All code changes in this pull request are directly related to implementing multi-column sorting for the project health metrics dashboard as specified in the linked issue. The backend changes add sortable field definitions, the frontend changes implement the sorting UI and logic, tests are updated to validate the new functionality, and minor textual and layout adjustments are all integral to the sorting feature. No unrelated refactoring, auxiliary features, or tangential modifications are present.
Description Check ✅ Passed The description is comprehensive and directly related to the changeset. It clearly references the resolved issue (#2002), explains the implementation approach for both backend and frontend, lists all modified files, describes the key features and behaviors (3-state sorting cycle, URL parameter handling, default sorting), and includes visual evidence through screenshots demonstrating the feature in action.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

📜 Recent review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a17ffd9 and 0e52842.

⛔ Files ignored due to path filters (4)
  • backend/poetry.lock is excluded by !**/*.lock
  • cspell/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • frontend/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • frontend/src/types/__generated__/graphql.ts is excluded by !**/__generated__/**
📒 Files selected for processing (4)
  • .github/workflows/check-pr-issue.yaml (1 hunks)
  • cspell/package.json (1 hunks)
  • frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx (3 hunks)
  • frontend/src/app/projects/dashboard/metrics/page.tsx (8 hunks)

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link

Test: The linked issue must be assigned to the PR author.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
frontend/src/app/projects/dashboard/metrics/page.tsx (3)

28-39: Canonicalize invalid order inputs to default and reflect it back in UI.

If ?order= contains an unknown key, you fall back to score for GraphQL but keep the raw URL key for UI, causing icon mismatch. Canonicalize the URL key too.

Apply:

 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 graphqlField = FIELD_MAPPING[fieldKey] || 'score'
-  const direction = isDescending ? Ordering.Desc : Ordering.Asc
-
-  return { field: graphqlField, direction, urlKey: orderParam }
+  const isValidKey = fieldKey in FIELD_MAPPING
+  const normalizedKey = isValidKey ? fieldKey : 'score'
+  const graphqlField = FIELD_MAPPING[normalizedKey]
+  const direction = isDescending ? Ordering.Desc : Ordering.Asc
+  const normalizedUrlKey = direction === Ordering.Desc ? `-${normalizedKey}` : normalizedKey
+  return { field: graphqlField, direction, urlKey: normalizedUrlKey }
 }

20-26: Tighten types for FIELD_MAPPING to prevent drift.

Use as const to derive a safe OrderKey union and avoid silent typos across UI ↔ GraphQL fields.

Apply:

-const FIELD_MAPPING: Record<string, string> = {
+const FIELD_MAPPING = {
   score: 'score',
   stars: 'starsCount',
   forks: 'forksCount',
   contributors: 'contributorsCount',
   createdAt: 'createdAt',
-}
+} as const
+type OrderKey = keyof typeof FIELD_MAPPING

Then update SortableColumnHeader prop and usages to fieldKey: OrderKey.


80-95: Improve sort button accessibility.

Expose state via ARIA for assistive tech. Keep the visible text; add aria-pressed and aria-label.

Apply:

-      <button
+      <button
         onClick={handleClick}
         className={`flex items-center gap-1 font-semibold transition-colors hover:text-blue-600 ${textAlignClass}`}
-        title={`Sort by ${label}`}
+        title={`Sort by ${label}`}
+        aria-label={`Sort by ${label}`}
+        aria-pressed={isActive}
       >
frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx (1)

117-149: Assert the 3‑state cycle and URL updates, not just clickability.

Strengthen this test to verify router.replace is called with expected ?order= values (desc → asc → cleared) for a column, and that default -score is restored when cleared.

Example:

@@
-  sortableColumns.forEach((column) => {
-    const sortButton = screen.getByTitle(`Sort by ${column}`)
-    expect(sortButton).toBeInTheDocument()
-    fireEvent.click(sortButton)
-  })
+  const { useRouter } = jest.requireMock('next/navigation')
+  const replaceMock = useRouter().replace as jest.Mock
+
+  const sortButton = screen.getByTitle('Sort by Stars')
+  // unsorted -> desc
+  fireEvent.click(sortButton)
+  expect(replaceMock).toHaveBeenLastCalledWith(
+    expect.stringContaining('order=-stars')
+  )
+  // desc -> asc
+  fireEvent.click(sortButton)
+  expect(replaceMock).toHaveBeenLastCalledWith(
+    expect.stringContaining('order=stars')
+  )
+  // asc -> unsorted (should drop ?order, default to -score)
+  fireEvent.click(sortButton)
+  expect(replaceMock).toHaveBeenLastCalledWith(
+    expect.not.stringContaining('order=')
+  )

Also consider asserting a second column click replaces the previous order key.

backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py (1)

29-37: Consider asserting enum types (if exposed) and adding a stability check.

If Strawberry exposes an Ordering enum in Python, prefer that over raw strings. Optionally, add a small dataset test to ensure ordering stabilizes with project__name when primary values are equal.

Example:

# if available:
from apps.owasp.api.internal.enums import Ordering
order_instance = ProjectHealthMetricsOrder(score=Ordering.DESC, ...)

Also applies to: 39-43

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 294c3f4 and e62e682.

📒 Files selected for processing (4)
  • backend/apps/owasp/api/internal/ordering/project_health_metrics.py (1 hunks)
  • backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py (1 hunks)
  • frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx (3 hunks)
  • frontend/src/app/projects/dashboard/metrics/page.tsx (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py (3)
backend/apps/owasp/api/internal/ordering/project_health_metrics.py (1)
  • ProjectHealthMetricsOrder (10-23)
backend/apps/owasp/api/internal/nodes/committee.py (3)
  • stars_count (40-42)
  • forks_count (25-27)
  • contributors_count (15-17)
backend/apps/owasp/api/internal/nodes/project_health_metrics.py (1)
  • created_at (45-47)
backend/apps/owasp/api/internal/ordering/project_health_metrics.py (2)
backend/apps/owasp/api/internal/nodes/committee.py (3)
  • stars_count (40-42)
  • forks_count (25-27)
  • contributors_count (15-17)
backend/apps/owasp/api/internal/nodes/project_health_metrics.py (1)
  • created_at (45-47)
🔇 Additional comments (3)
backend/tests/apps/owasp/api/internal/ordering/project_health_metrics_test.py (1)

18-26: Field coverage LGTM; aligns with new sortable fields.

The expected set matches the ordering type, including project__name as a tie‑breaker. Good guard against regressions.

backend/apps/owasp/api/internal/ordering/project_health_metrics.py (1)

14-17: Ordering fields addition looks correct and purposeful.

New fields (stars_count, forks_count, contributors_count, created_at) + tie‑breaker comment align with frontend needs and deterministic pagination.

Also applies to: 19-23

frontend/src/app/projects/dashboard/metrics/page.tsx (1)

47-53: Review comment is incorrect—field naming is proper.

The frontend correctly uses project_Name, which is the TypeScript-generated equivalent of the backend field project__name. This translation from Python snake_case to camelCase is standard GraphQL code generation behavior. The generated types at frontend/src/types/__generated__/graphql.ts:478 define project_Name?: InputMaybe<Ordering>, and the frontend code properly matches this definition. Using project__name directly would violate the generated type contract and cause GraphQL validation errors.

Likely an incorrect or invalid review comment.

@github-actions
Copy link

Test: The linked issue must be assigned to the PR author.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (5)
frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx (5)

54-70: DRY the next/navigation mocks; add a helper to set search params.

You’re reassigning mockSearchParams and re-stubbing useSearchParams in multiple places. Centralize this to reduce duplication and mistakes.

Apply this refactor:

 const mockReplace = jest.fn()
-let mockSearchParams = new URLSearchParams()
+let mockSearchParams = new URLSearchParams()
+const setSearchParams = (qs: string) => {
+  mockSearchParams = new URLSearchParams(qs)
+  ;(require('next/navigation').useSearchParams as jest.Mock).mockReturnValue(mockSearchParams)
+}

 jest.mock('next/navigation', () => ({
-  useSearchParams: jest.fn(() => mockSearchParams),
+  useSearchParams: jest.fn(() => mockSearchParams),
   useRouter: jest.fn(() => ({
     push: jest.fn(),
     replace: mockReplace,
   })),
 }))

 describe('MetricsPage', () => {
   beforeEach(() => {
     mockReplace.mockClear()
-    mockSearchParams = new URLSearchParams()
-    ;(require('next/navigation').useSearchParams as jest.Mock).mockReturnValue(mockSearchParams)
+    setSearchParams('')

Then replace ad-hoc reassignments below with setSearchParams('order=-stars'), etc.


123-156: Avoid firing events inside waitFor; use userEvent for clicks.

Interactions inside waitFor can mask timing issues. Prefer userEvent and assert replace calls per column.

-import { render, screen, waitFor, fireEvent } from '@testing-library/react'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
@@
-  test('renders filter dropdown and sortable column headers', async () => {
-    render(<MetricsPage />)
+  test('renders filter dropdown and sortable column headers', async () => {
+    const user = userEvent.setup()
+    render(<MetricsPage />)
@@
-    await waitFor(() => {
-      filterSectionsLabels.forEach((label) => {
-        expect(screen.getAllByText(label).length).toBeGreaterThan(0)
-      })
-      filterOptions.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()
-        fireEvent.click(sortButton)
-      })
-    })
+    await waitFor(() => {
+      filterSectionsLabels.forEach((label) =>
+        expect(screen.getAllByText(label).length).toBeGreaterThan(0)
+      )
+      filterOptions.forEach((option) =>
+        expect(screen.getAllByText(option).length).toBeGreaterThan(0)
+      )
+      sortableColumns.forEach((column) =>
+        expect(screen.getByTitle(`Sort by ${column}`)).toBeInTheDocument()
+      )
+    })
+    // Interact after DOM is ready
+    for (const option of filterOptions) {
+      await user.click(screen.getByRole('button', { name: option }))
+    }
+    for (const column of sortableColumns) {
+      await user.click(screen.getByTitle(`Sort by ${column}`))
+    }

Optional: also assert mockReplace was called when clicking sortable headers.


115-122: Prefer role-based assertions for table headers.

Role=columnheader is more robust than text-only matching.

-headers.forEach((header) => {
-  expect(screen.getAllByText(header).length).toBeGreaterThan(0)
-})
+headers.forEach((header) => {
+  expect(
+    screen.getAllByRole('columnheader', { name: header }).length
+  ).toBeGreaterThan(0)
+})

Also double-check the label “Health Checked At” vs “Health Check Date” to avoid brittleness.


208-213: Make date formatting deterministic to avoid locale/timezone flakes.

Using 'default' locale can fail in CI with non‑en locales/timezones.

- new Date(metric.createdAt).toLocaleString('default', {
-   month: 'short',
-   day: 'numeric',
-   year: 'numeric',
- })
+ new Date(metric.createdAt).toLocaleDateString('en-US', {
+   month: 'short',
+   day: 'numeric',
+   year: 'numeric',
+   timeZone: 'UTC',
+ })

Alternatively, compare against a regex or the exact formatter used by the component.


220-233: Assert side effects in the pagination test.

Validate that clicking “Next Page” triggers fetchMore (and/or URL update).

 const nextPageButton = screen.getByText('Next Page')
 expect(nextPageButton).toBeInTheDocument()
-fireEvent.click(nextPageButton)
+fireEvent.click(nextPageButton)
+await waitFor(() => {
+  expect(mockFetchMore).toHaveBeenCalled()
+})

Optionally assert the variables passed to fetchMore if available.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e62e682 and a17ffd9.

📒 Files selected for processing (1)
  • frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx (4 hunks)
🔇 Additional comments (1)
frontend/__tests__/unit/pages/ProjectsHealthDashboardMetrics.test.tsx (1)

157-194: I need to understand how sorting updates the URL and whether pagination is reset. Let me search for the sort handler and how it interacts with the router.

Now I need to find where the sort handler is and how it updates the URL query params:

Let me search directly in the codebase for the sort handler and where URL params are built:

Let me search the codebase directly for how the sort handler updates the URL and what URL params it uses:

Based on my investigation, I need to mark this for manual verification because:

  1. The standard Next.js pattern sets params.set('page', '1') when search or filter changes to reset pagination, which aligns with the review comment's suggestion.

  2. However, from the earlier codebase output, I found that pagination uses internal state ({ offset, limit }), not URL parameters like page=1. The test setup shows only order param being used in URL (order=-stars).

  3. The review comment suggests adding assertions for page=1 in the URL, but I cannot confirm:

    • Whether the sort handler currently implements pagination reset as a URL param
    • Whether pagination in this codebase uses URL params or state-based tracking
    • What the actual pagination parameter name should be (if page is correct)

Manually verify the sort handler implementation in frontend/src/app/projects/dashboard/metrics/page.tsx:

  • Find where the sort button click handler calls router.replace()
  • Confirm whether it resets pagination (and if so, how it encodes it in URL params)
  • Verify the pagination parameter name used in this codebase

arkid15r and others added 3 commits October 26, 2025 20:17
- Fix URL/state desync for browser back/forward navigation
- Canonicalize invalid order inputs to prevent UI mismatch
- Add type-safe FIELD_MAPPING with 'as const'
- Improve accessibility with ARIA attributes
- Add comprehensive test for 3-state sorting cycle
- Regenerate GraphQL types with new sortable fields
@EclipseAditya EclipseAditya force-pushed the feature/multi-column-sorting branch from a17ffd9 to 0e52842 Compare October 26, 2025 20:18
@github-actions github-actions bot added the ci label Oct 26, 2025
@github-actions
Copy link

The linked issue must be assigned to the PR author.

@github-actions github-actions bot closed this Oct 26, 2025
@sonarqubecloud
Copy link

@EclipseAditya
Copy link
Contributor Author

oops, messed up here I guess

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extend project health dashboard sorting capabilities

2 participants