fix: response and comment UI according to Figma (#220)
* fix: response and comment UI according to Figma * test: add test cases for endorse alert banner
This commit is contained in:
@@ -67,12 +67,12 @@ function DiscussionCommentsView({
|
||||
} = usePostComments(postId, endorsed);
|
||||
return (
|
||||
<>
|
||||
<div className="m-3" role="heading" aria-level="2">
|
||||
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
|
||||
{endorsed === EndorsementStatus.ENDORSED
|
||||
? intl.formatMessage(messages.endorsedResponseCount, { num: comments.length })
|
||||
: intl.formatMessage(messages.responseCount, { num: comments.length })}
|
||||
</div>
|
||||
<div className="mx-3" role="list">
|
||||
<div className="mx-4" role="list">
|
||||
{comments.map(comment => (
|
||||
<Comment comment={comment} key={comment.id} postType={postType} />
|
||||
))}
|
||||
|
||||
@@ -4,16 +4,18 @@ import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import timeLocale from '../../common/time-locale';
|
||||
import LikeButton from '../../posts/post/LikeButton';
|
||||
import { editComment } from '../data/thunks';
|
||||
|
||||
function CommentIcons({
|
||||
comment,
|
||||
intl,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
timeago.register('time-locale', timeLocale);
|
||||
|
||||
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
|
||||
return (
|
||||
<div className="d-flex flex-row align-items-center">
|
||||
@@ -22,8 +24,8 @@ function CommentIcons({
|
||||
onClick={handleLike}
|
||||
voted={comment.voted}
|
||||
/>
|
||||
<div className="d-flex flex-fill text-gray-500 justify-content-end mt-2" title={comment.createdAt}>
|
||||
{timeago.format(comment.createdAt, intl.locale)}
|
||||
<div className="d-flex flex-fill text-gray-500 justify-content-end" title={comment.createdAt}>
|
||||
{timeago.format(comment.createdAt, 'time-locale')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -37,7 +39,6 @@ CommentIcons.propTypes = {
|
||||
voted: PropTypes.bool,
|
||||
createdAt: PropTypes.string,
|
||||
}).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CommentIcons);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button, useToggle } from '@edx/paragon';
|
||||
|
||||
import HTMLLoader from '../../../components/HTMLLoader';
|
||||
import { ContentActions } from '../../../data/constants';
|
||||
import { AlertBanner, DeleteConfirmation } from '../../common';
|
||||
import { AlertBanner, DeleteConfirmation, EndorsedAlertBanner } from '../../common';
|
||||
import { fetchThread } from '../../posts/data/thunks';
|
||||
import CommentIcons from '../comment-icons/CommentIcons';
|
||||
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
|
||||
@@ -35,12 +35,14 @@ function Comment({
|
||||
const [isReplying, setReplying] = useState(false);
|
||||
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
|
||||
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
|
||||
|
||||
useEffect(() => {
|
||||
// If the comment has a parent comment, it won't have any children, so don't fetch them.
|
||||
if (hasChildren && !currentPage && showFullThread) {
|
||||
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
|
||||
}
|
||||
}, [comment.id]);
|
||||
|
||||
const actionHandlers = {
|
||||
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
|
||||
[ContentActions.ENDORSE]: async () => {
|
||||
@@ -50,79 +52,82 @@ function Comment({
|
||||
[ContentActions.DELETE]: showDeleteConfirmation,
|
||||
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
|
||||
};
|
||||
|
||||
const handleLoadMoreComments = () => (
|
||||
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
|
||||
);
|
||||
const commentClasses = classNames('d-flex flex-column card', { 'my-3': showFullThread });
|
||||
|
||||
return (
|
||||
<div className={commentClasses} data-testid={`comment-${comment.id}`} role="listitem">
|
||||
<DeleteConfirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||
description={intl.formatMessage(messages.deleteResponseDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeComment(comment.id));
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
/>
|
||||
<AlertBanner postType={postType} content={comment} />
|
||||
<div className="d-flex flex-column px-4 pb-4">
|
||||
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
|
||||
{isEditing
|
||||
? (
|
||||
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} />
|
||||
)
|
||||
: <HTMLLoader cssClassName="comment-body px-2" componentId="comment" htmlNode={comment.renderedBody} />}
|
||||
<CommentIcons
|
||||
comment={comment}
|
||||
following={comment.following}
|
||||
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
||||
createdAt={comment.createdAt}
|
||||
<div className={classNames({ 'py-2 my-3': showFullThread })}>
|
||||
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
|
||||
<DeleteConfirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteResponseTitle)}
|
||||
description={intl.formatMessage(messages.deleteResponseDescription)}
|
||||
onClose={hideDeleteConfirmation}
|
||||
onDelete={() => {
|
||||
dispatch(removeComment(comment.id));
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
/>
|
||||
<div className="sr-only" role="heading" aria-level="3"> {intl.formatMessage(messages.replies, { count: inlineReplies.length })}</div>
|
||||
<div className="d-flex my-2 flex-column" role="list">
|
||||
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
|
||||
{inlineReplies.map(inlineReply => (
|
||||
<Reply
|
||||
reply={inlineReply}
|
||||
postType={postType}
|
||||
key={inlineReply.id}
|
||||
intl={intl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMorePages && (
|
||||
<EndorsedAlertBanner postType={postType} content={comment} />
|
||||
<div className="d-flex flex-column p-4.5">
|
||||
<AlertBanner content={comment} />
|
||||
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
|
||||
{isEditing
|
||||
? (
|
||||
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} />
|
||||
)
|
||||
: <HTMLLoader cssClassName="comment-body pt-4 text-primary-500" componentId="comment" htmlNode={comment.renderedBody} />}
|
||||
<CommentIcons
|
||||
comment={comment}
|
||||
following={comment.following}
|
||||
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
|
||||
createdAt={comment.createdAt}
|
||||
/>
|
||||
<div className="sr-only" role="heading" aria-level="3"> {intl.formatMessage(messages.replies, { count: inlineReplies.length })}</div>
|
||||
<div className="d-flex flex-column" role="list">
|
||||
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
|
||||
{inlineReplies.map(inlineReply => (
|
||||
<Reply
|
||||
reply={inlineReply}
|
||||
postType={postType}
|
||||
key={inlineReply.id}
|
||||
intl={intl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMorePages && (
|
||||
<Button
|
||||
onClick={handleLoadMoreComments}
|
||||
variant="link"
|
||||
block="true"
|
||||
className="my-4"
|
||||
className="mt-4.5 font-size-14 font-style-normal font-family-inter font-weight-500 px-2.5 py-2"
|
||||
data-testid="load-more-comments-responses"
|
||||
style={{
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.loadMoreResponses)}
|
||||
</Button>
|
||||
)}
|
||||
{!isNested && showFullThread
|
||||
&& (
|
||||
isReplying
|
||||
? (
|
||||
<CommentEditor
|
||||
comment={{
|
||||
threadId: comment.threadId,
|
||||
parentId: comment.id,
|
||||
}}
|
||||
edit={false}
|
||||
onCloseEditor={() => setReplying(false)}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Button className="d-flex flex-grow " variant="outline-secondary" onClick={() => setReplying(true)}>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{!isNested && showFullThread && (
|
||||
isReplying ? (
|
||||
<CommentEditor
|
||||
comment={{
|
||||
threadId: comment.threadId,
|
||||
parentId: comment.id,
|
||||
}}
|
||||
edit={false}
|
||||
onCloseEditor={() => setReplying(false)}
|
||||
/>
|
||||
) : (
|
||||
<Button className="d-flex flex-grow mt-4.5" variant="outline-primary" onClick={() => setReplying(true)}>
|
||||
{intl.formatMessage(messages.addComment)}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl } from '@edx/frontend-platform/i18n';
|
||||
@@ -10,6 +11,7 @@ import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
|
||||
import { AuthorLabel } from '../../common';
|
||||
import ActionsDropdown from '../../common/ActionsDropdown';
|
||||
import { useAlertBannerVisible } from '../../data/hooks';
|
||||
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||
import { commentShape } from './proptypes';
|
||||
|
||||
@@ -20,20 +22,31 @@ function CommentHeader({
|
||||
}) {
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
|
||||
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(comment);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row justify-content-between">
|
||||
<div className={classNames('d-flex flex-row justify-content-between', {
|
||||
'mt-2': hasAnyAlert,
|
||||
})}
|
||||
>
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<Avatar
|
||||
className={`m-2 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={comment.author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
}}
|
||||
/>
|
||||
<AuthorLabel author={comment.author} authorLabel={comment.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
|
||||
</div>
|
||||
<div className="d-flex align-items-center">
|
||||
{comment.endorsed && (postType === 'question'
|
||||
? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" />
|
||||
: <Icon src={Verified} data-testid="verified-icon" />)}
|
||||
<span className="btn-icon btn-icon-sm mr-1 align-items-center">
|
||||
{comment.endorsed && (postType === 'question'
|
||||
? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" />
|
||||
: <Icon src={Verified} className="text-dark-500" data-testid="verified-icon" />)}
|
||||
</span>
|
||||
<ActionsDropdown
|
||||
commentOrPost={{
|
||||
...comment,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/const
|
||||
import {
|
||||
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
|
||||
} from '../../common';
|
||||
import timeLocale from '../../common/time-locale';
|
||||
import { useAlertBannerVisible } from '../../data/hooks';
|
||||
import { selectAuthorAvatars } from '../../posts/data/selectors';
|
||||
import { editComment, removeComment } from '../data/thunks';
|
||||
import messages from '../messages';
|
||||
@@ -23,6 +25,7 @@ function Reply({
|
||||
postType,
|
||||
intl,
|
||||
}) {
|
||||
timeago.register('time-locale', timeLocale);
|
||||
const dispatch = useDispatch();
|
||||
const [isEditing, setEditing] = useState(false);
|
||||
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
|
||||
@@ -34,8 +37,10 @@ function Reply({
|
||||
};
|
||||
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
|
||||
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(reply);
|
||||
|
||||
return (
|
||||
<div className="d-flex my-2 flex-column" data-testid={`reply-${reply.id}`} role="listitem">
|
||||
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
|
||||
<DeleteConfirmation
|
||||
isOpen={isDeleting}
|
||||
title={intl.formatMessage(messages.deleteCommentTitle)}
|
||||
@@ -47,26 +52,31 @@ function Reply({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="d-flex">
|
||||
<div className="d-flex mx-3 invisible">
|
||||
<Avatar className="m-2" />
|
||||
{hasAnyAlert && (
|
||||
<div className="d-flex">
|
||||
<div className="d-flex invisible">
|
||||
<Avatar />
|
||||
</div>
|
||||
<div className="w-100">
|
||||
<AlertBanner content={reply} intl={intl} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-100">
|
||||
<AlertBanner postType={null} content={reply} intl={intl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="d-flex">
|
||||
<div className="d-flex m-3">
|
||||
<div className="d-flex mr-3 mt-2.5">
|
||||
<Avatar
|
||||
className={`m-2 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
style={{ borderWidth: '2px' }}
|
||||
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
|
||||
alt={reply.author}
|
||||
src={authorAvatars?.imageUrlSmall}
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded bg-light-300 px-4 py-2 flex-fill">
|
||||
<div className="d-flex flex-row justify-content-between align-items-center">
|
||||
<div className="rounded bg-light-300 px-4 pb-2 pt-2.5 flex-fill">
|
||||
<div className="d-flex flex-row justify-content-between align-items-center mb-0.5">
|
||||
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
|
||||
<ActionsDropdown
|
||||
commentOrPost={{
|
||||
@@ -78,11 +88,11 @@ function Reply({
|
||||
</div>
|
||||
{isEditing
|
||||
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
|
||||
: <HTMLLoader componentId="reply" htmlNode={reply.renderedBody} />}
|
||||
: <HTMLLoader componentId="reply" htmlNode={reply.renderedBody} cssClassName="text-primary-500" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
|
||||
{timeago.format(reply.createdAt, intl.locale)}
|
||||
{timeago.format(reply.createdAt, 'time-locale')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -33,16 +33,15 @@ function ActionsDropdown({
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<span ref={dropdownIconRef}>
|
||||
<IconButton
|
||||
onClick={() => setOpen(!isOpen)}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
<IconButton
|
||||
onClick={() => setOpen(!isOpen)}
|
||||
alt={intl.formatMessage(messages.actionsAlt)}
|
||||
src={MoreHoriz}
|
||||
iconAs={Icon}
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
ref={dropdownIconRef}
|
||||
/>
|
||||
<ModalPopup
|
||||
onClose={() => setOpen(false)}
|
||||
positionRef={dropdownIconRef}
|
||||
|
||||
@@ -2,13 +2,11 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { CheckCircle, Error, Verified } from '@edx/paragon/icons';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import messages from '../comments/messages';
|
||||
import { selectModerationSettings, selectUserIsPrivileged } from '../data/selectors';
|
||||
@@ -18,50 +16,19 @@ import AuthorLabel from './AuthorLabel';
|
||||
function AlertBanner({
|
||||
intl,
|
||||
content,
|
||||
postType,
|
||||
}) {
|
||||
const isQuestion = postType === ThreadType.QUESTION;
|
||||
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
|
||||
const iconClass = isQuestion ? CheckCircle : Verified;
|
||||
const userIsPrivileged = useSelector(selectUserIsPrivileged);
|
||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||
|
||||
return (
|
||||
<>
|
||||
{content.endorsed && (
|
||||
<Alert
|
||||
variant="plain"
|
||||
className={`px-3 mb-4.5 py-10px align-items-center shadow-none ${classes}`}
|
||||
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
|
||||
icon={iconClass}
|
||||
>
|
||||
<div className="d-flex justify-content-between">
|
||||
<strong className="lead">{intl.formatMessage(
|
||||
isQuestion
|
||||
? messages.answer
|
||||
: messages.endorsed,
|
||||
)}
|
||||
</strong>
|
||||
<span className="d-flex align-items-center mr-1">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(
|
||||
isQuestion
|
||||
? messages.answeredLabel
|
||||
: messages.endorsedLabel,
|
||||
)}
|
||||
</span>
|
||||
<AuthorLabel author={content.endorsedBy} authorLabel={content.endorsedByLabel} />
|
||||
{timeago.format(content.endorsedAt, intl.locale)}
|
||||
</span>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
{content.abuseFlagged && (
|
||||
<Alert icon={Error} variant="danger" className="px-3 mb-4 py-10px shadow-none flex-fill">
|
||||
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
|
||||
{intl.formatMessage(messages.abuseFlaggedMessage)}
|
||||
</Alert>
|
||||
)}
|
||||
{reasonCodesEnabled && userIsPrivileged && content.lastEdit?.reason && (
|
||||
<Alert variant="info" className="px-3 shadow-none mb-4 py-10px bg-light-200">
|
||||
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
|
||||
<div className="d-flex align-items-center">
|
||||
{intl.formatMessage(messages.editedBy)}
|
||||
<span className="ml-1 mr-3">
|
||||
@@ -72,7 +39,7 @@ function AlertBanner({
|
||||
</Alert>
|
||||
)}
|
||||
{reasonCodesEnabled && content.closed && (
|
||||
<Alert variant="info" className="px-3 shadow-none mb-4 py-10px bg-light-200">
|
||||
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
|
||||
<div className="d-flex align-items-center">
|
||||
{intl.formatMessage(messages.closedBy)}
|
||||
<span className="ml-1 ">
|
||||
@@ -90,11 +57,6 @@ function AlertBanner({
|
||||
AlertBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
|
||||
postType: PropTypes.string,
|
||||
};
|
||||
|
||||
AlertBanner.defaultProps = {
|
||||
postType: null,
|
||||
};
|
||||
|
||||
export default injectIntl(AlertBanner);
|
||||
|
||||
@@ -22,14 +22,12 @@ function buildTestContent(type, buildParams) {
|
||||
|
||||
function renderComponent(
|
||||
content,
|
||||
postType,
|
||||
) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<AlertBanner
|
||||
content={content}
|
||||
postType={postType}
|
||||
/>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
@@ -44,27 +42,6 @@ describe.each([
|
||||
props: { abuseFlagged: true },
|
||||
expectText: [messages.abuseFlaggedMessage.defaultMessage],
|
||||
},
|
||||
{
|
||||
label: 'Staff endorsed comment in a question thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
|
||||
},
|
||||
{
|
||||
label: 'TA endorsed comment in a question thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
|
||||
},
|
||||
{
|
||||
label: 'endorsed comment in a discussion thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.DISCUSSION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user' },
|
||||
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
|
||||
},
|
||||
{
|
||||
label: 'flagged thread',
|
||||
type: 'thread',
|
||||
@@ -87,7 +64,7 @@ describe.each([
|
||||
expectText: [messages.closedBy.defaultMessage, 'closing-user', 'test-close-reason'],
|
||||
},
|
||||
])('AlertBanner', ({
|
||||
label, type, postType, props, expectText,
|
||||
label, type, props, expectText,
|
||||
}) => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
@@ -105,7 +82,7 @@ describe.each([
|
||||
},
|
||||
});
|
||||
const content = buildTestContent(type, props);
|
||||
renderComponent(content, postType);
|
||||
renderComponent(content);
|
||||
});
|
||||
|
||||
it(`should show correct banner for a ${label}`, async () => {
|
||||
|
||||
@@ -34,13 +34,14 @@ function AuthorLabel({
|
||||
authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
|
||||
}
|
||||
|
||||
const fontWeight = authorLabelMessage ? 'font-weight-500' : 'font-weight-normal text-primary-500';
|
||||
const className = classNames('d-flex align-items-center', labelColor);
|
||||
|
||||
const labelContents = (
|
||||
<div className={className}>
|
||||
<span
|
||||
className={`mr-1 font-size-14 font-style-normal font-family-inter ${fontWeight}`}
|
||||
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
||||
'text-primary-500': !authorLabelMessage,
|
||||
})}
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
>
|
||||
@@ -57,7 +58,9 @@ function AuthorLabel({
|
||||
)}
|
||||
{authorLabelMessage && (
|
||||
<span
|
||||
className={`mr-3 font-size-14 font-style-normal font-family-inter ${fontWeight}`}
|
||||
className={classNames('mr-3 font-size-14 font-style-normal font-family-inter font-weight-500', {
|
||||
'text-primary-500': !authorLabelMessage,
|
||||
})}
|
||||
style={{ marginLeft: '2px' }}
|
||||
>
|
||||
{authorLabelMessage}
|
||||
|
||||
66
src/discussions/common/EndorsedAlertBanner.jsx
Normal file
66
src/discussions/common/EndorsedAlertBanner.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import * as timeago from 'timeago.js';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { CheckCircle, Verified } from '@edx/paragon/icons';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { commentShape } from '../comments/comment/proptypes';
|
||||
import messages from '../comments/messages';
|
||||
import AuthorLabel from './AuthorLabel';
|
||||
|
||||
function EndorsedAlertBanner({
|
||||
intl,
|
||||
content,
|
||||
postType,
|
||||
}) {
|
||||
const isQuestion = postType === ThreadType.QUESTION;
|
||||
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
|
||||
const iconClass = isQuestion ? CheckCircle : Verified;
|
||||
|
||||
return (
|
||||
content.endorsed && (
|
||||
<Alert
|
||||
variant="plain"
|
||||
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
|
||||
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
|
||||
icon={iconClass}
|
||||
>
|
||||
<div className="d-flex justify-content-between">
|
||||
<strong className="lead">{intl.formatMessage(
|
||||
isQuestion
|
||||
? messages.answer
|
||||
: messages.endorsed,
|
||||
)}
|
||||
</strong>
|
||||
<span className="d-flex align-items-center mr-1">
|
||||
<span className="mr-2">
|
||||
{intl.formatMessage(
|
||||
isQuestion
|
||||
? messages.answeredLabel
|
||||
: messages.endorsedLabel,
|
||||
)}
|
||||
</span>
|
||||
<AuthorLabel author={content.endorsedBy} authorLabel={content.endorsedByLabel} />
|
||||
{timeago.format(content.endorsedAt, intl.locale)}
|
||||
</span>
|
||||
</div>
|
||||
</Alert>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
EndorsedAlertBanner.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
content: PropTypes.oneOfType([commentShape.isRequired]).isRequired,
|
||||
postType: PropTypes.string,
|
||||
};
|
||||
|
||||
EndorsedAlertBanner.defaultProps = {
|
||||
postType: null,
|
||||
};
|
||||
|
||||
export default injectIntl(EndorsedAlertBanner);
|
||||
87
src/discussions/common/EndorsedAlertBanner.test.jsx
Normal file
87
src/discussions/common/EndorsedAlertBanner.test.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import { ThreadType } from '../../data/constants';
|
||||
import { initializeStore } from '../../store';
|
||||
import messages from '../comments/messages';
|
||||
import EndorsedAlertBanner from './EndorsedAlertBanner';
|
||||
|
||||
import '../comments/data/__factories__';
|
||||
import '../posts/data/__factories__';
|
||||
|
||||
let store;
|
||||
|
||||
function buildTestContent(type, buildParams) {
|
||||
const buildParamsSnakeCase = snakeCaseObject(buildParams);
|
||||
return camelCaseObject(Factory.build(type, { ...buildParamsSnakeCase }, null));
|
||||
}
|
||||
|
||||
function renderComponent(
|
||||
content, postType,
|
||||
) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<EndorsedAlertBanner
|
||||
content={content}
|
||||
postType={postType}
|
||||
/>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe.each([
|
||||
{
|
||||
label: 'Staff endorsed comment in a question thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
|
||||
},
|
||||
{
|
||||
label: 'TA endorsed comment in a question thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.QUESTION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
|
||||
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
|
||||
},
|
||||
{
|
||||
label: 'endorsed comment in a discussion thread',
|
||||
type: 'comment',
|
||||
postType: ThreadType.DISCUSSION,
|
||||
props: { endorsed: true, endorsedBy: 'test-user' },
|
||||
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
|
||||
},
|
||||
])('EndorsedAlertBanner', ({
|
||||
label, type, postType, props, expectText,
|
||||
}) => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: false,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore({
|
||||
config: {
|
||||
userIsPrivileged: true,
|
||||
reasonCodesEnabled: true,
|
||||
},
|
||||
});
|
||||
const content = buildTestContent(type, props);
|
||||
renderComponent(content, postType);
|
||||
});
|
||||
|
||||
it(`should show correct banner for a ${label}`, async () => {
|
||||
expectText.forEach(message => {
|
||||
expect(screen.queryAllByText(message, { exact: false }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,3 +2,4 @@ export { default as ActionsDropdown } from './ActionsDropdown';
|
||||
export { default as AlertBanner } from './AlertBanner';
|
||||
export { default as AuthorLabel } from './AuthorLabel';
|
||||
export { default as DeleteConfirmation } from './DeleteConfirmation';
|
||||
export { default as EndorsedAlertBanner } from './EndorsedAlertBanner';
|
||||
|
||||
@@ -13,7 +13,9 @@ import { clearRedirect } from '../posts/data';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import { fetchCourseTopics } from '../topics/data/thunks';
|
||||
import { discussionsPath, postMessageToParent } from '../utils';
|
||||
import { selectAreThreadsFiltered, selectPostThreadCount } from './selectors';
|
||||
import {
|
||||
selectAreThreadsFiltered, selectModerationSettings, selectPostThreadCount, selectUserIsPrivileged,
|
||||
} from './selectors';
|
||||
import { fetchCourseConfig } from './thunks';
|
||||
|
||||
export function useTotalTopicThreadCount() {
|
||||
@@ -141,3 +143,14 @@ export function useContainerSizeForParent(refContainer) {
|
||||
};
|
||||
}, [refContainer, resizeObserver, location]);
|
||||
}
|
||||
|
||||
export const useAlertBannerVisible = (content) => {
|
||||
const userIsPrivileged = useSelector(selectUserIsPrivileged);
|
||||
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
|
||||
|
||||
return (
|
||||
(content.abuseFlagged)
|
||||
|| (reasonCodesEnabled && userIsPrivileged && content.lastEdit?.reason)
|
||||
|| (reasonCodesEnabled && content.closed)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ function Post({
|
||||
hideDeleteConfirmation();
|
||||
}}
|
||||
/>
|
||||
<AlertBanner postType={post.type} content={post} />
|
||||
<AlertBanner content={post} />
|
||||
<PostHeader post={post} actionHandlers={actionHandlers} />
|
||||
<div className="d-flex mt-4 mb-2 text-break font-style-normal text-primary-500">
|
||||
<HTMLLoader htmlNode={post.renderedBody} id="post" />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Avatar, Badge, Icon } from '@edx/paragon';
|
||||
import { Issue, Question } from '../../../components/icons';
|
||||
import { AvatarOutlineAndLabelColors, ThreadType } from '../../../data/constants';
|
||||
import { ActionsDropdown, AuthorLabel } from '../../common';
|
||||
import { useAlertBannerVisible } from '../../data/hooks';
|
||||
import { selectAuthorAvatars } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
import { postShape } from './proptypes';
|
||||
@@ -89,8 +90,10 @@ function PostHeader({
|
||||
}) {
|
||||
const showAnsweredBadge = preview && post.hasEndorsed && post.type === ThreadType.QUESTION;
|
||||
const authorLabelColor = AvatarOutlineAndLabelColors[post.authorLabel];
|
||||
const hasAnyAlert = useAlertBannerVisible(post);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-fill mw-100" style={{ height: '2.625rem' }}>
|
||||
<div className={classNames('d-flex flex-fill mw-100', { 'mt-2': hasAnyAlert && !preview })} style={{ height: '2.625rem' }}>
|
||||
<PostAvatar post={post} authorLabel={post.authorLabel} />
|
||||
<div className="align-items-center d-flex flex-row">
|
||||
<div className="d-flex flex-column justify-content-start mw-100">
|
||||
|
||||
@@ -63,6 +63,10 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.mb-0\.5 {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mt-0\.5 {
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -100,6 +104,6 @@ $fa-font-path: "~font-awesome/fonts";
|
||||
}
|
||||
|
||||
.avarat-img-position {
|
||||
margin-top: 17px;
|
||||
margin-left: 17px;
|
||||
margin-top: 17px;
|
||||
margin-left: 17px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user