diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index aefa37013..5c6f47430 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -42,11 +42,9 @@ jobs: - name: Run unit tests run: yarn test - if: ${{ github.event_name == 'pull_request' }} - name: Build project run: yarn build - if: ${{ github.event_name != 'pull_request' }} test-docker-compose: runs-on: ubuntu-latest diff --git a/src/components/media/GalleryImageCard.tsx b/src/components/media/GalleryImageCard.tsx new file mode 100644 index 000000000..f2d448acf --- /dev/null +++ b/src/components/media/GalleryImageCard.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react' +import Image from 'next/image' +import clx from 'classnames' +import Card from '../ui/Card/Card' +import TagList from '../media/TagList' +import { MobileLoader } from '../../js/sirv/util' +import { MediaWithTags } from '../../js/types' +import { getUploadDateSummary } from '../../js/utils' +import { PostHeader } from '../home/Post' + +const MOBILE_IMAGE_MAX_WIDITH = 600 + +interface GalleryImageCardProps { + header?: JSX.Element + mediaWithTags: MediaWithTags + onImageClick?: (event: React.MouseEvent, mediaWithTags: MediaWithTags) => void +} + +/** + * Image card for the gallery page + */ +export const GalleryImageCard = ({ + mediaWithTags, + onImageClick +}: GalleryImageCardProps): JSX.Element => { + const [loaded, setLoaded] = useState(false) + const { mediaUrl, width, height, username } = mediaWithTags + const imageRatio = width / height + return ( + } + image={ +
+ setLoaded(true)} + className='rounded-md' + onClick={(event) => { + if (onImageClick != null) { + console.log('onImageClick') + event.stopPropagation() + event.preventDefault() + onImageClick(event, mediaWithTags) + } + }} + /> +
+ {loaded} +
+
+ } + body={ + <> +
+ + + {getUploadDateSummary(mediaWithTags.uploadTime)} + +
+ + } + /> + ) +} diff --git a/src/components/media/PhotoGalleryModal.tsx b/src/components/media/PhotoGalleryModal.tsx index 75a3c3fc9..56c3004c0 100644 --- a/src/components/media/PhotoGalleryModal.tsx +++ b/src/components/media/PhotoGalleryModal.tsx @@ -1,22 +1,28 @@ import React, { Dispatch, SetStateAction, useState } from 'react' import { userMediaStore } from '../../js/stores/media' -import ResponsiveImage, { ResponsiveImage2 } from './slideshow/ResponsiveImage' +import ResponsiveImage from './slideshow/ResponsiveImage' import { MobileDialog, DialogContent } from '../ui/MobileDialog' import { XMarkIcon } from '@heroicons/react/24/outline' +import { MediaWithTags } from '../../js/types' +import { GalleryImageCard } from './GalleryImageCard' -interface PhotoGalleryModalProps { +export interface PhotoGalleryModalProps { setShowPhotoGalleryModal: Dispatch> } /* - * A reusable popup alert + * A modal component that displays a photo gallery. * @param setShowPhotoGallery A setState action that toggles the photo gallery modal on click. */ const PhotoGalleryModal = ({ setShowPhotoGalleryModal }: PhotoGalleryModalProps): JSX.Element => { - const [ShowImage, setShowImage] = useState('') + // State to keep track of the image details being viewed in full screen. + const [imageProperties, setImageProperties] = useState(null) + + // Fetch the list of photos. const photoList = userMediaStore.use.photoList() + return (
- {photoList.map((element) => { - const { mediaUrl, width, height } = element + {photoList.map((mediaWithTags) => { return (
setShowImage(element.mediaUrl)} - key={element.mediaUrl} - className='overflow-hidden mt-0 mb-2 lg:mb-4 hover:brightness-75 break-inside-avoid-column cursor-pointer break-inside-avoid relative block rounded-md' + data-testid='thumbnail' + onClick={() => setImageProperties(mediaWithTags)} + key={mediaWithTags.mediaUrl} + className='overflow-hidden hover:brightness-75 break-inside-avoid-column cursor-pointer relative block rounded-md mb-4' > - + setImageProperties(mediaWithTags)} />
) })}
+ {/* Full-screen view of selected image */} { - ShowImage !== '' + (imageProperties != null) && imageProperties.mediaUrl !== '' ? (
- +
diff --git a/src/components/media/Tag.tsx b/src/components/media/Tag.tsx index efd63cdf0..46ad30a96 100644 --- a/src/components/media/Tag.tsx +++ b/src/components/media/Tag.tsx @@ -7,7 +7,42 @@ import { EntityTag, TagTargetType } from '../../js/types' import { OnDeleteCallback } from './TagList' import { MouseEventHandler } from 'react' -interface PhotoTagProps { +const stopPropagation: MouseEventHandler = (event) => event.stopPropagation() + +const baseTag = 'badge badge-outline hover:underline max-w-full' +const lgTag = 'badge-lg gap-2' +const mdTag = 'gap-1' +const sizeMap = { md: mdTag, lg: lgTag } + +interface BaseTagProps { + size?: 'md' | 'lg' + url?: string + className?: string + children: React.ReactNode +} + +/** + * Base tag component that can be used to create other tag components. + * Clicking on it leads to the url provided. + * @param {BaseTagProps} props - Properties for the BaseTag component + */ +export const BaseTag: React.FC = ({ size = 'md', url, className, children }) => { + return ( + + ) +} + +interface LocationTagProps { mediaId: string tag: EntityTag onDelete: OnDeleteCallback @@ -16,45 +51,56 @@ interface PhotoTagProps { size?: 'md' | 'lg' } -export default function Tag ({ mediaId, tag, onDelete, size = 'md', showDelete = false, isAuthorized = false }: PhotoTagProps): JSX.Element | null { +/** + * Tag associated with specific locations, such as climbs or areas. + * If isAuthorized is true, a delete button will be displayed. + * @param {LocationTagProps} props - Properties for the LocationTag component + */ +export const LocationTag: React.FC = ({ mediaId, tag, onDelete, size = 'md', showDelete = false, isAuthorized = false }) => { const [url, name] = resolver(tag) if (url == null || name == null) return null const isArea = tag.type === TagTargetType.area return ( - - - {isArea &&
} + + {isArea &&
} +
{name}
+ {isAuthorized && showDelete && + } +
+ ) +} + +interface UsernameTagProps { + username: string + size?: 'md' | 'lg' +} -
{name}
- {isAuthorized && showDelete && - } -
- +/** + * Tag that display a username. + * Clicking on it leads to the user's profile page. + * @param {UsernameTagProps} props - Properties for the UsernameTag component + */ +export const UsernameTag: React.FC = ({ username, size = 'md' }) => { + if (username === undefined || username.trim() === '') return null + return ( + + {username} + ) } -const stopPropagation: MouseEventHandler = (event) => event.stopPropagation() - /** * Extract entity url and name from a tag * @param tag diff --git a/src/components/media/TagList.tsx b/src/components/media/TagList.tsx index 1e6e3a3df..a74b9f561 100644 --- a/src/components/media/TagList.tsx +++ b/src/components/media/TagList.tsx @@ -7,9 +7,9 @@ import { signIn, useSession } from 'next-auth/react' import AddTag from './AddTag' import { DropdownMenu, DropdownContent, DropdownTrigger, DropdownItem, DropdownSeparator } from '../ui/DropdownMenu' import { EntityTag, MediaWithTags } from '../../js/types' -import Tag from './Tag' import useMediaCmd, { RemoveEntityTagProps } from '../../js/hooks/useMediaCmd' import { AddEntityTagProps } from '../../js/graphql/gql/tags' +import { LocationTag, UsernameTag } from './Tag' export type OnAddCallback = (args: AddEntityTagProps) => Promise @@ -22,12 +22,13 @@ interface TagsProps { showDelete?: boolean showActions?: boolean className?: string + showUsernameTag?: boolean } /** * A horizontal tag list. The last item is a CTA. */ -export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthenticated = false, showDelete = false, showActions = true, className = '' }: TagsProps): JSX.Element | null { +export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthenticated = false, showDelete = false, showActions = true, className = '', showUsernameTag = false }: TagsProps): JSX.Element | null { const { addEntityTagCmd, removeEntityTagCmd } = useMediaCmd() const session = useSession() @@ -43,7 +44,7 @@ export default function TagList ({ mediaWithTags, isAuthorized = false, isAuthen await removeEntityTagCmd(args, session.data?.accessToken) } - const { entityTags, id } = mediaWithTags + const { entityTags, id, username } = mediaWithTags return (
+ {showUsernameTag && + username !== undefined && + } {entityTags.map((tag: EntityTag) => - = ({ mediaWithTags, isAu const onDeleteHandler: OnDeleteCallback = async (args) => { await removeEntityTagCmd(args, session.data?.accessToken) } - const { id, entityTags } = mediaWithTags + const { id, entityTags, username } = mediaWithTags return (
@@ -104,9 +108,10 @@ export const MobilePopupTagList: React.FC = ({ mediaWithTags, isAu <> + {username !== undefined && } {entityTags.map(tag => ( - = ({ username, size = 'md' }) => { + if (username === undefined || username.trim() === '') return null + + return ( + + ) +} +const stopPropagation: MouseEventHandler = (event) => event.stopPropagation() + +export default UsernameTag diff --git a/src/components/media/__tests__/PhotoGalleryModal.tsx b/src/components/media/__tests__/PhotoGalleryModal.tsx new file mode 100644 index 000000000..fbc5a2ce8 --- /dev/null +++ b/src/components/media/__tests__/PhotoGalleryModal.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { render } from '@testing-library/react' +import { PhotoGalleryModalProps } from '../PhotoGalleryModal' + +describe('', () => { + // Mock the userMediaStore + jest.mock('../../../js/stores/media', () => ({ + userMediaStore: { + use: { + photoList: jest.fn(() => [ + { + mediaUrl: 'test.jpg', + width: 200, + height: 200, + entityTags: [], + uploadTime: 1667766325921 + } + ]) + } + } + })) + + jest.mock('../TagList', () => ({ + __esModule: true, + default: jest.fn(() =>
tag list
) + })) + + jest.mock('../AddTag', () => ({ + __esModule: true, + default: jest.fn(() =>
add tag
) + })) + + let PhotoGalleryModal: React.FC + + beforeAll(async () => { + const module = await import('../PhotoGalleryModal') + PhotoGalleryModal = module.default + }) + + test('renders without crashing', () => { + const { getByTestId } = render() + expect(getByTestId('thumbnail')).toBeInTheDocument() + }) +}) diff --git a/src/components/media/__tests__/PhotoMontage.tsx b/src/components/media/__tests__/PhotoMontage.tsx index ee6cea4f8..e32743846 100644 --- a/src/components/media/__tests__/PhotoMontage.tsx +++ b/src/components/media/__tests__/PhotoMontage.tsx @@ -1,22 +1,42 @@ import { render, screen } from '@testing-library/react' -import PhotoMontage from '../PhotoMontage' +import { PhotoMontageProps } from '../PhotoMontage' import { mediaList } from './data' -test('PhotoMontage can render 1 photo', async () => { - render() - const elements: HTMLImageElement[] = await screen.findAllByRole('img') - expect(elements.length).toBe(1) - expect(elements[0].src).toContain(mediaList[0].mediaUrl) -}) +jest.mock('../AddTag', () => ({ + __esModule: true, + default: jest.fn((props) =>
tag
) +})) -test('PhotoMontage always renders 2 photos when provided with a list of 2 to 4', async () => { - render() - const elements: HTMLImageElement[] = await screen.findAllByRole('img') - expect(elements.length).toBe(2) // should be 2 -}) +jest.mock('../TagList', () => ({ + __esModule: true, + default: jest.fn((props) =>
tag list
) +})) + +let PhotoMontage: React.FC + +describe('MobilePopupTagMenu', () => { + beforeAll(async () => { + // why async import? see https://github.com/facebook/jest/issues/10025#issuecomment-716789840 + const module = await import('../PhotoMontage') + PhotoMontage = module.default + }) + + test('PhotoMontage can render 1 photo', async () => { + render() + const elements: HTMLImageElement[] = await screen.findAllByRole('img') + expect(elements.length).toBe(1) + expect(elements[0].src).toContain(mediaList[0].mediaUrl) + }) + + test('PhotoMontage always renders 2 photos when provided with a list of 2 to 4', async () => { + render() + const elements: HTMLImageElement[] = await screen.findAllByRole('img') + expect(elements.length).toBe(2) // should be 2 + }) -test('PhotoMontage always renders 5 photos when provided with a list > 5', async () => { - render() - const elements: HTMLImageElement[] = await screen.findAllByRole('img') - expect(elements.length).toBe(5) // should be 5 + test('PhotoMontage always renders 5 photos when provided with a list > 5', async () => { + render() + const elements: HTMLImageElement[] = await screen.findAllByRole('img') + expect(elements.length).toBe(5) // should be 5 + }) }) diff --git a/src/components/media/__tests__/Tag.tsx b/src/components/media/__tests__/Tag.tsx index 77472c5f1..3c4f86065 100644 --- a/src/components/media/__tests__/Tag.tsx +++ b/src/components/media/__tests__/Tag.tsx @@ -1,11 +1,13 @@ import { v4 } from 'uuid' import '' -import { render, screen } from '@testing-library/react' +import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import Tag from '../Tag' +import { BaseTag, LocationTag, UsernameTag } from '../Tag' import { EntityTag } from '../../../js/types' +afterEach(cleanup) + const TAG_DATA: EntityTag = { id: v4(), type: 0, @@ -15,30 +17,61 @@ const TAG_DATA: EntityTag = { targetId: v4().toString() } -test.skip('Default tag', () => { - render( - ) +describe('BaseTag', () => { + it('renders without crashing', () => { + render(does it render?) + const baseElement = screen.getByTestId('base-tag') // You'll need to add a data-testid="base-tag" to your BaseTag component + expect(baseElement).toBeInTheDocument() + }) + + it('renders with provided children', () => { + render(Sample Content) + expect(screen.getByText('Sample Content')).toBeInTheDocument() + }) - const aTag = screen.getByRole('link') - expect(aTag).not.toBeNull() - // @ts-expect-error - expect(aTag).toHaveTextContent(TAG_DATA.climbName) - expect(aTag.getAttribute('href')).toEqual('/climbs/' + TAG_DATA.targetId) + it('applies size correctly', () => { + render(Medium Size) + const baseElement = screen.getByText('Medium Size') + expect(baseElement).toHaveClass('gap-1') // This assumes that 'gap-1' is the class applied for medium size. Adjust if needed. - expect(screen.queryByRole('button')).toBeNull() + render(Large Size) + const largeElement = screen.getByText('Large Size') + expect(largeElement).toHaveClass('badge-lg gap-2') // Adjust if needed. + }) }) -test.skip('Tag with permission to delete', async () => { - const user = userEvent.setup() - const onDeleteFn = jest.fn() - render( - ) - - await user.click(screen.getByRole('button')) - expect(onDeleteFn).toHaveBeenCalledTimes(1) +describe('LocationTag', () => { + it('Default tag', () => { + render() + const aTag = screen.getByRole('link') + expect(aTag).toHaveTextContent(TAG_DATA.climbName as string) + expect(aTag.getAttribute('href')).toEqual('/climbs/' + TAG_DATA.targetId) + expect(screen.queryByRole('button')).toBeNull() + }) + + it('Tag with permission to delete', async () => { + const user = userEvent.setup() + const onDeleteFn = jest.fn() + render() + await user.click(screen.getByRole('button')) + expect(onDeleteFn).toHaveBeenCalledTimes(1) + }) +}) + +describe('UsernameTag', () => { + it('renders without crashing', () => { + render() + const linkElement = screen.getByText('testuser') + expect(linkElement).toBeInTheDocument() + }) + + it('does not render if username is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('does not render if username is an empty string', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) }) diff --git a/src/components/media/__tests__/UsernameTag.tsx b/src/components/media/__tests__/UsernameTag.tsx new file mode 100644 index 000000000..1acd6a180 --- /dev/null +++ b/src/components/media/__tests__/UsernameTag.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react' +import UsernameTag from '../UsernameTag' + +describe('UsernameTag', () => { + it('renders without crashing', () => { + render() + const linkElement = screen.getByText('testuser') + expect(linkElement).toBeInTheDocument() + }) + + it('does not render if username is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('does not render if username is an empty string', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) diff --git a/src/components/ui/MobileDialog.tsx b/src/components/ui/MobileDialog.tsx index e6eece3a9..ed33b457d 100644 --- a/src/components/ui/MobileDialog.tsx +++ b/src/components/ui/MobileDialog.tsx @@ -28,7 +28,7 @@ export const DialogContent = React.forwardRef( {children} {/* Use absolute positioning to place the close button on the upper left corner */} - diff --git a/src/js/setupTests.ts b/src/js/setupTests.ts index f2552f497..f66299b97 100644 --- a/src/js/setupTests.ts +++ b/src/js/setupTests.ts @@ -1,3 +1,4 @@ import * as ResizeObserverModule from 'resize-observer-polyfill' import '@testing-library/jest-dom' + (global as any).ResizeObserver = ResizeObserverModule.default