Skip to content
Merged
15 changes: 15 additions & 0 deletions backend/apps/mentorship/api/internal/nodes/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import strawberry

from apps.github.api.internal.nodes.milestone import MilestoneNode # noqa: TC001
from apps.github.models.milestone import Milestone
from apps.mentorship.api.internal.nodes.enum import (
ExperienceLevelEnum,
ProgramStatusEnum,
Expand Down Expand Up @@ -33,6 +35,19 @@ def admins(self) -> list[MentorNode] | None:
"""Get the list of program administrators."""
return self.admins.all()

@strawberry.field
def recent_milestones(self) -> list["MilestoneNode"]:
"""Get the list of recent milestones for the program."""
project_ids = self.modules.values_list("project_id", flat=True)

return (
Milestone.open_milestones.filter(repository__project__in=project_ids)
.select_related("repository__organization", "author")
.prefetch_related("labels")
.order_by("-created_at")
.distinct()
)


@strawberry.type
class PaginatedPrograms:
Expand Down
101 changes: 100 additions & 1 deletion frontend/__tests__/unit/components/CardDetailsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, cleanup } from '@testing-library/react'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import React from 'react'
import '@testing-library/jest-dom'
import { FaCode, FaTags } from 'react-icons/fa6'
Expand Down Expand Up @@ -1701,4 +1701,103 @@ describe('CardDetailsPage', () => {
)
})
})

