Skip to content

Commit 899d1fa

Browse files
feat: Profile image on user posts (#574)
* feat: add env variable to display image * feat: refactor after review, updated tests
1 parent 7ca3d9b commit 899d1fa

File tree

11 files changed

+255
-6
lines changed

11 files changed

+255
-6
lines changed

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,6 @@ USER_INFO_COOKIE_NAME=''
2222
SUPPORT_URL=''
2323
LEARNER_FEEDBACK_URL=''
2424
STAFF_FEEDBACK_URL=''
25+
ENABLE_PROFILE_IMAGE=''
2526
# Fallback in local style files
2627
PARAGON_THEME_URLS={}

.env.development

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,6 @@ USER_INFO_COOKIE_NAME='edx-user-info'
2323
SUPPORT_URL='https://support.edx.org'
2424
LEARNER_FEEDBACK_URL=''
2525
STAFF_FEEDBACK_URL=''
26+
ENABLE_PROFILE_IMAGE=''
2627
# Fallback in local style files
2728
PARAGON_THEME_URLS={}

.env.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ USER_INFO_COOKIE_NAME='edx-user-info'
2121
SUPPORT_URL='https://support.edx.org'
2222
LEARNER_FEEDBACK_URL=''
2323
STAFF_FEEDBACK_URL=''
24+
ENABLE_PROFILE_IMAGE=''

src/discussions/post-comments/comments/comment/Comment.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const Comment = ({
4646
const {
4747
id, parentId, childCount, abuseFlagged, endorsed, threadId, endorsedAt, endorsedBy, endorsedByLabel, renderedBody,
4848
voted, following, voteCount, authorLabel, author, createdAt, lastEdit, rawBody, closed, closedBy, closeReason,
49-
editByLabel, closedByLabel,
49+
editByLabel, closedByLabel, users: postUsers,
5050
} = comment;
5151
const intl = useIntl();
5252
const hasChildren = childCount > 0;
@@ -209,6 +209,7 @@ const Comment = ({
209209
closed={closed}
210210
createdAt={createdAt}
211211
lastEdit={lastEdit}
212+
postUsers={postUsers}
212213
/>
213214
{isEditing ? (
214215
<CommentEditor

src/discussions/post-comments/comments/comment/CommentHeader.jsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { Avatar } from '@openedx/paragon';
55
import classNames from 'classnames';
66
import { useSelector } from 'react-redux';
77

8+
import { getConfig } from '@edx/frontend-platform';
9+
810
import { AvatarOutlineAndLabelColors } from '../../../../data/constants';
911
import { AuthorLabel } from '../../../common';
1012
import { useAlertBannerVisible } from '../../../data/hooks';
@@ -17,6 +19,7 @@ const CommentHeader = ({
1719
closed,
1820
createdAt,
1921
lastEdit,
22+
postUsers,
2023
}) => {
2124
const colorClass = AvatarOutlineAndLabelColors[authorLabel];
2225
const hasAnyAlert = useAlertBannerVisible({
@@ -27,6 +30,10 @@ const CommentHeader = ({
2730
});
2831
const authorAvatar = useSelector(selectAuthorAvatar(author));
2932

33+
const profileImage = getConfig()?.ENABLE_PROFILE_IMAGE === 'true'
34+
? Object.values(postUsers ?? {})[0]?.profile?.image
35+
: null;
36+
3037
return (
3138
<div className={classNames('d-flex flex-row justify-content-between', {
3239
'mt-2': hasAnyAlert,
@@ -36,7 +43,7 @@ const CommentHeader = ({
3643
<Avatar
3744
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
3845
alt={author}
39-
src={authorAvatar?.imageUrlSmall}
46+
src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatar}
4047
style={{
4148
width: '32px',
4249
height: '32px',
@@ -65,6 +72,7 @@ CommentHeader.propTypes = {
6572
editorUsername: PropTypes.string,
6673
reason: PropTypes.string,
6774
}),
75+
postUsers: PropTypes.shape({}).isRequired,
6876
};
6977

7078
CommentHeader.defaultProps = {
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import React from 'react';
2+
3+
import { render, screen } from '@testing-library/react';
4+
import { IntlProvider } from 'react-intl';
5+
import { useSelector } from 'react-redux';
6+
import { MemoryRouter } from 'react-router';
7+
8+
import { getConfig } from '@edx/frontend-platform';
9+
10+
import DiscussionContext from '../../../common/context';
11+
import { useAlertBannerVisible } from '../../../data/hooks';
12+
import CommentHeader from './CommentHeader';
13+
14+
jest.mock('react-redux', () => ({ useSelector: jest.fn() }));
15+
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
16+
jest.mock('../../../data/hooks', () => ({ useAlertBannerVisible: jest.fn() }));
17+
18+
const defaultProps = {
19+
author: 'test-user',
20+
authorLabel: 'staff',
21+
abuseFlagged: false,
22+
closed: false,
23+
createdAt: '2025-09-23T10:00:00Z',
24+
lastEdit: null,
25+
postUsers: {
26+
'test-user': {
27+
profile: { image: { hasImage: true, imageUrlSmall: 'http://avatar.test/img.png' } },
28+
},
29+
},
30+
};
31+
32+
const renderComponent = (
33+
props = {},
34+
ctx = { courseId: 'course-v1:edX+DemoX+Demo_Course', enableInContextSidebar: false },
35+
) => render(
36+
<IntlProvider locale="en">
37+
<MemoryRouter>
38+
<DiscussionContext.Provider value={ctx}>
39+
<CommentHeader {...defaultProps} {...props} />
40+
</DiscussionContext.Provider>
41+
</MemoryRouter>
42+
</IntlProvider>,
43+
);
44+
45+
describe('CommentHeader', () => {
46+
beforeEach(() => {
47+
jest.clearAllMocks();
48+
useSelector.mockReturnValue('http://fallback-avatar.png');
49+
useAlertBannerVisible.mockReturnValue(false);
50+
getConfig.mockReturnValue({ ENABLE_PROFILE_IMAGE: 'true' });
51+
});
52+
53+
it('renders author and avatar with profile image when ENABLE_PROFILE_IMAGE=true', () => {
54+
renderComponent();
55+
const avatarImg = screen.getByAltText('test-user');
56+
expect(avatarImg).toHaveAttribute('src', 'http://avatar.test/img.png');
57+
expect(screen.getByText('test-user')).toBeInTheDocument();
58+
});
59+
60+
it('uses redux avatar if profile image is disabled by config', () => {
61+
getConfig.mockReturnValue({ ENABLE_PROFILE_IMAGE: 'false' });
62+
const { container } = renderComponent();
63+
const avatar = container.querySelector('.outline-staff, .outline-anonymous');
64+
expect(avatar).toBeInTheDocument();
65+
});
66+
67+
it('applies anonymous class if no color class is found', () => {
68+
const { container } = renderComponent({ authorLabel: null });
69+
expect(container.querySelector('.outline-anonymous')).toBeInTheDocument();
70+
});
71+
72+
it('adds margin-top if alert banner is visible', () => {
73+
useAlertBannerVisible.mockReturnValue(true);
74+
const { container } = renderComponent();
75+
expect(container.firstChild).toHaveClass('mt-2');
76+
});
77+
});

src/discussions/posts/post/Post.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
3333
const {
3434
topicId, abuseFlagged, closed, pinned, voted, hasEndorsed, following, closedBy, voteCount, groupId, groupName,
3535
closeReason, authorLabel, type: postType, author, title, createdAt, renderedBody, lastEdit, editByLabel,
36-
closedByLabel,
36+
closedByLabel, users: postUsers,
3737
} = useSelector(selectThread(postId));
3838
const intl = useIntl();
3939
const location = useLocation();
@@ -187,6 +187,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
187187
lastEdit={lastEdit}
188188
postType={postType}
189189
title={title}
190+
postUsers={postUsers}
190191
/>
191192
<div className="d-flex mt-14px text-break font-style text-primary-500">
192193
<HTMLLoader htmlNode={renderedBody} componentId="post" cssClassName="html-loader w-100" testId={postId} />

src/discussions/posts/post/PostHeader.jsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Question } from '@openedx/paragon/icons';
66
import classNames from 'classnames';
77
import { useSelector } from 'react-redux';
88

9+
import { getConfig } from '@edx/frontend-platform';
910
import { useIntl } from '@edx/frontend-platform/i18n';
1011

1112
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
@@ -15,7 +16,7 @@ import { selectAuthorAvatar } from '../data/selectors';
1516
import messages from './messages';
1617

1718
export const PostAvatar = React.memo(({
18-
author, postType, authorLabel, fromPostLink, read,
19+
author, postType, authorLabel, fromPostLink, read, postUsers,
1920
}) => {
2021
const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
2122
const authorAvatars = useSelector(selectAuthorAvatar(author));
@@ -40,6 +41,10 @@ export const PostAvatar = React.memo(({
4041
return spacing;
4142
}, [postType]);
4243

44+
const profileImage = getConfig()?.ENABLE_PROFILE_IMAGE === 'true'
45+
? Object.values(postUsers ?? {})[0]?.profile?.image
46+
: null;
47+
4348
return (
4449
<div className={avatarSpacing}>
4550
{postType === ThreadType.QUESTION && (
@@ -62,8 +67,8 @@ export const PostAvatar = React.memo(({
6267
height: avatarSize,
6368
width: avatarSize,
6469
}}
70+
src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatars?.imageUrlSmall}
6571
alt={author}
66-
src={authorAvatars?.imageUrlSmall}
6772
/>
6873
</div>
6974
);
@@ -75,6 +80,7 @@ PostAvatar.propTypes = {
7580
authorLabel: PropTypes.string,
7681
fromPostLink: PropTypes.bool,
7782
read: PropTypes.bool,
83+
postUsers: PropTypes.shape({}).isRequired,
7884
};
7985

8086
PostAvatar.defaultProps = {
@@ -94,6 +100,7 @@ const PostHeader = ({
94100
title,
95101
postType,
96102
preview,
103+
postUsers,
97104
}) => {
98105
const intl = useIntl();
99106
const showAnsweredBadge = preview && hasEndorsed && postType === ThreadType.QUESTION;
@@ -105,7 +112,7 @@ const PostHeader = ({
105112
return (
106113
<div className={classNames('d-flex flex-fill mw-100', { 'mt-10px': hasAnyAlert && !preview })}>
107114
<div className="flex-shrink-0">
108-
<PostAvatar postType={postType} author={author} authorLabel={authorLabel} />
115+
<PostAvatar postType={postType} author={author} authorLabel={authorLabel} postUsers={postUsers} />
109116
</div>
110117
<div className="align-items-center d-flex flex-row">
111118
<div className="d-flex flex-column justify-content-start mw-100">
@@ -155,6 +162,7 @@ PostHeader.propTypes = {
155162
reason: PropTypes.string,
156163
}),
157164
closed: PropTypes.bool,
165+
postUsers: PropTypes.shape({}).isRequired,
158166
};
159167

160168
PostHeader.defaultProps = {
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import React from 'react';
2+
3+
import { render, screen } from '@testing-library/react';
4+
import { IntlProvider } from 'react-intl';
5+
import { useSelector } from 'react-redux';
6+
import { MemoryRouter } from 'react-router-dom';
7+
8+
import { getConfig } from '@edx/frontend-platform';
9+
10+
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
11+
import DiscussionContext from '../../common/context';
12+
import { useAlertBannerVisible } from '../../data/hooks';
13+
import PostHeader, { PostAvatar } from './PostHeader';
14+
15+
jest.mock('react-redux', () => ({ useSelector: jest.fn() }));
16+
jest.mock('@edx/frontend-platform', () => ({ getConfig: jest.fn() }));
17+
jest.mock('../../data/hooks', () => ({ useAlertBannerVisible: jest.fn() }));
18+
19+
const defaultPostUsers = {
20+
'test-user': {
21+
profile: { image: { hasImage: true, imageUrlSmall: 'http://avatar.test/img.png' } },
22+
},
23+
};
24+
25+
const ctxValue = { courseId: 'course-v1:edX+DemoX+Demo_Course', enableInContextSidebar: false };
26+
27+
function renderWithContext(ui) {
28+
return render(
29+
<IntlProvider locale="en">
30+
<MemoryRouter>
31+
<DiscussionContext.Provider value={ctxValue}>
32+
{ui}
33+
</DiscussionContext.Provider>
34+
</MemoryRouter>
35+
</IntlProvider>,
36+
);
37+
}
38+
39+
describe('PostAvatar', () => {
40+
beforeEach(() => {
41+
jest.clearAllMocks();
42+
useSelector.mockReturnValue({ imageUrlSmall: 'http://redux-avatar.png' });
43+
getConfig.mockReturnValue({ ENABLE_PROFILE_IMAGE: 'true' });
44+
});
45+
46+
it('renders avatar with profile image when ENABLE_PROFILE_IMAGE=true', () => {
47+
renderWithContext(
48+
<PostAvatar
49+
author="test-user"
50+
postType={ThreadType.DISCUSSION}
51+
authorLabel="Staff"
52+
postUsers={defaultPostUsers}
53+
/>,
54+
);
55+
56+
const avatarImg = screen.getByAltText('test-user');
57+
expect(avatarImg).toHaveAttribute('src', 'http://avatar.test/img.png');
58+
});
59+
60+
it('falls back to redux avatar if no profile image', () => {
61+
renderWithContext(
62+
<PostAvatar
63+
author="test-user"
64+
postType={ThreadType.DISCUSSION}
65+
authorLabel="Staff"
66+
postUsers={{ 'test-user': { profile: { image: { hasImage: false, imageUrlSmall: null } } } }}
67+
/>,
68+
);
69+
70+
const avatarImg = screen.getByAltText('test-user');
71+
expect(avatarImg).toHaveAttribute('src', 'http://redux-avatar.png');
72+
});
73+
74+
it('applies Staff outline class if authorLabel provided', () => {
75+
renderWithContext(
76+
<PostAvatar
77+
author="test-user"
78+
postType={ThreadType.DISCUSSION}
79+
authorLabel="Staff"
80+
postUsers={defaultPostUsers}
81+
/>,
82+
);
83+
84+
const avatar = screen.getByAltText('test-user');
85+
expect(avatar.className).toMatch(`outline-${AvatarOutlineAndLabelColors.Staff}`);
86+
});
87+
88+
it('applies anonymous outline class if no authorLabel', () => {
89+
const { container } = renderWithContext(
90+
<PostAvatar
91+
author="test-user"
92+
postType={ThreadType.DISCUSSION}
93+
authorLabel={null}
94+
postUsers={defaultPostUsers}
95+
/>,
96+
);
97+
98+
expect(container.querySelector('.outline-anonymous')).toBeInTheDocument();
99+
});
100+
});
101+
102+
describe('PostHeader', () => {
103+
beforeEach(() => {
104+
jest.clearAllMocks();
105+
useSelector.mockReturnValue({ imageUrlSmall: 'http://redux-avatar.png' });
106+
getConfig.mockReturnValue({ ENABLE_PROFILE_IMAGE: 'true' });
107+
useAlertBannerVisible.mockReturnValue(false);
108+
});
109+
110+
const renderHeader = (props = {}) => renderWithContext(
111+
<PostHeader
112+
author="test-user"
113+
authorLabel="Staff"
114+
abuseFlagged={false}
115+
closed={false}
116+
createdAt="2025-09-23T10:00:00Z"
117+
lastEdit={null}
118+
postUsers={defaultPostUsers}
119+
title="Sample Post Title"
120+
postType={ThreadType.DISCUSSION}
121+
hasEndorsed={false}
122+
preview={false}
123+
{...props}
124+
/>,
125+
);
126+
127+
it('renders post title and author', () => {
128+
renderHeader();
129+
expect(screen.getByText('Sample Post Title')).toBeInTheDocument();
130+
expect(screen.getByText('test-user')).toBeInTheDocument();
131+
});
132+
133+
it('adds answered badge for endorsed QUESTION preview', () => {
134+
renderHeader({ postType: ThreadType.QUESTION, hasEndorsed: true, preview: true });
135+
expect(screen.getByText(/answered/i)).toBeInTheDocument();
136+
});
137+
138+
it('adds mt-10px class if alert banner is visible', () => {
139+
useAlertBannerVisible.mockReturnValue(true);
140+
const { container } = renderHeader({ preview: false });
141+
expect(container.firstChild).toHaveClass('mt-10px');
142+
});
143+
144+
it('falls back to anonymous if no author provided', () => {
145+
renderHeader({ author: '' });
146+
expect(screen.getByText(/anonymous/i)).toBeInTheDocument();
147+
});
148+
});

0 commit comments

Comments
 (0)