diff --git a/.all-contributorsrc b/.all-contributorsrc index f13836e2b8..b420a56a0e 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -607,6 +607,15 @@ "contributions": [ "code" ] + }, + { + "login": "dariusmihut", + "name": "dariusmihut", + "avatar_url": "https://avatars.githubusercontent.com/u/7417010?v=4", + "profile": "https://github.com/dariusmihut", + "contributions": [ + "code" + ] } ], "projectName": "community-platform", diff --git a/README.md b/README.md index eb2a173f79..ce4d94ae88 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ Thanks go to these wonderful people ([emoji key](https://allcontributors.org/doc viracoding
viracoding

💻 Gashmoh
Gashmoh

💻 + dariusmihut
dariusmihut

💻 diff --git a/package.json b/package.json index 2025d79fb4..eb2ca2e449 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "format:style": "prettier --write '**/*.{md,json,js,tsx,ts}'", "serve": "npx serve -s build", "test": "yarn workspace oa-cypress start", - "test:components": "yarn workspace oa-components test", + "test:components": "yarn workspace oa-components test-ci", "test:unit": "yarn build:themes && yarn build:components && env-cmd -e cra craco test --env=jsdom --runInBand --logHeapUsage --coverage --reporters=default --reporters=jest-junit", "test:madge": "npx madge --circular --extensions ts,tsx ./ --exclude src/stores", "storybook": "yarn workspace oa-components start", diff --git a/packages/components/package.json b/packages/components/package.json index fba42d713f..e849dd88a2 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -14,7 +14,8 @@ "dev": "tsc --watch", "lint": "eslint . --ext .js,.jsx,.ts,.tsx src --color", "new-component": "ts-node scripts/newComponent.ts", - "test": "vitest --coverage" + "test": "vitest", + "test-ci": "vitest --coverage" }, "dependencies": { "@emotion/react": "^11.10.6", diff --git a/packages/components/src/CommentItem/CommentItem.tsx b/packages/components/src/CommentItem/CommentItem.tsx index 609af76d76..96547b2f74 100644 --- a/packages/components/src/CommentItem/CommentItem.tsx +++ b/packages/components/src/CommentItem/CommentItem.tsx @@ -19,8 +19,9 @@ export interface CommentItemProps { _id: string _edited?: string _created?: string - handleEdit?: (commentId: string, newCommentText: string) => void + handleCommentReply?: (commentId: string | null) => void handleDelete?: (commentId: string) => Promise + handleEdit?: (commentId: string, newCommentText: string) => void handleEditRequest?: (commentId: string) => Promise } @@ -49,6 +50,7 @@ export const CommentItem = (props: CommentItemProps) => { handleDelete, handleEdit, isEditable, + handleCommentReply, } = props const date = formatDate(_edited || _created) @@ -103,37 +105,52 @@ export const CommentItem = (props: CommentItemProps) => { {date} - {isEditable && ( - + + {isEditable && ( + <> + + + + )} + {typeof handleCommentReply === 'function' ? ( - - - )} + ) : null} + = () => ( ) export const WithNestedComments: StoryFn = () => { - // TODO: This is a temporary solution to get nested comments to pass type check - const comments: any = [ + const comments = [ fakeComment({ replies: [fakeComment(), fakeComment()], }), @@ -49,6 +48,56 @@ export const WithNestedComments: StoryFn = () => { ) } +export const WithNestedCommentsAndReplies: StoryFn = () => { + const comments = [ + fakeComment({ + replies: [fakeComment(), fakeComment()], + }), + fakeComment(), + fakeComment(), + ] + + return ( + {}} + handleDelete={() => Promise.resolve()} + handleEditRequest={() => Promise.resolve()} + handleEdit={() => Promise.resolve()} + onMoreComments={() => Promise.resolve()} + /> + ) +} + +export const WithNestedCommentsAndRepliesMaxDepthTwo: StoryFn< + typeof CommentList +> = () => { + const comments = [ + fakeComment({ + replies: [ + fakeComment({ + replies: [fakeComment()], + }), + ], + }), + ] + + return ( + {}} + handleDelete={() => Promise.resolve()} + handleEditRequest={() => Promise.resolve()} + handleEdit={() => Promise.resolve()} + onMoreComments={() => Promise.resolve()} + /> + ) +} + const highlightedCommentList = createFakeComments(20, { isEditable: false }) export const Highlighted: StoryFn = () => ( diff --git a/packages/components/src/CommentList/CommentList.test.tsx b/packages/components/src/CommentList/CommentList.test.tsx index 4ceb165baf..41f21a3433 100644 --- a/packages/components/src/CommentList/CommentList.test.tsx +++ b/packages/components/src/CommentList/CommentList.test.tsx @@ -86,4 +86,33 @@ describe('CommentList', () => { expect(screen.getAllByTestId('CommentList: item')).toHaveLength(5) }) + + it('does not show reply once max depth is reached', () => { + const mockComments = [ + fakeComment({ + replies: [ + fakeComment({ + replies: [fakeComment()], + }), + ], + }), + ] + + const screen = render( + <>} + setCommentBeingRepliedTo={() => {}} + handleEdit={mockHandleEdit} + handleEditRequest={mockHandleEditRequest} + handleDelete={mockHandleDelete} + onMoreComments={mockOnMoreComments} + />, + ) + + expect(screen.getAllByText('reply')).toHaveLength(2) + }) }) diff --git a/packages/components/src/CommentList/CommentList.tsx b/packages/components/src/CommentList/CommentList.tsx index 5010de2d86..68f8041416 100644 --- a/packages/components/src/CommentList/CommentList.tsx +++ b/packages/components/src/CommentList/CommentList.tsx @@ -10,12 +10,17 @@ export type CommentWithReplies = Comment & { replies?: Comment[] } const MAX_COMMENTS = 5 export interface IProps { + supportReplies?: boolean comments: CommentWithReplies[] handleEdit: (_id: string, comment: string) => Promise handleEditRequest: () => Promise handleDelete: (_id: string) => Promise highlightedCommentId?: string onMoreComments: () => void + setCommentBeingRepliedTo?: (commentId: string | null) => void + replyForm?: (commentId: string) => JSX.Element + currentDepth?: number + maxDepth?: number } export const CommentList = (props: IProps) => { @@ -26,7 +31,15 @@ export const CommentList = (props: IProps) => { highlightedCommentId, handleEdit, onMoreComments, + replyForm, + setCommentBeingRepliedTo, + supportReplies = false, + maxDepth = 9999, + currentDepth = 0, } = props + + const hasRepliesEnabled = supportReplies && currentDepth < maxDepth + const [moreComments, setMoreComments] = useState(1) const shownComments = moreComments * MAX_COMMENTS @@ -44,6 +57,11 @@ export const CommentList = (props: IProps) => { }, 0) } + const handleCommentReply = + hasRepliesEnabled && setCommentBeingRepliedTo + ? setCommentBeingRepliedTo + : undefined + useEffect(() => { if (!highlightedCommentId) return @@ -81,20 +99,27 @@ export const CommentList = (props: IProps) => { > + {replyForm && replyForm(comment._id)} {comment.replies ? ( ) : null} diff --git a/packages/components/src/CreateComment/CreateComment.tsx b/packages/components/src/CreateComment/CreateComment.tsx index 87bb48c349..29a5710280 100644 --- a/packages/components/src/CreateComment/CreateComment.tsx +++ b/packages/components/src/CreateComment/CreateComment.tsx @@ -11,12 +11,14 @@ export interface Props { comment: string placeholder?: string userProfileType?: string + buttonLabel?: string } export const CreateComment = (props: Props) => { const { comment, isLoggedIn, maxLength, onSubmit } = props const userProfileType = props.userProfileType || 'member' const placeholder = props.placeholder || 'Leave your questions or feedback...' + const buttonLabel = props.buttonLabel ?? 'Leave a comment' const onChange = (newValue: string) => { props.onChange && props?.onChange(newValue) @@ -107,7 +109,7 @@ export const CreateComment = (props: Props) => { onClick={() => onSubmit(comment)} sx={{ marginTop: 3 }} > - Leave a comment + {buttonLabel} diff --git a/packages/components/src/DiscussionContainer/DiscussionContainer.stories.tsx b/packages/components/src/DiscussionContainer/DiscussionContainer.stories.tsx index 14eb153809..29fcf4efd8 100644 --- a/packages/components/src/DiscussionContainer/DiscussionContainer.stories.tsx +++ b/packages/components/src/DiscussionContainer/DiscussionContainer.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react' import { createFakeComments } from '../utils' import { DiscussionContainer } from './DiscussionContainer' -import type { Meta, StoryFn } from '@storybook/react' +import type { Meta, StoryObj } from '@storybook/react' export default { title: 'Components/DiscussionContainer', @@ -13,74 +13,115 @@ export default { const fakeComments = createFakeComments(3) const expandableFakeComments = createFakeComments(15) -export const Default: StoryFn = () => { - return ( - Promise.resolve()} - handleEditRequest={() => Promise.resolve()} - handleEdit={() => Promise.resolve()} - maxLength={1000} - comment={''} - onChange={() => null} - onMoreComments={() => null} - onSubmit={() => null} - isLoggedIn={false} - /> - ) +type Story = StoryObj & { + render: () => JSX.Element } -export const NoComments: StoryFn = () => { - return ( - Promise.resolve()} - handleEditRequest={() => Promise.resolve()} - handleEdit={() => Promise.resolve()} - maxLength={1000} - comment={''} - onChange={() => null} - onMoreComments={() => null} - onSubmit={() => null} - isLoggedIn={false} - /> - ) +export const Default: Story = { + render: () => { + return ( + Promise.resolve()} + handleEditRequest={() => Promise.resolve()} + handleEdit={() => Promise.resolve()} + maxLength={1000} + comment={''} + onChange={() => null} + onMoreComments={() => null} + onSubmit={() => null} + isLoggedIn={false} + /> + ) + }, } -export const LoggedIn: StoryFn = () => { - const [comment, setComment] = useState('') +export const NoComments: Story = { + render: () => { + return ( + Promise.resolve()} + handleEditRequest={() => Promise.resolve()} + handleEdit={() => Promise.resolve()} + maxLength={1000} + comment={''} + onChange={() => null} + onMoreComments={() => null} + onSubmit={() => null} + isLoggedIn={false} + /> + ) + }, +} + +export const LoggedIn: Story = { + render: () => { + const [comment, setComment] = useState('') - return ( - Promise.resolve()} - handleEditRequest={() => Promise.resolve()} - handleEdit={() => Promise.resolve()} - maxLength={1000} - comment={comment} - onChange={setComment} - onMoreComments={() => null} - onSubmit={() => null} - isLoggedIn={true} - /> - ) + return ( + Promise.resolve()} + handleEditRequest={() => Promise.resolve()} + handleEdit={() => Promise.resolve()} + maxLength={1000} + comment={comment} + onChange={setComment} + onMoreComments={() => null} + onSubmit={() => null} + isLoggedIn={true} + /> + ) + }, } -export const Expandable: StoryFn = () => { - const [comment, setComment] = useState('') +export const Expandable: Story = { + render: () => { + const [comment, setComment] = useState('') + + return ( + Promise.resolve()} + handleEditRequest={() => Promise.resolve()} + handleEdit={() => Promise.resolve()} + maxLength={1000} + comment={comment} + onChange={setComment} + onMoreComments={() => null} + onSubmit={() => null} + isLoggedIn={true} + /> + ) + }, +} + +export const WithReplies: Story = { + render: () => { + const [comment, setComment] = useState('') + + const fakeComments = createFakeComments(3) + + fakeComments[0].replies = createFakeComments(2) - return ( - Promise.resolve()} - handleEditRequest={() => Promise.resolve()} - handleEdit={() => Promise.resolve()} - maxLength={1000} - comment={comment} - onChange={setComment} - onMoreComments={() => null} - onSubmit={() => null} - isLoggedIn={true} - /> - ) + return ( + Promise.resolve()} + handleEditRequest={() => Promise.resolve()} + handleEdit={() => Promise.resolve()} + maxLength={1000} + comment={comment} + onChange={setComment} + onMoreComments={() => null} + onSubmit={() => null} + isLoggedIn={true} + onSubmitReply={async (commentId, comment) => + alert(`reply to commentId: ${commentId} with comment: ${comment}`) + } + /> + ) + }, } diff --git a/packages/components/src/DiscussionContainer/DiscussionContainer.test.tsx b/packages/components/src/DiscussionContainer/DiscussionContainer.test.tsx index 255b7cd355..098746c4ed 100644 --- a/packages/components/src/DiscussionContainer/DiscussionContainer.test.tsx +++ b/packages/components/src/DiscussionContainer/DiscussionContainer.test.tsx @@ -1,13 +1,104 @@ -import { render } from '../tests/utils' -import { Default } from './DiscussionContainer.stories' +import { act } from 'react-dom/test-utils' +import { fireEvent } from '@testing-library/react' -import type { IProps } from './DiscussionContainer' +import { render } from '../tests/utils' +import { Default, WithReplies } from './DiscussionContainer.stories' describe('DiscussionContainer', () => { it('validates the component behaviour', () => { - const { getByText } = render() + const { getByText } = render() expect(getByText('3 Comments')).toBeInTheDocument() expect(getByText('Leave a comment')).toBeInTheDocument() + + expect(() => getByText('reply')).toThrow() + }) + + it('allows replying to a comment', async () => { + const screen = render() + + const replyButton = screen.getAllByText('reply')[0] + expect(replyButton).toBeInTheDocument() + + // Show reply form + await act(async () => { + await fireEvent.click(replyButton) + expect(screen.getAllByText('Send your reply')).toHaveLength(1) + }) + + // Hide reply form + await act(async () => { + await fireEvent.click(replyButton) + expect(() => { + screen.getAllByText('Send your reply') + }).toThrow() + }) + + const SecondReplyButton = screen.getAllByText('reply')[2] + expect(SecondReplyButton).toBeInTheDocument() + + // Show reply form + await act(async () => { + await fireEvent.click(SecondReplyButton) + expect(screen.getAllByText('Send your reply')).toHaveLength(1) + }) + + // Hide reply form + await act(async () => { + await fireEvent.click(SecondReplyButton) + expect(() => { + screen.getAllByText('Send your reply') + }).toThrow() + }) + }) + + it('does not show the reply form more than once', async () => { + const screen = render() + + const replyButton = screen.getAllByText('reply')[0] + expect(replyButton).toBeInTheDocument() + + // Show first reply form + await act(async () => { + await fireEvent.click(replyButton) + expect(screen.getAllByText('Send your reply')).toHaveLength(1) + }) + + const SecondReplyButton = screen.getAllByText('reply')[2] + expect(SecondReplyButton).toBeInTheDocument() + + // Show second reply form + await act(async () => { + await fireEvent.click(SecondReplyButton) + expect(screen.getAllByText('Send your reply')).toHaveLength(1) + }) }) + + it.todo('allows replying to a comment', async () => { + // const handleSubmitReply: any = vi.fn() + // const screen = render( + // , + // ) + // const replyButton = screen.getAllByText('reply')[0] + // expect(replyButton).toBeInTheDocument() + // // Show reply form + // await act(async () => { + // await fireEvent.click(replyButton) + // expect(screen.getAllByText('Send your reply')).toHaveLength(1) + // const textarea = screen.getAllByPlaceholderText( + // 'Leave your questions or feedback...', + // )[0] + // await fireEvent.change(textarea, { target: { value: 'New comment' } }) + // await fireEvent.click(screen.getByText('Send your reply')) + // expect(handleSubmitReply).toHaveBeenCalled() + // }) + }) + + it.todo( + 'adding a reply to a comment does not affect the primary create comment form', + async () => {}, + ) }) diff --git a/packages/components/src/DiscussionContainer/DiscussionContainer.tsx b/packages/components/src/DiscussionContainer/DiscussionContainer.tsx index 26df8bb61a..dc3c54f1d4 100644 --- a/packages/components/src/DiscussionContainer/DiscussionContainer.tsx +++ b/packages/components/src/DiscussionContainer/DiscussionContainer.tsx @@ -1,6 +1,8 @@ -import { Flex } from 'theme-ui' +import { useMemo, useState } from 'react' +import { Box, Flex } from 'theme-ui' import { CommentList, CreateComment, DiscussionTitle } from '../' +import { transformToTree } from './transformToStructuredComments' import type { CommentItemProps as Comment } from '../CommentItem/CommentItem' @@ -15,7 +17,9 @@ export interface IProps { onChange: (comment: string) => void onMoreComments: () => void onSubmit: (comment: string) => void + onSubmitReply?: (_id: string, comment: string) => Promise isLoggedIn: boolean + supportReplies?: boolean } export const DiscussionContainer = (props: IProps) => { @@ -25,25 +29,78 @@ export const DiscussionContainer = (props: IProps) => { handleDelete, handleEdit, handleEditRequest, + onSubmitReply, highlightedCommentId, maxLength, onChange, onMoreComments, onSubmit, isLoggedIn, + supportReplies = false, } = props + const [commentBeingRepliedTo, setCommentBeingRepliedTo] = useState< + null | string + >(null) + const structuredComments = useMemo( + () => transformToTree(comments), + [comments], + ) + + const reployForm = (commentId: string) => { + if (commentId !== commentBeingRepliedTo) { + return <> + } + + return ( + + { + if (commentId && onSubmitReply) { + onSubmitReply(commentId, comment) + } + setCommentBeingRepliedTo(null) + }} + buttonLabel="Send your reply" + isLoggedIn={isLoggedIn} + /> + + ) + } + + const handleSetCommentBeingRepliedTo = (commentId: string | null): void => { + if (commentId === commentBeingRepliedTo) { + return setCommentBeingRepliedTo(null) + } + setCommentBeingRepliedTo(commentId) + } + return ( <> { + const rootComments: CommentWithRepliesParent[] = [] + const commentsById: any = {} + + // Traverse the comments and map them to their parent IDs + for (const comment of comments) { + commentsById[comment._id] = comment + + if (comment.parentCommentId) { + const parentComment = commentsById[comment.parentCommentId] + if (!parentComment.replies) { + parentComment.replies = [] + } + + parentComment.replies.push(comment) + } + } + + // Extract the root comments (those with no parent IDs) + for (const comment of comments) { + if (!comment.parentCommentId) { + rootComments.push(comment) + } + } + + return rootComments +} diff --git a/packages/components/src/ImageGallery/ImageGallery.stories.tsx b/packages/components/src/ImageGallery/ImageGallery.stories.tsx index 26810871d0..4ae11dfe2a 100644 --- a/packages/components/src/ImageGallery/ImageGallery.stories.tsx +++ b/packages/components/src/ImageGallery/ImageGallery.stories.tsx @@ -66,3 +66,16 @@ export const ShowNextPrevButtons: StoryFn = ( /> ) } + +export const DoNotShowNextPrevButtons: StoryFn = ( + props: Omit, +) => { + return ( + + ) +} diff --git a/packages/components/src/ImageGallery/ImageGallery.test.tsx b/packages/components/src/ImageGallery/ImageGallery.test.tsx index 22af2f6527..c7e3df1e9f 100644 --- a/packages/components/src/ImageGallery/ImageGallery.test.tsx +++ b/packages/components/src/ImageGallery/ImageGallery.test.tsx @@ -5,6 +5,7 @@ import { render } from '../tests/utils' import { ImageGallery } from './ImageGallery' import { Default, + DoNotShowNextPrevButtons, NoThumbnails, ShowNextPrevButtons, testImages, @@ -175,4 +176,18 @@ describe('ImageGallery', () => { expect(nextBtn).toBeInTheDocument() expect(previousBtn).toBeInTheDocument() }) + + it('does not support show next/previous buttons because only one image', async () => { + const { queryByRole } = render( + , + ) + + const nextBtn = queryByRole('button', { name: 'Next image' }) + const previousBtn = queryByRole('button', { name: 'Previous image' }) + + expect(nextBtn).not.toBeInTheDocument() + expect(previousBtn).not.toBeInTheDocument() + }) }) diff --git a/packages/components/src/ImageGallery/ImageGallery.tsx b/packages/components/src/ImageGallery/ImageGallery.tsx index 5a0434f620..b878b7f538 100644 --- a/packages/components/src/ImageGallery/ImageGallery.tsx +++ b/packages/components/src/ImageGallery/ImageGallery.tsx @@ -127,7 +127,7 @@ export const ImageGallery = (props: ImageGalleryProps) => { const activeImage = images[activeImageIndex] const imageNumber = images.length const showThumbnails = !props.hideThumbnails && images.length >= 1 - const showNextPrevButton = !!props.showNextPrevButton + const showNextPrevButton = !!props.showNextPrevButton && images.length > 1 return activeImage ? ( diff --git a/src/common/transformToUserComments.ts b/src/common/transformToUserComments.ts new file mode 100644 index 0000000000..97061e8031 --- /dev/null +++ b/src/common/transformToUserComments.ts @@ -0,0 +1,10 @@ +import type { IDiscussionComment, IUserPPDB } from 'src/models' + +export const transformToUserComments = ( + comments: IDiscussionComment[], + loggedInUser: IUserPPDB | null | undefined, +) => + comments.map((c) => ({ + ...c, + isEditable: c._creatorId === loggedInUser?._id, + })) diff --git a/src/pages/Question/QuestionComments.tsx b/src/pages/Question/QuestionComments.tsx index a6f0ee30fb..593c7c7b41 100644 --- a/src/pages/Question/QuestionComments.tsx +++ b/src/pages/Question/QuestionComments.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { DiscussionContainer } from 'oa-components' +import { transformToUserComments } from 'src/common/transformToUserComments' import { MAX_COMMENT_LENGTH } from 'src/constants' import { logger } from 'src/logger' import { useDiscussionStore } from 'src/stores/Discussions/discussions.store' @@ -64,6 +65,21 @@ export const QuestionComments = ({ } } + const handleSubmitReply = async (commentId: string, reply) => { + logger.info({ commentId, reply }, 'reply submitted') + if (discussionObject) { + const updatedObj = await store.addComment( + discussionObject, + reply, + commentId, + ) + commentsUpdated && + commentsUpdated( + transformToUserComments(updatedObj?.comments || [], activeUser), + ) + } + } + return ( ) } - -const transformToUserComments = ( - comments: IDiscussionComment[], - loggedInUser: IUserPPDB | null | undefined, -) => - comments.map((c) => ({ - ...c, - isEditable: c._creatorId === loggedInUser?._id, - })) diff --git a/src/pages/Question/QuestionPage.tsx b/src/pages/Question/QuestionPage.tsx index 2f484b4ea7..d04a5e83d0 100644 --- a/src/pages/Question/QuestionPage.tsx +++ b/src/pages/Question/QuestionPage.tsx @@ -6,6 +6,7 @@ import { ModerationStatus, UsefulStatsButton, } from 'oa-components' +import { transformToUserComments } from 'src/common/transformToUserComments' import { logger } from 'src/logger' import { useDiscussionStore } from 'src/stores/Discussions/discussions.store' import { useQuestionStore } from 'src/stores/Question/question.store' @@ -15,7 +16,7 @@ import { Box, Button, Card, Flex, Heading, Text } from 'theme-ui' import { ContentAuthorTimestamp } from '../common/ContentAuthorTimestamp/ContentAuthorTimestamp' import { QuestionComments } from './QuestionComments' -import type { IDiscussionComment, IQuestion, IUserPPDB } from 'src/models' +import type { IDiscussionComment, IQuestion } from 'src/models' export const QuestionPage = () => { const { slug } = useParams() @@ -190,12 +191,3 @@ export const QuestionPage = () => { ) } - -const transformToUserComments = ( - comments: IDiscussionComment[], - loggedInUser: IUserPPDB | null | undefined, -) => - comments.map((c) => ({ - ...c, - isEditable: c._creatorId === loggedInUser?._id, - }))