describe('Program Milestones Display', () => {
const createMilestones = (count: number) => {
const milestones = []
for (let i = 0; i < count; i++) {
milestones.push({
author: mockUser,
body: `Milestone description ${i + 1}`,
closedIssuesCount: 5,
createdAt: new Date(Date.now() - 10000000).toISOString(),
openIssuesCount: 2,
repositoryName: `test-repo-${i}`,
organizationName: 'test-org',
state: 'open',
title: `Milestone ${i + 1}`,
url: `https://github.com/test/project/milestone/${i + 1}`,
})
}
return milestones
}

it('renders only first 4 milestones initially for program type', () => {
const manyMilestones = createMilestones(6)
const programProps: DetailsCardProps = {
...defaultProps,
type: 'program' as const,
recentMilestones: manyMilestones,
modules: [],
}

render(<CardDetailsPage {...programProps} />)

expect(screen.getByText('Recent Milestones')).toBeInTheDocument()

expect(screen.getByText('Milestone 1')).toBeInTheDocument()
expect(screen.getByText('Milestone 4')).toBeInTheDocument()

expect(screen.queryByText('Milestone 5')).not.toBeInTheDocument()
expect(screen.queryByText('Milestone 6')).not.toBeInTheDocument()

expect(screen.getByText(/Show more/i)).toBeInTheDocument()
})

it('expands to show all milestones when "Show more" is clicked', () => {
const manyMilestones = createMilestones(6)
const programProps: DetailsCardProps = {
...defaultProps,
type: 'program' as const,
recentMilestones: manyMilestones,
modules: [],
}

render(<CardDetailsPage {...programProps} />)

const showMoreBtn = screen.getByText(/Show more/i)
fireEvent.click(showMoreBtn)

expect(screen.getByText('Milestone 5')).toBeInTheDocument()
expect(screen.getByText('Milestone 6')).toBeInTheDocument()

expect(screen.getByText(/Show less/i)).toBeInTheDocument()
})

it('collapses back to 4 milestones when "Show less" is clicked', () => {
const manyMilestones = createMilestones(6)
const programProps: DetailsCardProps = {
...defaultProps,
type: 'program' as const,
recentMilestones: manyMilestones,
modules: [],
}

render(<CardDetailsPage {...programProps} />)

fireEvent.click(screen.getByText(/Show more/i))
expect(screen.getByText('Milestone 5')).toBeInTheDocument()

fireEvent.click(screen.getByText(/Show less/i))

expect(screen.queryByText('Milestone 5')).not.toBeInTheDocument()
expect(screen.getByText(/Show more/i)).toBeInTheDocument()
})

it('does not show toggle button if milestones <= 4', () => {
const fewMilestones = createMilestones(4)
const programProps: DetailsCardProps = {
...defaultProps,
type: 'program' as const,
recentMilestones: fewMilestones,
modules: [],
}

render(<CardDetailsPage {...programProps} />)

expect(screen.getByText('Milestone 1')).toBeInTheDocument()
expect(screen.getByText('Milestone 4')).toBeInTheDocument()
expect(screen.queryByText(/Show more/i)).not.toBeInTheDocument()
})
})
})
2 changes: 2 additions & 0 deletions frontend/src/app/mentorship/programs/[programKey]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ const ProgramDetailsPage = () => {

return (
<DetailsCard
admins={program.admins}
details={programDetails}
domains={program.domains}
modules={modules}
recentMilestones={program.recentMilestones}
summary={program.description}
tags={program.tags}
title={program.name}
Expand Down
106 changes: 106 additions & 0 deletions frontend/src/components/CardDetailsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import { Tooltip } from '@heroui/tooltip'
import upperFirst from 'lodash/upperFirst'
import Image from 'next/image'
import Link from 'next/link'
import { useSession } from 'next-auth/react'
import { useState } from 'react'
import {
FaCircleInfo,
FaChartPie,
FaFolderOpen,
FaCode,
FaTags,
FaRectangleList,
FaCalendar,
FaCircleCheck,
FaCircleExclamation,
FaSignsPost,
} from 'react-icons/fa6'
import { HiUserGroup } from 'react-icons/hi'
import type { ExtendedSession } from 'types/auth'
import type { DetailsCardProps } from 'types/card'
import { formatDate } from 'utils/dateFormatter'
import { IS_PROJECT_HEALTH_ENABLED } from 'utils/env.client'
import { scrollToAnchor } from 'utils/scrollToAnchor'
import { getMemberUrl, getMenteeUrl } from 'utils/urlFormatter'
Expand All @@ -34,10 +43,13 @@ import RecentPullRequests from 'components/RecentPullRequests'
import RecentReleases from 'components/RecentReleases'
import RepositoryCard from 'components/RepositoryCard'
import SecondaryCard from 'components/SecondaryCard'
import ShowMoreButton from 'components/ShowMoreButton'
import SponsorCard from 'components/SponsorCard'
import StatusBadge from 'components/StatusBadge'
import ToggleableList from 'components/ToggleableList'

import { TruncatedText } from 'components/TruncatedText'

export type CardType =
| 'chapter'
| 'committee'
Expand All @@ -57,6 +69,8 @@ const showIssuesAndMilestones = (type: CardType): boolean =>
const showPullRequestsAndReleases = (type: CardType): boolean =>
['organization', 'project', 'repository', 'user'].includes(type)

const MILESTONE_LIMIT = 4

const DetailsCard = ({
description,
details,
Expand Down Expand Up @@ -100,6 +114,7 @@ const DetailsCard = ({
userSummary,
}: DetailsCardProps) => {
const { data: session } = useSession() as { data: ExtendedSession | null }
const [showAllMilestones, setShowAllMilestones] = useState(false)

// compute styles based on type prop
const typeStylesMap = {
Expand Down Expand Up @@ -366,6 +381,97 @@ const DetailsCard = ({
<ModuleCard modules={modules} accessLevel={accessLevel} admins={admins} />
</SecondaryCard>
)}
{type === 'program' && recentMilestones && recentMilestones.length > 0 && (
<SecondaryCard icon={FaSignsPost} title={<AnchorTitle title="Recent Milestones" />}>
<div className="grid gap-4 gap-y-0 sm:grid-cols-1 md:grid-cols-2">
{recentMilestones
.slice(0, showAllMilestones ? recentMilestones.length : MILESTONE_LIMIT)
.map((milestone, index) => (
<div
key={milestone.url || `${milestone.title}-${index}`}
className="mb-4 w-full rounded-lg bg-gray-200 p-4 dark:bg-gray-700"
>
<div className="flex w-full flex-col justify-between">
<div className="flex w-full items-center">
{showAvatar && milestone?.author?.login && milestone?.author?.avatarUrl && (
<Tooltip
closeDelay={100}
content={milestone?.author?.name || milestone?.author?.login}
id={`avatar-tooltip-${index}`}
delay={100}
placement="bottom"
showArrow
>
<Link
className="shrink-0 text-blue-400 hover:underline"
href={`/members/${milestone?.author?.login}`}
>
<Image
height={24}
width={24}
src={milestone?.author?.avatarUrl}
alt={
milestone.author &&
(milestone.author.name || milestone.author.login)
? `${milestone.author.name || milestone.author.login}'s avatar`
: "Author's avatar"
}
className="mr-2 rounded-full"
/>
</Link>
</Tooltip>
)}
<h3 className="min-w-0 flex-1 overflow-hidden font-semibold text-ellipsis whitespace-nowrap">
{milestone?.url ? (
<Link
className="text-blue-400 hover:underline"
href={milestone?.url}
target="_blank"
rel="noopener noreferrer"
>
<TruncatedText text={milestone.title} />
</Link>
) : (
<TruncatedText text={milestone.title} />
)}
</h3>
</div>
<div className="ml-0.5 w-full">
<div className="mt-2 flex flex-wrap items-center text-sm text-gray-600 dark:text-gray-400">
<div className="mr-4 flex items-center">
<FaCalendar className="mr-2 h-4 w-4" />
<span>{formatDate(milestone.createdAt)}</span>
</div>
<div className="mr-4 flex items-center">
<FaCircleCheck className="mr-2 h-4 w-4" />
<span>{milestone.closedIssuesCount} closed</span>
</div>
<div className="mr-4 flex items-center">
<FaCircleExclamation className="mr-2 h-4 w-4" />
<span>{milestone.openIssuesCount} open</span>
</div>
{milestone?.repositoryName && milestone?.organizationName && (
<div className="flex flex-1 items-center overflow-hidden">
<FaFolderOpen className="mr-2 h-5 w-4 shrink-0" />
<Link
href={`/organizations/${milestone.organizationName}/repositories/${milestone.repositoryName}`}
className="cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap text-gray-600 hover:underline dark:text-gray-400"
>
<TruncatedText text={milestone.repositoryName} />
</Link>
</div>
)}
</div>
</div>
</div>
</div>
))}
</div>
{recentMilestones.length > MILESTONE_LIMIT && (
<ShowMoreButton onToggle={() => setShowAllMilestones(!showAllMilestones)} />
)}
</SecondaryCard>
)}
{IS_PROJECT_HEALTH_ENABLED && type === 'project' && healthMetricsData.length > 0 && (
<HealthMetrics data={healthMetricsData} />
)}
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/server/queries/programsQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ export const GET_PROGRAM_AND_MODULES = gql`
name
avatarUrl
}
recentMilestones {
id
title
state
openIssuesCount
closedIssuesCount
createdAt
repositoryName
organizationName
url
author {
id
login
name
avatarUrl
}
}
}
getProgramModules(programKey: $programKey) {
id
Expand Down
1 change: 1 addition & 0 deletions frontend/src/types/__generated__/graphql.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading