Compare commits

..

1 Commits

Author SHA1 Message Date
Awais Ansari
e216d847eb feat: added cohort update option in post edit 2025-12-18 16:39:24 +05:00
16 changed files with 1403 additions and 3852 deletions

4998
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -75,7 +75,7 @@ const HoverCard = ({
const actionFunction = actionHandlers[endorseIcons.action];
actionFunction();
}}
className="text-primary"
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
size="sm"
alt="Endorse"
/>

View File

@@ -14,5 +14,5 @@ export const selectLearnerSorting = () => state => state.learners.sortedBy;
export const selectLearnerNextPage = () => state => state.learners.nextPage;
export const selectLearnerAvatar = author => state => (
state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall
state.learners.learnerProfiles[author]?.profileImage?.imageUrlLarge
);

View File

@@ -9,9 +9,7 @@ import {
} from 'react-router-dom';
import { Factory } from 'rosie';
import {
camelCaseObject, getConfig, initializeMockApp, setConfig,
} from '@edx/frontend-platform';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -740,20 +738,6 @@ describe('ThreadView', () => {
expect(screen.queryByTestId('comment-comment-4'))
.toBeInTheDocument();
});
it('it show avatar for reply author when ENABLE_PROFILE_IMAGE is true', async () => {
setConfig({
...getConfig(),
ENABLE_PROFILE_IMAGE: 'true',
});
await waitFor(() => renderComponent(discussionPostId));
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
expect(comment).toBeInTheDocument();
const replyAuthorAvatar = within(comment).getAllByAltText('edx');
expect(replyAuthorAvatar.length).toBeGreaterThan(0);
});
});
describe('for question thread', () => {

View File

@@ -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, users: commentUsers,
editByLabel, closedByLabel, users: postUsers,
} = comment;
const intl = useIntl();
const hasChildren = childCount > 0;
@@ -209,7 +209,7 @@ const Comment = ({
closed={closed}
createdAt={createdAt}
lastEdit={lastEdit}
commentUsers={commentUsers}
postUsers={postUsers}
/>
{isEditing ? (
<CommentEditor
@@ -226,7 +226,7 @@ const Comment = ({
/>
) : (
<HTMLLoader
cssClassName="comment-body html-loader text-break mt-14px font-style text-gray-700"
cssClassName="comment-body html-loader text-break mt-14px font-style text-primary-500"
componentId="comment"
htmlNode={renderedBody}
testId={id}

View File

@@ -19,7 +19,7 @@ const CommentHeader = ({
closed,
createdAt,
lastEdit,
commentUsers,
postUsers,
}) => {
const colorClass = AvatarOutlineAndLabelColors[authorLabel];
const hasAnyAlert = useAlertBannerVisible({
@@ -31,7 +31,7 @@ const CommentHeader = ({
const authorAvatar = useSelector(selectAuthorAvatar(author));
const profileImage = getConfig()?.ENABLE_PROFILE_IMAGE === 'true'
? Object.values(commentUsers ?? {})[0]?.profile?.image
? Object.values(postUsers ?? {})[0]?.profile?.image
: null;
return (
@@ -43,7 +43,7 @@ const CommentHeader = ({
<Avatar
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={author}
src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatar?.imageUrlSmall}
src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatar}
style={{
width: '32px',
height: '32px',
@@ -72,7 +72,7 @@ CommentHeader.propTypes = {
editorUsername: PropTypes.string,
reason: PropTypes.string,
}),
commentUsers: PropTypes.shape({}).isRequired,
postUsers: PropTypes.shape({}).isRequired,
};
CommentHeader.defaultProps = {

View File

@@ -22,7 +22,7 @@ const defaultProps = {
closed: false,
createdAt: '2025-09-23T10:00:00Z',
lastEdit: null,
commentUsers: {
postUsers: {
'test-user': {
profile: { image: { hasImage: true, imageUrlSmall: 'http://avatar.test/img.png' } },
},

View File

@@ -5,7 +5,6 @@ import { Avatar, useToggle } from '@openedx/paragon';
import { useDispatch, useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import HTMLLoader from '../../../../components/HTMLLoader';
@@ -25,7 +24,7 @@ import CommentEditor from './CommentEditor';
const Reply = ({ responseId }) => {
timeago.register('time-locale', timeLocale);
const {
id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy, users: replyUsers,
id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy,
closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel,
} = useSelector(selectCommentOrResponseById(responseId));
const intl = useIntl();
@@ -79,10 +78,6 @@ const Reply = ({ responseId }) => {
[ContentActions.REPORT]: handleAbusedFlag,
}), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleAbusedFlag]);
const profileImage = getConfig()?.ENABLE_PROFILE_IMAGE === 'true'
? Object.values(replyUsers ?? {})[0]?.profile?.image
: null;
return (
<div className="d-flex flex-column mt-2.5 " data-testid={`reply-${id}`} role="listitem">
<Confirmation
@@ -128,7 +123,7 @@ const Reply = ({ responseId }) => {
<Avatar
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={author}
src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatar?.imageUrlSmall}
src={authorAvatar?.imageUrlSmall}
style={{
width: '32px',
height: '32px',
@@ -173,7 +168,7 @@ const Reply = ({ responseId }) => {
<HTMLLoader
componentId="reply"
htmlNode={renderedBody}
cssClassName="html-loader text-break font-style text-gray-700"
cssClassName="html-loader text-break font-style text-primary-500"
testId={id}
/>
)}

View File

@@ -30,16 +30,6 @@ Factory.define('comment')
parent_id: null,
children: [],
abuse_flagged_any_user: false,
users: {
edx: {
profile: {
image: {
hasImage: true,
imageUrlSmall: 'http://test.site/default-avatar-small.png',
},
},
},
},
});
Factory.define('commentsResult')

View File

@@ -97,7 +97,7 @@ export const postThread = async (
content,
{
following,
cohort,
groupId,
anonymous,
anonymousToPeers,
notifyAllLearners,
@@ -114,7 +114,7 @@ export const postThread = async (
following,
anonymous,
anonymousToPeers,
groupId: cohort,
groupId,
enableInContextSidebar,
notifyAllLearners,
captchaToken: recaptchaToken,
@@ -154,6 +154,7 @@ export const updateThread = async (threadId, {
pinned,
editReasonCode,
closeReasonCode,
groupId,
} = {}) => {
const url = `${getThreadsApiUrl()}${threadId}/`;
const patchData = snakeCaseObject({
@@ -169,6 +170,7 @@ export const updateThread = async (threadId, {
pinned,
editReasonCode,
closeReasonCode,
groupId,
});
const { data } = await getAuthenticatedHttpClient()
.patch(url, patchData, { headers: { 'Content-Type': 'application/merge-patch+json' } });

View File

@@ -208,7 +208,7 @@ export function createNewThread({
following,
anonymous,
anonymousToPeers,
cohort,
groupId,
enableInContextSidebar,
notifyAllLearners,
recaptchaToken,
@@ -224,12 +224,12 @@ export function createNewThread({
following,
anonymous,
anonymousToPeers,
cohort,
groupId,
notifyAllLearners,
recaptchaToken,
}));
const data = await postThread(courseId, topicId, type, title, content, {
cohort,
groupId,
following,
anonymous,
anonymousToPeers,
@@ -252,7 +252,8 @@ export function createNewThread({
}
export function updateExistingThread(threadId, {
flagged, voted, read, topicId, type, title, content, following, closed, pinned, closeReasonCode, editReasonCode,
flagged, voted, read, topicId, type, title, content, following,
closed, pinned, closeReasonCode, editReasonCode, groupId,
}) {
return async (dispatch) => {
try {
@@ -270,6 +271,7 @@ export function updateExistingThread(threadId, {
pinned,
editReasonCode,
closeReasonCode,
groupId,
}));
const data = await updateThread(threadId, {
flagged,
@@ -284,6 +286,7 @@ export function updateExistingThread(threadId, {
pinned,
editReasonCode,
closeReasonCode,
groupId,
});
dispatch(updateThreadSuccess(camelCaseObject(data)));
} catch (error) {

View File

@@ -140,7 +140,7 @@ const PostEditor = ({
notifyAllLearners: false,
anonymous: allowAnonymous ? false : undefined,
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
cohort: post?.cohort || 'default',
cohort: post?.groupId || 'default',
editReasonCode: post?.lastEdit?.reasonCode || (
userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined
),
@@ -175,6 +175,8 @@ const PostEditor = ({
const submitForm = useCallback(async (values, { resetForm }) => {
let recaptchaToken;
const groupId = canSelectCohort(values.topic) ? selectedCohort(values.cohort) : undefined;
if (shouldRequireCaptcha && executeRecaptcha) {
try {
recaptchaToken = await executeRecaptcha('submit_post');
@@ -196,10 +198,9 @@ const PostEditor = ({
title: values.title,
content: values.comment,
editReasonCode: values.editReasonCode || undefined,
groupId,
}));
} else {
const cohort = canSelectCohort(values.topic) ? selectedCohort(values.cohort) : undefined;
await dispatchSubmit(createNewThread({
courseId,
topicId: values.topic,
@@ -209,7 +210,7 @@ const PostEditor = ({
following: values.follow,
anonymous: allowAnonymous ? values.anonymous : undefined,
anonymousToPeers: allowAnonymousToPeers ? values.anonymousToPeers : undefined,
cohort,
groupId,
enableInContextSidebar,
notifyAllLearners: values.notifyAllLearners,
...(shouldRequireCaptcha && recaptchaToken ? { recaptchaToken } : {}),

View File

@@ -143,7 +143,6 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
onClose={hideDeleteConfirmation}
confirmAction={handleDeleteConfirmation}
closeButtonVariant="tertiary"
confirmButtonVariant="danger"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
{!abuseFlagged && (
@@ -153,6 +152,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
description={intl.formatMessage(messages.reportPostDescription)}
onClose={hideReportConfirmation}
confirmAction={handleReportConfirmation}
confirmButtonVariant="danger"
/>
)}
<HoverCard
@@ -189,7 +189,7 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => {
title={title}
postUsers={postUsers}
/>
<div className="d-flex mt-14px text-break font-style text-gray-700">
<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} />
</div>
{(topicContext || topic) && (

View File

@@ -1,157 +0,0 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { useSelector } from 'react-redux';
import Post from './Post';
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => jest.fn(),
useSelector: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({ pathname: '/test' }),
useNavigate: () => jest.fn(),
}));
jest.mock('@edx/frontend-platform/i18n', () => ({
useIntl: () => ({
formatMessage: (msg) => ((msg && msg.defaultMessage) ? msg.defaultMessage : 'test-message'),
}),
defineMessages: (msgs) => msgs,
}));
jest.mock('@openedx/paragon', () => {
const actual = jest.requireActual('@openedx/paragon');
// eslint-disable-next-line global-require
const PropTypes = require('prop-types');
const MockHyperlink = ({ children }) => <div>{children}</div>;
MockHyperlink.propTypes = { children: PropTypes.node.isRequired };
return {
...actual,
Hyperlink: MockHyperlink,
useToggle: actual.useToggle,
};
});
jest.mock('../../common', () => {
// eslint-disable-next-line global-require
const PropTypes = require('prop-types');
const MockConfirmation = ({ confirmButtonVariant, isOpen }) => {
if (!isOpen) { return null; }
return (
<div data-testid="mock-confirmation" data-variant={confirmButtonVariant}>
Mock Confirmation
</div>
);
};
MockConfirmation.propTypes = {
confirmButtonVariant: PropTypes.string,
isOpen: PropTypes.bool.isRequired,
};
// eslint-disable-next-line react/prop-types
const MockAlertBanner = () => <div />;
return {
Confirmation: MockConfirmation,
AlertBanner: MockAlertBanner,
};
});
jest.mock('./PostHeader', () => function MockPostHeader() { return <div>PostHeader</div>; });
jest.mock('./PostFooter', () => function MockPostFooter() { return <div>PostFooter</div>; });
jest.mock('./ClosePostReasonModal', () => function MockCloseModal() { return <div />; });
jest.mock('../../../components/HTMLLoader', () => function MockLoader() { return <div>Body Content</div>; });
jest.mock('../../common/HoverCard', () => {
// eslint-disable-next-line global-require
const { ContentActions } = require('../../../data/constants');
// eslint-disable-next-line global-require
const PropTypes = require('prop-types');
const MockHoverCard = ({ actionHandlers }) => (
<div>
<button
type="button"
data-testid="trigger-delete"
onClick={() => actionHandlers[ContentActions.DELETE] && actionHandlers[ContentActions.DELETE]()}
>
Delete Post
</button>
<button
type="button"
data-testid="trigger-report"
onClick={() => actionHandlers[ContentActions.REPORT] && actionHandlers[ContentActions.REPORT]()}
>
Report Post
</button>
</div>
);
MockHoverCard.propTypes = {
actionHandlers: PropTypes.shape({}).isRequired,
};
return MockHoverCard;
});
describe('Post Component - Delete/Report Confirmation', () => {
const mockPostId = '123';
beforeEach(() => {
useSelector.mockReturnValue({
topicId: 'topic-1',
abuseFlagged: false,
closed: false,
pinned: false,
voted: false,
following: false,
author: {},
title: 'Test Post',
renderedBody: '<div>Hello</div>',
users: {},
});
});
const renderPost = () => {
// eslint-disable-next-line global-require
const DiscussionContext = require('../../common/context').default;
return render(
<DiscussionContext.Provider value={{ postId: mockPostId, enableInContextSidebar: false, courseId: 'course-1' }}>
<Post
handleAddResponseButton={jest.fn()}
openRestrictionDialogue={jest.fn()}
/>
</DiscussionContext.Provider>,
);
};
it('passes "danger" variant to Confirmation modal when deleting a post', () => {
renderPost();
const deleteBtn = screen.getByTestId('trigger-delete');
fireEvent.click(deleteBtn);
const confirmation = screen.getByTestId('mock-confirmation');
expect(confirmation).toHaveAttribute('data-variant', 'danger');
});
it('does NOT pass "danger" variant to Confirmation modal when reporting a post', () => {
renderPost();
const reportBtn = screen.getByTestId('trigger-report');
fireEvent.click(reportBtn);
const confirmation = screen.getByTestId('mock-confirmation');
expect(confirmation).not.toHaveAttribute('data-variant', 'danger');
});
});

View File

@@ -126,7 +126,7 @@ const PostHeader = ({
</div>
) : (
<h5
className="mb-0 font-style"
className="mb-0 font-style text-primary-500"
style={{ lineHeight: '21px' }}
aria-level="1"
tabIndex="-1"

View File

@@ -1,7 +1,5 @@
import PropTypes from 'prop-types';
import { mergeConfig } from '@edx/frontend-platform';
import '@testing-library/jest-dom/extend-expect';
import 'babel-polyfill';
@@ -68,10 +66,3 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({
}));
jest.setTimeout(1000000);
mergeConfig({
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 || 'false',
}, 'DiscussionsConfig');