fix: fix post height and remove overstate from author label (#472)
* fix: fixed post length according to figma * fix: remove hoverstate from author label and author icon * style: adds tooltip in hovercard for endorsemment icon * test: fix test cases * test: adds test cases for descreaased coverage * refactor: updated api call to a method for reusability * refactor: changed test descriptions
This commit is contained in:
@@ -44,34 +44,31 @@ function AuthorLabel({
|
||||
|
||||
const isRetiredUser = author ? author.startsWith('retired__user') : false;
|
||||
const showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert;
|
||||
|
||||
const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor);
|
||||
|
||||
const showUserNameAsLink = useShowLearnersTab()
|
||||
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
|
||||
|
||||
const authorName = (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-gray-700': isRetiredUser,
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
||||
})}
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{isRetiredUser ? '[Deactivated]' : author}
|
||||
</span>
|
||||
);
|
||||
const labelContents = (
|
||||
<div className={className}>
|
||||
{!alert && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-gray-700': isRetiredUser,
|
||||
'text-primary-500': !authorLabelMessage && !isRetiredUser,
|
||||
})}
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
{isRetiredUser ? '[Deactivated]' : author}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id={`endorsed-by-${author}-tooltip`}>
|
||||
{author}
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<div className={classNames('d-flex flex-row align-items-center', {
|
||||
@@ -86,20 +83,19 @@ function AuthorLabel({
|
||||
src={icon}
|
||||
data-testid="author-icon"
|
||||
/>
|
||||
|
||||
{authorLabelMessage && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-primary-500': showTextPrimary,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
style={{ marginLeft: '2px' }}
|
||||
>
|
||||
{authorLabelMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</OverlayTrigger>
|
||||
{authorLabelMessage && (
|
||||
<span
|
||||
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
|
||||
'text-primary-500': showTextPrimary,
|
||||
'text-gray-700': isRetiredUser,
|
||||
})}
|
||||
style={{ marginLeft: '2px' }}
|
||||
>
|
||||
{authorLabelMessage}
|
||||
</span>
|
||||
)}
|
||||
{postCreatedAt && (
|
||||
<span
|
||||
title={postCreatedAt}
|
||||
@@ -112,23 +108,25 @@ function AuthorLabel({
|
||||
{timeago.format(postCreatedAt, 'time-locale')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return showUserNameAsLink
|
||||
? (
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
id="learner-posts-link"
|
||||
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
|
||||
className="text-decoration-none"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
<div className={className}>
|
||||
<Link
|
||||
data-testid="learner-posts-link"
|
||||
id="learner-posts-link"
|
||||
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
|
||||
className="text-decoration-none"
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
{!alert && authorName}
|
||||
</Link>
|
||||
{labelContents}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
: <>{labelContents}</>;
|
||||
: <div className={className}>{authorName}{labelContents}</div>;
|
||||
}
|
||||
|
||||
AuthorLabel.propTypes = {
|
||||
|
||||
@@ -93,12 +93,13 @@ describe('Author label', () => {
|
||||
async () => {
|
||||
renderComponent(author, authorLabel, linkToProfile, labelColor);
|
||||
const authorElement = container.querySelector('[role=heading]');
|
||||
const labelElement = authorElement.parentNode.lastChild;
|
||||
const labelParentNode = authorElement.parentNode.parentNode;
|
||||
const labelElement = labelParentNode.lastChild.lastChild;
|
||||
const label = ['TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent;
|
||||
|
||||
if (linkToProfile) {
|
||||
expect(authorElement.parentNode).toHaveClass(labelColor);
|
||||
expect(authorElement.parentNode.lastChild).toHaveTextContent(label);
|
||||
expect(labelParentNode).toHaveClass(labelColor);
|
||||
expect(labelElement).toHaveTextContent(label);
|
||||
} else {
|
||||
expect(authorElement.parentNode.lastChild).not.toHaveTextContent(label, { exact: true });
|
||||
expect(authorElement.parentNode).not.toHaveClass(labelColor, { exact: true });
|
||||
|
||||
@@ -3,8 +3,10 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon, IconButton } from '@edx/paragon';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, OverlayTrigger, Tooltip,
|
||||
} from '@edx/paragon';
|
||||
|
||||
import {
|
||||
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
|
||||
@@ -16,6 +18,7 @@ import ActionsDropdown from './ActionsDropdown';
|
||||
import { DiscussionContext } from './context';
|
||||
|
||||
function HoverCard({
|
||||
intl,
|
||||
commentOrPost,
|
||||
actionHandlers,
|
||||
handleResponseCommentButton,
|
||||
@@ -49,17 +52,26 @@ function HoverCard({
|
||||
)}
|
||||
{endorseIcons && (
|
||||
<div className="hover-button">
|
||||
<IconButton
|
||||
src={endorseIcons.icon}
|
||||
iconAs={Icon}
|
||||
onClick={() => {
|
||||
const actionFunction = actionHandlers[endorseIcons.action];
|
||||
actionFunction();
|
||||
}}
|
||||
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
||||
size="sm"
|
||||
alt="Endorse"
|
||||
/>
|
||||
<OverlayTrigger
|
||||
overlay={(
|
||||
<Tooltip id="endorsed-icon-tooltip">
|
||||
{intl.formatMessage(endorseIcons.label)}
|
||||
</Tooltip>
|
||||
)}
|
||||
trigger={['hover', 'focus']}
|
||||
>
|
||||
<IconButton
|
||||
src={endorseIcons.icon}
|
||||
iconAs={Icon}
|
||||
onClick={() => {
|
||||
const actionFunction = actionHandlers[endorseIcons.action];
|
||||
actionFunction();
|
||||
}}
|
||||
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
|
||||
size="sm"
|
||||
alt="Endorse"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
)}
|
||||
<div className="hover-button">
|
||||
@@ -98,6 +110,7 @@ function HoverCard({
|
||||
}
|
||||
|
||||
HoverCard.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
|
||||
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
|
||||
handleResponseCommentButton: PropTypes.func.isRequired,
|
||||
|
||||
@@ -97,9 +97,9 @@ function mockAxiosReturnPagedCommentsResponses() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getThreadAPIResponse(threadId, topicId) {
|
||||
async function getThreadAPIResponse(attr = null) {
|
||||
axiosMock.onGet(`${threadsApiUrl}${discussionPostId}/`)
|
||||
.reply(200, Factory.build('thread', { id: threadId, topic_id: topicId }));
|
||||
.reply(200, Factory.build('thread', attr));
|
||||
await executeThunk(fetchThread(discussionPostId), store.dispatch, store.getState);
|
||||
}
|
||||
|
||||
@@ -152,14 +152,14 @@ describe('PostView', () => {
|
||||
});
|
||||
|
||||
it('should show Topic Info for non-courseware topics', async () => {
|
||||
await getThreadAPIResponse('thread-1', 'non-courseware-topic-1');
|
||||
await getThreadAPIResponse({ id: 'thread-1', topic_id: 'non-courseware-topic-1' });
|
||||
renderComponent(discussionPostId);
|
||||
expect(await screen.findByText('Related to')).toBeInTheDocument();
|
||||
expect(await screen.findByText('non-courseware-topic 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show Topic Info for courseware topics with category', async () => {
|
||||
await getThreadAPIResponse('thread-2', 'courseware-topic-2');
|
||||
await getThreadAPIResponse({ id: 'thread-2', topic_id: 'courseware-topic-2' });
|
||||
|
||||
renderComponent('thread-2');
|
||||
expect(await screen.findByText('Related to')).toBeInTheDocument();
|
||||
@@ -233,6 +233,22 @@ describe('ThreadView', () => {
|
||||
renderComponent(discussionPostId);
|
||||
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
|
||||
expect(within(comment).queryByTestId('comment-1')).toBeInTheDocument();
|
||||
it('should not show post footer', async () => {
|
||||
Factory.resetAll();
|
||||
await getThreadAPIResponse({
|
||||
vote_count: 0, following: false, closed: false, group_id: null,
|
||||
});
|
||||
renderComponent(discussionPostId);
|
||||
expect(screen.queryByTestId('post-footer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show post footer', async () => {
|
||||
Factory.resetAll();
|
||||
await getThreadAPIResponse({
|
||||
vote_count: 4, following: true, closed: false, group_id: null,
|
||||
});
|
||||
renderComponent(discussionPostId);
|
||||
expect(screen.queryByTestId('post-footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show and hide the editor', async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { selectorForUnitSubsection, selectTopicContext } from '../../../data/sel
|
||||
import { AlertBanner, Confirmation } from '../../common';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import HoverCard from '../../common/HoverCard';
|
||||
import { selectModerationSettings } from '../../data/selectors';
|
||||
import { selectModerationSettings, selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import { selectTopic } from '../../topics/data/selectors';
|
||||
import { removeThread, updateExistingThread } from '../data/thunks';
|
||||
import ClosePostReasonModal from './ClosePostReasonModal';
|
||||
@@ -26,7 +26,6 @@ import { postShape } from './proptypes';
|
||||
|
||||
function Post({
|
||||
post,
|
||||
preview,
|
||||
intl,
|
||||
handleAddResponseButton,
|
||||
}) {
|
||||
@@ -42,6 +41,9 @@ function Post({
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
|
||||
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const displayPostFooter = post.following || post.voteCount || post.closed
|
||||
|| (post.groupId && userHasModerationPrivileges);
|
||||
|
||||
const handleAbusedFlag = useCallback(() => {
|
||||
if (post.abuseFlagged) {
|
||||
@@ -147,8 +149,8 @@ function Post({
|
||||
</div>
|
||||
{(topicContext || topic) && (
|
||||
<div
|
||||
className={classNames('mt-14px mb-1 font-style font-size-12',
|
||||
{ 'w-100': enableInContextSidebar })}
|
||||
className={classNames('mt-14px font-style font-size-12',
|
||||
{ 'w-100': enableInContextSidebar, 'mb-1': !displayPostFooter })}
|
||||
style={{ lineHeight: '20px' }}
|
||||
>
|
||||
<span className="text-gray-500" style={{ lineHeight: '20px' }}>{intl.formatMessage(messages.relatedTo)}{' '}</span>
|
||||
@@ -170,7 +172,7 @@ function Post({
|
||||
</Hyperlink>
|
||||
</div>
|
||||
)}
|
||||
<PostFooter post={post} preview={preview} />
|
||||
{displayPostFooter && <PostFooter post={post} userHasModerationPrivileges={userHasModerationPrivileges} />}
|
||||
<ClosePostReasonModal
|
||||
isOpen={isClosing}
|
||||
onCancel={hideClosePostModal}
|
||||
@@ -186,12 +188,7 @@ function Post({
|
||||
Post.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
preview: PropTypes.bool,
|
||||
handleAddResponseButton: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Post.defaultProps = {
|
||||
preview: false,
|
||||
};
|
||||
|
||||
export default injectIntl(Post);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
@@ -9,20 +10,19 @@ import {
|
||||
import { Locked, People } from '@edx/paragon/icons';
|
||||
|
||||
import { StarFilled, StarOutline } from '../../../components/icons';
|
||||
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import { updateExistingThread } from '../data/thunks';
|
||||
import LikeButton from './LikeButton';
|
||||
import messages from './messages';
|
||||
import { postShape } from './proptypes';
|
||||
|
||||
function PostFooter({
|
||||
post,
|
||||
intl,
|
||||
post,
|
||||
userHasModerationPrivileges,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
return (
|
||||
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ lineHeight: '32px' }}>
|
||||
<div className="d-flex align-items-center ml-n1.5 mt-10px" style={{ height: '32px' }} data-testid="post-footer">
|
||||
{post.voteCount !== 0 && (
|
||||
<LikeButton
|
||||
count={post.voteCount}
|
||||
@@ -83,6 +83,7 @@ function PostFooter({
|
||||
>
|
||||
<Icon
|
||||
src={Locked}
|
||||
className="text-primary-500"
|
||||
style={{
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
@@ -99,7 +100,7 @@ function PostFooter({
|
||||
PostFooter.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
post: postShape.isRequired,
|
||||
|
||||
userHasModerationPrivileges: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PostFooter);
|
||||
|
||||
@@ -14,11 +14,11 @@ import PostFooter from './PostFooter';
|
||||
|
||||
let store;
|
||||
|
||||
function renderComponent(post, preview = false, showNewCountLabel = false) {
|
||||
function renderComponent(post, userHasModerationPrivileges = false) {
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<PostFooter post={post} preview={preview} showNewCountLabel={showNewCountLabel} />
|
||||
<PostFooter post={post} userHasModerationPrivileges={userHasModerationPrivileges} />
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
@@ -64,23 +64,11 @@ describe('PostFooter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("doesn't have 'new' badge when there are 0 new comments", () => {
|
||||
renderComponent({ ...mockPost, unreadCommentCount: 0 });
|
||||
expect(screen.queryByText('2 New')).toBeFalsy();
|
||||
expect(screen.queryByText('0 New')).toBeFalsy();
|
||||
});
|
||||
|
||||
it("doesn't has 'new' badge when the new-unread item is the post itself", () => {
|
||||
// commentCount === 1 means it's just the post without any further comments
|
||||
renderComponent({ ...mockPost, unreadCommentCount: 1, commentCount: 1 });
|
||||
expect(screen.queryByText('1 New')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('has the cohort icon only when group information is present', () => {
|
||||
renderComponent(mockPost);
|
||||
expect(screen.queryByTestId('cohort-icon')).toBeFalsy();
|
||||
|
||||
renderComponent({ ...mockPost, groupId: 5, groupName: 'Test Cohort' });
|
||||
renderComponent({ ...mockPost, groupId: 5, groupName: 'Test Cohort' }, true);
|
||||
expect(screen.getByTestId('cohort-icon')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -104,4 +92,24 @@ describe('PostFooter', () => {
|
||||
renderComponent({ ...mockPost, following: false });
|
||||
expect(screen.queryByRole('button', { name: /follow/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tests like button when voteCount is zero', async () => {
|
||||
renderComponent({ ...mockPost, voteCount: 0 });
|
||||
expect(screen.queryByRole('button', { name: /like/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tests like button when voteCount is not zero', async () => {
|
||||
renderComponent({ ...mockPost, voted: true, voteCount: 4 });
|
||||
const likeButton = screen.getByRole('button', { name: /like/i });
|
||||
await act(async () => {
|
||||
fireEvent.mouseEnter(likeButton);
|
||||
});
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(/unlike/i);
|
||||
await act(async () => {
|
||||
fireEvent.click(likeButton);
|
||||
});
|
||||
// clicking on the button triggers thread update.
|
||||
expect(store.getState().threads.status === RequestStatus.IN_PROGRESS).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user