feat: Profile image on user posts (#574)

* feat: add env variable to display image

* feat: refactor after review, updated tests
This commit is contained in:
vladislavkeblysh
2025-09-25 21:09:37 +03:00
committed by GitHub
parent 7ca3d9bc32
commit 899d1fafcd
11 changed files with 255 additions and 6 deletions

1
.env
View File

@@ -22,5 +22,6 @@ USER_INFO_COOKIE_NAME=''
SUPPORT_URL='' SUPPORT_URL=''
LEARNER_FEEDBACK_URL='' LEARNER_FEEDBACK_URL=''
STAFF_FEEDBACK_URL='' STAFF_FEEDBACK_URL=''
ENABLE_PROFILE_IMAGE=''
# Fallback in local style files # Fallback in local style files
PARAGON_THEME_URLS={} PARAGON_THEME_URLS={}

View File

@@ -23,5 +23,6 @@ USER_INFO_COOKIE_NAME='edx-user-info'
SUPPORT_URL='https://support.edx.org' SUPPORT_URL='https://support.edx.org'
LEARNER_FEEDBACK_URL='' LEARNER_FEEDBACK_URL=''
STAFF_FEEDBACK_URL='' STAFF_FEEDBACK_URL=''
ENABLE_PROFILE_IMAGE=''
# Fallback in local style files # Fallback in local style files
PARAGON_THEME_URLS={} PARAGON_THEME_URLS={}

View File

@@ -21,3 +21,4 @@ USER_INFO_COOKIE_NAME='edx-user-info'
SUPPORT_URL='https://support.edx.org' SUPPORT_URL='https://support.edx.org'
LEARNER_FEEDBACK_URL='' LEARNER_FEEDBACK_URL=''
STAFF_FEEDBACK_URL='' STAFF_FEEDBACK_URL=''
ENABLE_PROFILE_IMAGE=''

View File

@@ -46,7 +46,7 @@ const Comment = ({
const { const {
id, parentId, childCount, abuseFlagged, endorsed, threadId, endorsedAt, endorsedBy, endorsedByLabel, renderedBody, id, parentId, childCount, abuseFlagged, endorsed, threadId, endorsedAt, endorsedBy, endorsedByLabel, renderedBody,
voted, following, voteCount, authorLabel, author, createdAt, lastEdit, rawBody, closed, closedBy, closeReason, voted, following, voteCount, authorLabel, author, createdAt, lastEdit, rawBody, closed, closedBy, closeReason,
editByLabel, closedByLabel, editByLabel, closedByLabel, users: postUsers,
} = comment; } = comment;
const intl = useIntl(); const intl = useIntl();
const hasChildren = childCount > 0; const hasChildren = childCount > 0;
@@ -209,6 +209,7 @@ const Comment = ({
closed={closed} closed={closed}
createdAt={createdAt} createdAt={createdAt}
lastEdit={lastEdit} lastEdit={lastEdit}
postUsers={postUsers}
/> />
{isEditing ? ( {isEditing ? (
<CommentEditor <CommentEditor

View File

@@ -5,6 +5,8 @@ import { Avatar } from '@openedx/paragon';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { AvatarOutlineAndLabelColors } from '../../../../data/constants'; import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
import { AuthorLabel } from '../../../common'; import { AuthorLabel } from '../../../common';
import { useAlertBannerVisible } from '../../../data/hooks'; import { useAlertBannerVisible } from '../../../data/hooks';
@@ -17,6 +19,7 @@ const CommentHeader = ({
closed, closed,
createdAt, createdAt,
lastEdit, lastEdit,
postUsers,
}) => { }) => {
const colorClass = AvatarOutlineAndLabelColors[authorLabel]; const colorClass = AvatarOutlineAndLabelColors[authorLabel];
const hasAnyAlert = useAlertBannerVisible({ const hasAnyAlert = useAlertBannerVisible({
@@ -27,6 +30,10 @@ const CommentHeader = ({
}); });
const authorAvatar = useSelector(selectAuthorAvatar(author)); const authorAvatar = useSelector(selectAuthorAvatar(author));
const profileImage = getConfig()?.ENABLE_PROFILE_IMAGE === 'true'
? Object.values(postUsers ?? {})[0]?.profile?.image
: null;
return ( return (
<div className={classNames('d-flex flex-row justify-content-between', { <div className={classNames('d-flex flex-row justify-content-between', {
'mt-2': hasAnyAlert, 'mt-2': hasAnyAlert,
@@ -36,7 +43,7 @@ const CommentHeader = ({
<Avatar <Avatar
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`} className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={author} alt={author}
src={authorAvatar?.imageUrlSmall} src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatar}
style={{ style={{
width: '32px', width: '32px',
height: '32px', height: '32px',
@@ -65,6 +72,7 @@ CommentHeader.propTypes = {
editorUsername: PropTypes.string, editorUsername: PropTypes.string,
reason: PropTypes.string, reason: PropTypes.string,
}), }),
postUsers: PropTypes.shape({}).isRequired,
}; };
CommentHeader.defaultProps = { CommentHeader.defaultProps = {

View File

@@ -0,0 +1,77 @@
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';
import { getConfig } from '@edx/frontend-platform';
import DiscussionContext from '../../../common/context';
import { useAlertBannerVisible } from '../../../data/hooks';
import CommentHeader from './CommentHeader';
jest.mock('react-redux', () => ({ 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(
<IntlProvider locale="en">
<MemoryRouter>
<DiscussionContext.Provider value={ctx}>
<CommentHeader {...defaultProps} {...props} />
</DiscussionContext.Provider>
</MemoryRouter>
</IntlProvider>,
);
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');
});
});

View File

@@ -33,7 +33,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
const { const {
topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName, topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName,
closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel, closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel,
closedByLabel, closedByLabel, users: postUsers,
} = useSelector(selectThread(postId)); } = useSelector(selectThread(postId));
const intl = useIntl(); const intl = useIntl();
const location = useLocation(); const location = useLocation();
@@ -187,6 +187,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
lastEdit={lastEdit} lastEdit={lastEdit}
postType={postType} postType={postType}
title={title} title={title}
postUsers={postUsers}
/> />
<div className="d-flex mt-14px text-break font-style text-primary-500"> <div className="d-flex mt-14px text-break font-style text-primary-500">
<HTMLLoader htmlNode={renderedBody} componentId="post" cssClassName="html-loader w-100" testId={postId} /> <HTMLLoader htmlNode={renderedBody} componentId="post" cssClassName="html-loader w-100" testId={postId} />

View File

@@ -6,6 +6,7 @@ import { Question } from '@openedx/paragon/icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants'; import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
@@ -15,7 +16,7 @@ import { selectAuthorAvatar } from '../data/selectors';
import messages from './messages'; import messages from './messages';
export const PostAvatar = React.memo(({ export const PostAvatar = React.memo(({
author, postType, authorLabel, fromPostLink, read, author, postType, authorLabel, fromPostLink, read, postUsers,
}) => { }) => {
const outlineColor = AvatarOutlineAndLabelColors[authorLabel]; const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
const authorAvatars = useSelector(selectAuthorAvatar(author)); const authorAvatars = useSelector(selectAuthorAvatar(author));
@@ -40,6 +41,10 @@ export const PostAvatar = React.memo(({
return spacing; return spacing;
}, [postType]); }, [postType]);
const profileImage = getConfig()?.ENABLE_PROFILE_IMAGE === 'true'
? Object.values(postUsers ?? {})[0]?.profile?.image
: null;
return ( return (
<div className={avatarSpacing}> <div className={avatarSpacing}>
{postType === ThreadType.QUESTION && ( {postType === ThreadType.QUESTION && (
@@ -62,8 +67,8 @@ export const PostAvatar = React.memo(({
height: avatarSize, height: avatarSize,
width: avatarSize, width: avatarSize,
}} }}
src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatars?.imageUrlSmall}
alt={author} alt={author}
src={authorAvatars?.imageUrlSmall}
/> />
</div> </div>
); );
@@ -75,6 +80,7 @@ PostAvatar.propTypes = {
authorLabel: PropTypes.string, authorLabel: PropTypes.string,
fromPostLink: PropTypes.bool, fromPostLink: PropTypes.bool,
read: PropTypes.bool, read: PropTypes.bool,
postUsers: PropTypes.shape({}).isRequired,
}; };
PostAvatar.defaultProps = { PostAvatar.defaultProps = {
@@ -94,6 +100,7 @@ const PostHeader = ({
title, title,
postType, postType,
preview, preview,
postUsers,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const showAnsweredBadge = preview && hasEndorsed && postType === ThreadType.QUESTION; const showAnsweredBadge = preview && hasEndorsed && postType === ThreadType.QUESTION;
@@ -105,7 +112,7 @@ const PostHeader = ({
return ( return (
<div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}> <div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}>
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<PostAvatar postType={postType} author={author} authorLabel={authorLabel} /> <PostAvatar postType={postType} author={author} authorLabel={authorLabel} postUsers={postUsers} />
</div> </div>
<div className="align-items-center d-flex flex-row"> <div className="align-items-center d-flex flex-row">
<div className="d-flex flex-column justify-content-start mw-100"> <div className="d-flex flex-column justify-content-start mw-100">
@@ -155,6 +162,7 @@ PostHeader.propTypes = {
reason: PropTypes.string, reason: PropTypes.string,
}), }),
closed: PropTypes.bool, closed: PropTypes.bool,
postUsers: PropTypes.shape({}).isRequired,
}; };
PostHeader.defaultProps = { PostHeader.defaultProps = {

View File

@@ -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(
<IntlProvider locale="en">
<MemoryRouter>
<DiscussionContext.Provider value={ctxValue}>
{ui}
</DiscussionContext.Provider>
</MemoryRouter>
</IntlProvider>,
);
}
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(
<PostAvatar
author="test-user"
postType={ThreadType.DISCUSSION}
authorLabel="Staff"
postUsers={defaultPostUsers}
/>,
);
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(
<PostAvatar
author="test-user"
postType={ThreadType.DISCUSSION}
authorLabel="Staff"
postUsers={{ 'test-user': { profile: { image: { hasImage: false, imageUrlSmall: null } } } }}
/>,
);
const avatarImg = screen.getByAltText('test-user');
expect(avatarImg).toHaveAttribute('src', 'http://redux-avatar.png');
});
it('applies Staff outline class if authorLabel provided', () => {
renderWithContext(
<PostAvatar
author="test-user"
postType={ThreadType.DISCUSSION}
authorLabel="Staff"
postUsers={defaultPostUsers}
/>,
);
const avatar = screen.getByAltText('test-user');
expect(avatar.className).toMatch(`outline-${AvatarOutlineAndLabelColors.Staff}`);
});
it('applies anonymous outline class if no authorLabel', () => {
const { container } = renderWithContext(
<PostAvatar
author="test-user"
postType={ThreadType.DISCUSSION}
authorLabel={null}
postUsers={defaultPostUsers}
/>,
);
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(
<PostHeader
author="test-user"
authorLabel="Staff"
abuseFlagged={false}
closed={false}
createdAt="2025-09-23T10:00:00Z"
lastEdit={null}
postUsers={defaultPostUsers}
title="Sample Post Title"
postType={ThreadType.DISCUSSION}
hasEndorsed={false}
preview={false}
{...props}
/>,
);
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();
});
});

View File

@@ -36,6 +36,7 @@ const PostLink = ({
const { const {
topicId, hasEndorsed, type, author, authorLabel, abuseFlagged, abuseFlaggedCount, read, commentCount, topicId, hasEndorsed, type, author, authorLabel, abuseFlagged, abuseFlaggedCount, read, commentCount,
unreadCommentCount, id, pinned, previewBody, title, voted, voteCount, following, groupId, groupName, createdAt, unreadCommentCount, id, pinned, previewBody, title, voted, voteCount, following, groupId, groupName, createdAt,
users: postUsers,
} = useSelector(selectThread(postId)); } = useSelector(selectThread(postId));
const { pathname } = discussionsPath(Routes.COMMENTS.PAGES[page], { const { pathname } = discussionsPath(Routes.COMMENTS.PAGES[page], {
0: enableInContextSidebar ? 'in-context' : undefined, 0: enableInContextSidebar ? 'in-context' : undefined,
@@ -83,6 +84,7 @@ const PostLink = ({
authorLabel={authorLabel} authorLabel={authorLabel}
fromPostLink fromPostLink
read={isPostRead} read={isPostRead}
postUsers={postUsers}
/> />
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}> <div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill" style={{ marginBottom: '-3px' }}> <div className="d-flex flex-column justify-content-start mw-100 flex-fill" style={{ marginBottom: '-3px' }}>

View File

@@ -44,6 +44,7 @@ initialize({
LEARNING_BASE_URL: process.env.LEARNING_BASE_URL, LEARNING_BASE_URL: process.env.LEARNING_BASE_URL,
LEARNER_FEEDBACK_URL: process.env.LEARNER_FEEDBACK_URL, LEARNER_FEEDBACK_URL: process.env.LEARNER_FEEDBACK_URL,
STAFF_FEEDBACK_URL: process.env.STAFF_FEEDBACK_URL, STAFF_FEEDBACK_URL: process.env.STAFF_FEEDBACK_URL,
ENABLE_PROFILE_IMAGE: process.env.ENABLE_PROFILE_IMAGE,
}, 'DiscussionsConfig'); }, 'DiscussionsConfig');
}, },
}, },