diff --git a/.env b/.env index 4d768878..70a1695c 100644 --- a/.env +++ b/.env @@ -22,5 +22,6 @@ USER_INFO_COOKIE_NAME='' SUPPORT_URL='' LEARNER_FEEDBACK_URL='' STAFF_FEEDBACK_URL='' +ENABLE_PROFILE_IMAGE='' # Fallback in local style files PARAGON_THEME_URLS={} diff --git a/.env.development b/.env.development index d024251a..2bc65aa9 100644 --- a/.env.development +++ b/.env.development @@ -23,5 +23,6 @@ USER_INFO_COOKIE_NAME='edx-user-info' SUPPORT_URL='https://support.edx.org' LEARNER_FEEDBACK_URL='' STAFF_FEEDBACK_URL='' +ENABLE_PROFILE_IMAGE='' # Fallback in local style files PARAGON_THEME_URLS={} diff --git a/.env.test b/.env.test index 84bc122c..a1147f2b 100644 --- a/.env.test +++ b/.env.test @@ -21,3 +21,4 @@ USER_INFO_COOKIE_NAME='edx-user-info' SUPPORT_URL='https://support.edx.org' LEARNER_FEEDBACK_URL='' STAFF_FEEDBACK_URL='' +ENABLE_PROFILE_IMAGE='' diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index 2f03ad6b..7a082074 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -46,7 +46,7 @@ const Comment = ({ const { id, parentId, childCount, abuseFlagged, endorsed, threadId, endorsedAt, endorsedBy, endorsedByLabel, renderedBody, voted, following, voteCount, authorLabel, author, createdAt, lastEdit, rawBody, closed, closedBy, closeReason, - editByLabel, closedByLabel, + editByLabel, closedByLabel, users: postUsers, } = comment; const intl = useIntl(); const hasChildren = childCount > 0; @@ -209,6 +209,7 @@ const Comment = ({ closed={closed} createdAt={createdAt} lastEdit={lastEdit} + postUsers={postUsers} /> {isEditing ? ( { const colorClass = AvatarOutlineAndLabelColors[authorLabel]; const hasAnyAlert = useAlertBannerVisible({ @@ -27,6 +30,10 @@ const CommentHeader = ({ }); const authorAvatar = useSelector(selectAuthorAvatar(author)); + const profileImage = getConfig()?.ENABLE_PROFILE_IMAGE === 'true' + ? Object.values(postUsers ?? {})[0]?.profile?.image + : null; + return (
({ useSelector: jest.fn() })); +jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() })); +jest.mock('../../../data/hooks', () => ({ useAlertBannerVisible: jest.fn() })); + +const defaultProps = { + author: 'test-user', + authorLabel: 'staff', + abuseFlagged: false, + closed: false, + createdAt: '2025-09-23T10:00:00Z', + lastEdit: null, + postUsers: { + 'test-user': { + profile: { image: { hasImage: true, imageUrlSmall: 'http://avatar.test/img.png' } }, + }, + }, +}; + +const renderComponent = ( + props = {}, + ctx = { courseId: 'course-v1:edX+DemoX+Demo_Course', enableInContextSidebar: false }, +) => render( + + + + + + + , +); + +describe('CommentHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + useSelector.mockReturnValue('http://fallback-avatar.png'); + useAlertBannerVisible.mockReturnValue(false); + getConfig.mockReturnValue({ ENABLE_PROFILE_IMAGE: 'true' }); + }); + + it('renders author and avatar with profile image when ENABLE_PROFILE_IMAGE=true', () => { + renderComponent(); + const avatarImg = screen.getByAltText('test-user'); + expect(avatarImg).toHaveAttribute('src', 'http://avatar.test/img.png'); + expect(screen.getByText('test-user')).toBeInTheDocument(); + }); + + it('uses redux avatar if profile image is disabled by config', () => { + getConfig.mockReturnValue({ ENABLE_PROFILE_IMAGE: 'false' }); + const { container } = renderComponent(); + const avatar = container.querySelector('.outline-staff, .outline-anonymous'); + expect(avatar).toBeInTheDocument(); + }); + + it('applies anonymous class if no color class is found', () => { + const { container } = renderComponent({ authorLabel: null }); + expect(container.querySelector('.outline-anonymous')).toBeInTheDocument(); + }); + + it('adds margin-top if alert banner is visible', () => { + useAlertBannerVisible.mockReturnValue(true); + const { container } = renderComponent(); + expect(container.firstChild).toHaveClass('mt-2'); + }); +}); diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 29f38459..dd51d144 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -33,7 +33,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { const { topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName, closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel, - closedByLabel, + closedByLabel, users: postUsers, } = useSelector(selectThread(postId)); const intl = useIntl(); const location = useLocation(); @@ -187,6 +187,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { lastEdit={lastEdit} postType={postType} title={title} + postUsers={postUsers} />
diff --git a/src/discussions/posts/post/PostHeader.jsx b/src/discussions/posts/post/PostHeader.jsx index a3b000c2..5f40985d 100644 --- a/src/discussions/posts/post/PostHeader.jsx +++ b/src/discussions/posts/post/PostHeader.jsx @@ -6,6 +6,7 @@ import { Question } from '@openedx/paragon/icons'; import classNames from 'classnames'; import { useSelector } from 'react-redux'; +import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants'; @@ -15,7 +16,7 @@ import { selectAuthorAvatar } from '../data/selectors'; import messages from './messages'; export const PostAvatar = React.memo(({ - author, postType, authorLabel, fromPostLink, read, + author, postType, authorLabel, fromPostLink, read, postUsers, }) => { const outlineColor = AvatarOutlineAndLabelColors[authorLabel]; const authorAvatars = useSelector(selectAuthorAvatar(author)); @@ -40,6 +41,10 @@ export const PostAvatar = React.memo(({ return spacing; }, [postType]); + const profileImage = getConfig()?.ENABLE_PROFILE_IMAGE === 'true' + ? Object.values(postUsers ?? {})[0]?.profile?.image + : null; + return (
{postType === ThreadType.QUESTION && ( @@ -62,8 +67,8 @@ export const PostAvatar = React.memo(({ height: avatarSize, width: avatarSize, }} + src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatars?.imageUrlSmall} alt={author} - src={authorAvatars?.imageUrlSmall} />
); @@ -75,6 +80,7 @@ PostAvatar.propTypes = { authorLabel: PropTypes.string, fromPostLink: PropTypes.bool, read: PropTypes.bool, + postUsers: PropTypes.shape({}).isRequired, }; PostAvatar.defaultProps = { @@ -94,6 +100,7 @@ const PostHeader = ({ title, postType, preview, + postUsers, }) => { const intl = useIntl(); const showAnsweredBadge = preview && hasEndorsed && postType === ThreadType.QUESTION; @@ -105,7 +112,7 @@ const PostHeader = ({ return (
- +
@@ -155,6 +162,7 @@ PostHeader.propTypes = { reason: PropTypes.string, }), closed: PropTypes.bool, + postUsers: PropTypes.shape({}).isRequired, }; PostHeader.defaultProps = { diff --git a/src/discussions/posts/post/PostHeader.test.jsx b/src/discussions/posts/post/PostHeader.test.jsx new file mode 100644 index 00000000..5dee8185 --- /dev/null +++ b/src/discussions/posts/post/PostHeader.test.jsx @@ -0,0 +1,148 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from 'react-intl'; +import { useSelector } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; + +import { getConfig } from '@edx/frontend-platform'; + +import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants'; +import DiscussionContext from '../../common/context'; +import { useAlertBannerVisible } from '../../data/hooks'; +import PostHeader, { PostAvatar } from './PostHeader'; + +jest.mock('react-redux', () => ({ useSelector: jest.fn() })); +jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() })); +jest.mock('../../data/hooks', () => ({ useAlertBannerVisible: jest.fn() })); + +const defaultPostUsers = { + 'test-user': { + profile: { image: { hasImage: true, imageUrlSmall: 'http://avatar.test/img.png' } }, + }, +}; + +const ctxValue = { courseId: 'course-v1:edX+DemoX+Demo_Course', enableInContextSidebar: false }; + +function renderWithContext(ui) { + return render( + + + + {ui} + + + , + ); +} + +describe('PostAvatar', () => { + beforeEach(() => { + jest.clearAllMocks(); + useSelector.mockReturnValue({ imageUrlSmall: 'http://redux-avatar.png' }); + getConfig.mockReturnValue({ ENABLE_PROFILE_IMAGE: 'true' }); + }); + + it('renders avatar with profile image when ENABLE_PROFILE_IMAGE=true', () => { + renderWithContext( + , + ); + + const avatarImg = screen.getByAltText('test-user'); + expect(avatarImg).toHaveAttribute('src', 'http://avatar.test/img.png'); + }); + + it('falls back to redux avatar if no profile image', () => { + renderWithContext( + , + ); + + const avatarImg = screen.getByAltText('test-user'); + expect(avatarImg).toHaveAttribute('src', 'http://redux-avatar.png'); + }); + + it('applies Staff outline class if authorLabel provided', () => { + renderWithContext( + , + ); + + const avatar = screen.getByAltText('test-user'); + expect(avatar.className).toMatch(`outline-${AvatarOutlineAndLabelColors.Staff}`); + }); + + it('applies anonymous outline class if no authorLabel', () => { + const { container } = renderWithContext( + , + ); + + expect(container.querySelector('.outline-anonymous')).toBeInTheDocument(); + }); +}); + +describe('PostHeader', () => { + beforeEach(() => { + jest.clearAllMocks(); + useSelector.mockReturnValue({ imageUrlSmall: 'http://redux-avatar.png' }); + getConfig.mockReturnValue({ ENABLE_PROFILE_IMAGE: 'true' }); + useAlertBannerVisible.mockReturnValue(false); + }); + + const renderHeader = (props = {}) => renderWithContext( + , + ); + + it('renders post title and author', () => { + renderHeader(); + expect(screen.getByText('Sample Post Title')).toBeInTheDocument(); + expect(screen.getByText('test-user')).toBeInTheDocument(); + }); + + it('adds answered badge for endorsed QUESTION preview', () => { + renderHeader({ postType: ThreadType.QUESTION, hasEndorsed: true, preview: true }); + expect(screen.getByText(/answered/i)).toBeInTheDocument(); + }); + + it('adds mt-10px class if alert banner is visible', () => { + useAlertBannerVisible.mockReturnValue(true); + const { container } = renderHeader({ preview: false }); + expect(container.firstChild).toHaveClass('mt-10px'); + }); + + it('falls back to anonymous if no author provided', () => { + renderHeader({ author: '' }); + expect(screen.getByText(/anonymous/i)).toBeInTheDocument(); + }); +}); diff --git a/src/discussions/posts/post/PostLink.jsx b/src/discussions/posts/post/PostLink.jsx index 63f7659d..5501fba1 100644 --- a/src/discussions/posts/post/PostLink.jsx +++ b/src/discussions/posts/post/PostLink.jsx @@ -36,6 +36,7 @@ const PostLink = ({ const { topicId, hasEndorsed, type, author, authorLabel, abuseFlagged, abuseFlaggedCount, read, commentCount, unreadCommentCount, id, pinned, previewBody, title, voted, voteCount, following, groupId, groupName, createdAt, + users: postUsers, } = useSelector(selectThread(postId)); const { pathname } = discussionsPath(Routes.COMMENTS.PAGES[page], { 0: enableInContextSidebar ? 'in-context' : undefined, @@ -83,6 +84,7 @@ const PostLink = ({ authorLabel={authorLabel} fromPostLink read={isPostRead} + postUsers={postUsers} />
diff --git a/src/index.jsx b/src/index.jsx index f77b5f76..d7bb95fd 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -44,6 +44,7 @@ initialize({ LEARNING_BASE_URL: process.env.LEARNING_BASE_URL, LEARNER_FEEDBACK_URL: process.env.LEARNER_FEEDBACK_URL, STAFF_FEEDBACK_URL: process.env.STAFF_FEEDBACK_URL, + ENABLE_PROFILE_IMAGE: process.env.ENABLE_PROFILE_IMAGE, }, 'DiscussionsConfig'); }, },