Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ describe('MetricsPage', () => {
})
})
})
test('renders filter and sort dropdowns', async () => {
test('renders filter dropdown and sortable column headers', async () => {
render(<MetricsPage />)
const filterOptions = [
'Incubator',
Expand All @@ -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) => {
Expand All @@ -139,11 +139,11 @@ 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()
fireEvent.click(sortButton)
})
})
})
Expand Down
207 changes: 140 additions & 67 deletions frontend/src/app/projects/dashboard/metrics/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -16,6 +17,83 @@ import ProjectsDashboardDropDown from 'components/ProjectsDashboardDropDown'

const PAGINATION_LIMIT = 10

const FIELD_MAPPING: Record<string, string> = {
score: 'score',
stars: 'starsCount',
forks: 'forksCount',
contributors: 'contributorsCount',
createdAt: 'createdAt',
}

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 buildGraphQLOrdering = (field: string, direction: Ordering) => {
return {
[field]: direction,
}
}

const buildOrderingWithTieBreaker = (primaryOrdering: Record<string, Ordering>) => [
primaryOrdering,
{
// eslint-disable-next-line @typescript-eslint/naming-convention
project_Name: Ordering.Asc,
},
]
const SortableColumnHeader: FC<{
label: string
fieldKey: string
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 (
<div className={`flex items-center gap-1 ${alignmentClass}`}>
<button
onClick={handleClick}
className={`flex items-center gap-1 font-semibold transition-colors hover:text-blue-600 ${textAlignClass}`}
title={`Sort by ${label}`}
>
<span className="truncate">{label}</span>
<FontAwesomeIcon
icon={isActiveSortDesc ? faSortDown : isActiveSortAsc ? faSortUp : faSort}
className={`h-3 w-3 ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
/>
</button>
</div>
)
}

const MetricsPage: FC = () => {
const searchParams = useSearchParams()
const router = useRouter()
Expand Down Expand Up @@ -53,12 +131,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 = {
Expand All @@ -73,25 +151,13 @@ const MetricsPage: FC = () => {
}
currentFilterKeys.push(levelFilter)
}
if (orderingParam) {
currentOrdering = {
score: orderingParam,
}
}

const [metrics, setMetrics] = useState<HealthMetricsProps[]>([])
const [metricsLength, setMetricsLength] = useState<number>(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,
Expand All @@ -101,13 +167,7 @@ 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),
},
})

Expand All @@ -121,15 +181,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',
Expand Down Expand Up @@ -158,6 +209,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 (
<>
<div className="mb-4 flex items-center justify-between">
Expand Down Expand Up @@ -198,35 +267,45 @@ const MetricsPage: FC = () => {
router.replace(`/projects/dashboard/metrics?${newParams.toString()}`)
}}
/>

<ProjectsDashboardDropDown
buttonDisplayName="Score"
isOrdering
sections={orderingSections}
selectionMode="single"
selectedKeys={activeOrdering}
selectedLabels={getKeysLabels(orderingSections, activeOrdering)}
onAction={(key: Ordering) => {
// 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()}`)
}}
/>
</div>
</div>
<div className="grid grid-cols-[4fr_1fr_1fr_1fr_1.5fr_1fr] p-4">
<div className="grid grid-cols-[4fr_1fr_1fr_1fr_1.5fr_1fr] gap-2 border-b border-gray-200 p-4 dark:border-gray-700">
<div className="truncate font-semibold">Project Name</div>
<div className="truncate text-center font-semibold">Stars</div>
<div className="truncate text-center font-semibold">Forks</div>
<div className="truncate text-center font-semibold">Contributors</div>
<div className="truncate text-center font-semibold">Health Checked At</div>
<div className="truncate text-center font-semibold">Score</div>
<SortableColumnHeader
label="Stars"
fieldKey="stars"
currentOrderKey={urlKey}
onSort={handleSort}
align="center"
/>
<SortableColumnHeader
label="Forks"
fieldKey="forks"
currentOrderKey={urlKey}
onSort={handleSort}
align="center"
/>
<SortableColumnHeader
label="Contributors"
fieldKey="contributors"
currentOrderKey={urlKey}
onSort={handleSort}
align="center"
/>
<SortableColumnHeader
label="Health Checked At"
fieldKey="createdAt"
currentOrderKey={urlKey}
onSort={handleSort}
align="center"
/>
<SortableColumnHeader
label="Score"
fieldKey="score"
currentOrderKey={urlKey}
onSort={handleSort}
align="center"
/>
</div>
{loading ? (
<LoadingSpinner />
Expand Down Expand Up @@ -254,13 +333,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
Expand Down