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:
1
.env
1
.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={}
|
||||
|
||||
@@ -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={}
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -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 ? (
|
||||
<CommentEditor
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Avatar } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
|
||||
import { AuthorLabel } from '../../../common';
|
||||
import { useAlertBannerVisible } from '../../../data/hooks';
|
||||
@@ -17,6 +19,7 @@ const CommentHeader = ({
|
||||
closed,
|
||||
createdAt,
|
||||
lastEdit,
|
||||
postUsers,
|
||||
}) => {
|
||||
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 (
|
||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||
'mt-2': hasAnyAlert,
|
||||
@@ -36,7 +43,7 @@ const CommentHeader = ({
|
||||
<Avatar
|
||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={author}
|
||||
src={authorAvatar?.imageUrlSmall}
|
||||
src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatar}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
@@ -65,6 +72,7 @@ CommentHeader.propTypes = {
|
||||
editorUsername: PropTypes.string,
|
||||
reason: PropTypes.string,
|
||||
}),
|
||||
postUsers: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
CommentHeader.defaultProps = {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
<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} />
|
||||
|
||||
@@ -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 (
|
||||
<div className={avatarSpacing}>
|
||||
{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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -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 (
|
||||
<div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}>
|
||||
<div className="flex-shrink-0">
|
||||
<PostAvatar postType={postType} author={author} authorLabel={authorLabel} />
|
||||
<PostAvatar postType={postType} author={author} authorLabel={authorLabel} postUsers={postUsers} />
|
||||
</div>
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<div className="d-flex flex-column justify-content-start mw-100">
|
||||
@@ -155,6 +162,7 @@ PostHeader.propTypes = {
|
||||
reason: PropTypes.string,
|
||||
}),
|
||||
closed: PropTypes.bool,
|
||||
postUsers: PropTypes.shape({}).isRequired,
|
||||
};
|
||||
|
||||
PostHeader.defaultProps = {
|
||||
|
||||
148
src/discussions/posts/post/PostHeader.test.jsx
Normal file
148
src/discussions/posts/post/PostHeader.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
<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' }}>
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user