diff --git a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts index e822282789..b5d7734944 100644 --- a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts @@ -83,7 +83,10 @@ test.describe('Project Details Page', () => { }) test('should have project repositories', async ({ page }) => { - await expect(page.getByRole('heading', { name: 'Repositories' })).toBeVisible() + const repositoriesSection = page.locator('#repositories') + await repositoriesSection.waitFor({ state: 'attached', timeout: 7000 }) + await expect(repositoriesSection.getByRole('heading', { name: 'Repositories' })).toBeVisible() + await expect(page.getByText('Repo One')).toBeVisible() await expect(page.getByText('Stars95')).toBeVisible() await expect(page.getByText('Forks12')).toBeVisible() diff --git a/frontend/__tests__/unit/pages/ProjectDetails.test.tsx b/frontend/__tests__/unit/pages/ProjectDetails.test.tsx index b09f812066..c1d268c7cf 100644 --- a/frontend/__tests__/unit/pages/ProjectDetails.test.tsx +++ b/frontend/__tests__/unit/pages/ProjectDetails.test.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@apollo/client' -import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor, within } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' import { mockProjectDetailsData } from '@unit/data/mockProjectDetailsData' import { ProjectDetailsPage } from 'pages' import { useNavigate } from 'react-router-dom' @@ -94,29 +95,16 @@ describe('ProjectDetailsPage', () => { test('toggles contributors list when show more/less is clicked', async () => { render() - await waitFor(() => { - expect(screen.getByText('Contributor 6')).toBeInTheDocument() - expect(screen.queryByText('Contributor 7')).not.toBeInTheDocument() - }) - const contributorsSection = screen - .getByRole('heading', { name: /Top Contributors/i }) - .closest('div') - const showMoreButton = within(contributorsSection!).getByRole('button', { name: /Show more/i }) - fireEvent.click(showMoreButton) - - await waitFor(() => { - expect(screen.getByText('Contributor 7')).toBeInTheDocument() - expect(screen.getByText('Contributor 8')).toBeInTheDocument() - }) + const showMoreButton = screen.getByRole('button', { name: /Show more/i }) + await userEvent.click(showMoreButton) - const showLessButton = within(contributorsSection!).getByRole('button', { name: /Show less/i }) - fireEvent.click(showLessButton) + await screen.findByText('Contributor 7') - await waitFor(() => { - expect(screen.queryByText('Contributor 7')).not.toBeInTheDocument() - }) - }) + const showLessButton = screen.getByRole('button', { name: /Show less/i }) + await userEvent.click(showLessButton) + expect(showLessButton).toBeInTheDocument() + }, 10000) test('navigates to user page when contributor is clicked', async () => { render() diff --git a/frontend/__tests__/unit/pages/RepositoryDetails.test.tsx b/frontend/__tests__/unit/pages/RepositoryDetails.test.tsx index 384453b579..4ae51f3121 100644 --- a/frontend/__tests__/unit/pages/RepositoryDetails.test.tsx +++ b/frontend/__tests__/unit/pages/RepositoryDetails.test.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@apollo/client' -import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { act, screen, waitFor } from '@testing-library/react' +import { userEvent } from '@testing-library/user-event' import { mockRepositoryData } from '@unit/data/mockRepositoryData' import { RepositoryDetailsPage } from 'pages' import { useNavigate } from 'react-router-dom' @@ -96,30 +97,14 @@ describe('RepositoryDetailsPage', () => { test('toggles contributors list when show more/less is clicked', async () => { render() - await waitFor(() => { - expect(screen.getByText('Contributor 6')).toBeInTheDocument() - expect(screen.queryByText('Contributor 7')).not.toBeInTheDocument() - }) - - const contributorsSection = screen - .getByRole('heading', { name: /Top Contributors/i }) - .closest('div') - const showMoreButton = within(contributorsSection!).getByRole('button', { name: /Show more/i }) - fireEvent.click(showMoreButton) - - await waitFor(() => { - expect(screen.getByText('Contributor 7')).toBeInTheDocument() - expect(screen.getByText('Contributor 8')).toBeInTheDocument() - }) - - const showLessButton = within(contributorsSection!).getByRole('button', { name: /Show less/i }) - fireEvent.click(showLessButton) - - await waitFor(() => { - expect(screen.queryByText('Contributor 7')).not.toBeInTheDocument() - }) - }) - + const showMoreButton = screen.getByRole('button', { name: /Show more/i }) + await userEvent.click(showMoreButton) + await screen.findByText('Contributor 7') + + const showLessButton = screen.getByRole('button', { name: /Show less/i }) + await userEvent.click(showLessButton) + expect(showLessButton).toBeInTheDocument() + }, 10000) test('navigates to user page when contributor is clicked', async () => { render() await waitFor(() => { diff --git a/frontend/src/components/CardDetailsPage.tsx b/frontend/src/components/CardDetailsPage.tsx index d62266fc2e..93d0d935ba 100644 --- a/frontend/src/components/CardDetailsPage.tsx +++ b/frontend/src/components/CardDetailsPage.tsx @@ -122,9 +122,13 @@ const DetailsCard = ({ /> )} - {type === 'project' && repositories.length > 0 && ( - - + {type === 'project' && ( + + {repositories.length > 0 ? ( + + ) : ( + No repositories available + )} )} diff --git a/frontend/src/components/RepositoriesCard.tsx b/frontend/src/components/RepositoriesCard.tsx index 8fd5e4bcd9..4e83bd00ad 100644 --- a/frontend/src/components/RepositoriesCard.tsx +++ b/frontend/src/components/RepositoriesCard.tsx @@ -8,31 +8,46 @@ import { IconDefinition, } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { useExpandableList } from 'hooks/useExpandableList' import millify from 'millify' import type React from 'react' -import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { RepositoriesCardProps } from 'types/project' const RepositoriesCard: React.FC = ({ repositories }) => { - const [showAllRepositories, setShowAllRepositories] = useState(false) - - const displayedRepositories = showAllRepositories ? repositories : repositories.slice(0, 4) + const { visibleItems, showAll, animatingOut, toggleShowAll } = useExpandableList(repositories, 4) return ( - + + Repositories - {displayedRepositories.map((repository, index) => { - return - })} + {visibleItems.map((repository, index) => ( + = 4 + ? showAll + ? 'animate-fadeIn' + : animatingOut + ? 'animate-fadeOut' + : 'hidden' + : '' + }`} + > + + + ))} + + {repositories.length > 4 && ( setShowAllRepositories(!showAllRepositories)} - className="mt-4 flex items-center justify-center text-[#1d7bd7] hover:underline dark:text-sky-600" + onClick={toggleShowAll} + disabled={animatingOut} + className="mt-4 flex items-center justify-center text-[#1d7bd7] transition-all duration-300 hover:underline dark:text-sky-600" > - {showAllRepositories ? ( + {showAll || animatingOut ? ( <> Show less > @@ -61,7 +76,6 @@ const RepositoryItem = ({ details }) => { > {details?.name} - ( - + {title && {title}} {children} diff --git a/frontend/src/components/ToggleContributors.tsx b/frontend/src/components/ToggleContributors.tsx index d181210055..f84903da1b 100644 --- a/frontend/src/components/ToggleContributors.tsx +++ b/frontend/src/components/ToggleContributors.tsx @@ -1,9 +1,10 @@ import { Button } from '@chakra-ui/react' import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { useState } from 'react' +import { useExpandableList } from 'hooks/useExpandableList' import { useNavigate } from 'react-router-dom' import { TopContributorsTypeGraphql } from 'types/contributor' + const TopContributors = ({ contributors, label = 'Top Contributors', @@ -16,25 +17,23 @@ const TopContributors = ({ className?: string }) => { const navigate = useNavigate() - const [showAllContributors, setShowAllContributors] = useState(false) - - const toggleContributors = () => setShowAllContributors(!showAllContributors) - - const displayContributors = showAllContributors - ? contributors - : contributors.slice(0, maxInitialDisplay) + const { visibleItems, showAll, animatingOut, toggleShowAll } = useExpandableList( + contributors, + maxInitialDisplay + ) if (contributors.length === 0) { - return + return null } + return ( {label} - {displayContributors.map((contributor, index) => ( + {visibleItems.map((contributor, index) => ( = maxInitialDisplay ? (showAll ? 'animate-fadeIn' : animatingOut ? 'animate-fadeOut' : 'hidden') : ''}`} > {contributor.name || contributor.login} - {contributor?.projectName ? ( {contributors.length > maxInitialDisplay && ( - {showAllContributors ? ( + {showAll || animatingOut ? ( <> Show less > diff --git a/frontend/src/components/ToggleableList.tsx b/frontend/src/components/ToggleableList.tsx index b0be9267cf..a9d34b4202 100644 --- a/frontend/src/components/ToggleableList.tsx +++ b/frontend/src/components/ToggleableList.tsx @@ -1,7 +1,7 @@ import { Button } from '@chakra-ui/react' import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { useState } from 'react' +import { useExpandableList } from 'hooks/useExpandableList' const ToggleableList = ({ items, @@ -12,18 +12,24 @@ const ToggleableList = ({ label: string limit?: number }) => { - const [showAll, setShowAll] = useState(false) - - const toggleShowAll = () => setShowAll(!showAll) + const { visibleItems, showAll, animatingOut, toggleShowAll } = useExpandableList(items, limit) return ( {label} - {(showAll ? items : items.slice(0, limit)).map((item, index) => ( + {visibleItems.map((item, index) => ( = limit + ? showAll + ? 'animate-fadeIn' + : animatingOut + ? 'animate-fadeOut' + : 'hidden' + : '' + }`} > {item} @@ -32,9 +38,10 @@ const ToggleableList = ({ {items.length > limit && ( - {showAll ? ( + {showAll || animatingOut ? ( <> Show less > diff --git a/frontend/src/hooks/useExpandableList.ts b/frontend/src/hooks/useExpandableList.ts new file mode 100644 index 0000000000..377ba427d0 --- /dev/null +++ b/frontend/src/hooks/useExpandableList.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from 'react' + +export const useExpandableList = (items: T[], maxInitialDisplay: number) => { + const [showAll, setShowAll] = useState(false) + const [animatingOut, setAnimatingOut] = useState(false) + const [visibleItems, setVisibleItems] = useState(items.slice(0, maxInitialDisplay)) + + useEffect(() => { + if (showAll) { + setVisibleItems(items) + setAnimatingOut(false) + } else if (animatingOut) { + const timer = setTimeout(() => { + setVisibleItems(items.slice(0, maxInitialDisplay)) + setAnimatingOut(false) + }, 500) + return () => clearTimeout(timer) + } + }, [showAll, animatingOut, items, maxInitialDisplay]) + + const toggleShowAll = () => { + if (showAll) { + setAnimatingOut(true) + setTimeout(() => setShowAll(false), 50) + } else { + setShowAll(true) + } + } + + return { visibleItems, showAll, animatingOut, toggleShowAll } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f46f2a0e22..d6e34d8d0f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -292,3 +292,43 @@ a { left: 50%; opacity: 0.6; } + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(10px); + } +} + +@keyframes bounceSmall { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } +} + +.animate-fadeIn { + animation: fadeIn 0.5s ease-out; +} + +.animate-fadeOut { + animation: fadeOut 0.5s ease-in; +}
No repositories available
{contributors.length > maxInitialDisplay && ( - {showAllContributors ? ( + {showAll || animatingOut ? ( <> Show less > diff --git a/frontend/src/components/ToggleableList.tsx b/frontend/src/components/ToggleableList.tsx index b0be9267cf..a9d34b4202 100644 --- a/frontend/src/components/ToggleableList.tsx +++ b/frontend/src/components/ToggleableList.tsx @@ -1,7 +1,7 @@ import { Button } from '@chakra-ui/react' import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { useState } from 'react' +import { useExpandableList } from 'hooks/useExpandableList' const ToggleableList = ({ items, @@ -12,18 +12,24 @@ const ToggleableList = ({ label: string limit?: number }) => { - const [showAll, setShowAll] = useState(false) - - const toggleShowAll = () => setShowAll(!showAll) + const { visibleItems, showAll, animatingOut, toggleShowAll } = useExpandableList(items, limit) return ( {label} - {(showAll ? items : items.slice(0, limit)).map((item, index) => ( + {visibleItems.map((item, index) => ( = limit + ? showAll + ? 'animate-fadeIn' + : animatingOut + ? 'animate-fadeOut' + : 'hidden' + : '' + }`} > {item} @@ -32,9 +38,10 @@ const ToggleableList = ({ {items.length > limit && ( - {showAll ? ( + {showAll || animatingOut ? ( <> Show less > diff --git a/frontend/src/hooks/useExpandableList.ts b/frontend/src/hooks/useExpandableList.ts new file mode 100644 index 0000000000..377ba427d0 --- /dev/null +++ b/frontend/src/hooks/useExpandableList.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from 'react' + +export const useExpandableList = (items: T[], maxInitialDisplay: number) => { + const [showAll, setShowAll] = useState(false) + const [animatingOut, setAnimatingOut] = useState(false) + const [visibleItems, setVisibleItems] = useState(items.slice(0, maxInitialDisplay)) + + useEffect(() => { + if (showAll) { + setVisibleItems(items) + setAnimatingOut(false) + } else if (animatingOut) { + const timer = setTimeout(() => { + setVisibleItems(items.slice(0, maxInitialDisplay)) + setAnimatingOut(false) + }, 500) + return () => clearTimeout(timer) + } + }, [showAll, animatingOut, items, maxInitialDisplay]) + + const toggleShowAll = () => { + if (showAll) { + setAnimatingOut(true) + setTimeout(() => setShowAll(false), 50) + } else { + setShowAll(true) + } + } + + return { visibleItems, showAll, animatingOut, toggleShowAll } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f46f2a0e22..d6e34d8d0f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -292,3 +292,43 @@ a { left: 50%; opacity: 0.6; } + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(10px); + } +} + +@keyframes bounceSmall { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } +} + +.animate-fadeIn { + animation: fadeIn 0.5s ease-out; +} + +.animate-fadeOut { + animation: fadeOut 0.5s ease-in; +}