Compare commits

..

5 Commits

Author SHA1 Message Date
Awais Ansari
16901009cb fix: profile picture is not showing in forum responses (#850) 2026-01-02 14:57:44 +05:00
Awais Ansari
52a4e5c94c fix: cohort name difference in filter and post view (#844) 2025-12-18 18:34:51 +05:00
Awais Ansari
29da3f5939 fix: fetch topics on MFE load (#843) 2025-12-18 18:34:27 +05:00
Tobias Macey
1268442415 fix: prevent generatePath error when author is invalid in AuthorLabel
Wrap learnerPostsLink creation in useMemo with guard to prevent
'Missing :learnerUsername param' error. The generatePath function
was being called unconditionally during render even when the link
wouldn't be displayed, causing errors when author was null, undefined,
or the 'anonymous' string.

The fix ensures generatePath is only called when showUserNameAsLink
is true, which validates that author is a valid username.
2025-11-14 12:26:00 -05:00
renovate[bot]
60996abdc5 chore(deps): update dependency @edx/frontend-platform to v8.5.2 (#775)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 12:26:00 -05:00
23 changed files with 2983 additions and 1235 deletions

View File

@@ -1,7 +0,0 @@
version: 2
updates:
# Adding new check for github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -12,11 +12,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Nodejs
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- name: Install dependencies

4029
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,9 +31,9 @@
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-platform": "^8.3.3",
"@edx/openedx-atlas": "^0.7.0",
"@edx/openedx-atlas": "^0.6.0",
"@openedx/paragon": "^23.4.5",
"@reduxjs/toolkit": "1.9.7",
"@tinymce/tinymce-react": "5.1.1",
@@ -41,7 +41,7 @@
"classnames": "2.5.1",
"core-js": "3.21.1",
"dompurify": "^2.4.3",
"formik": "2.4.9",
"formik": "2.4.5",
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
"raw-loader": "4.0.2",
@@ -56,20 +56,20 @@
"redux": "4.2.1",
"regenerator-runtime": "0.14.1",
"timeago.js": "4.0.2",
"tinymce": "5.10.9",
"tinymce": "5.10.7",
"yup": "0.32.11"
},
"devDependencies": {
"@edx/browserslist-config": "1.5.0",
"@edx/browserslist-config": "1.2.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "14.3.1",
"@testing-library/user-event": "13.5.0",
"axios": "^0.30.0",
"axios": "^0.28.0",
"axios-mock-adapter": "1.22.0",
"babel-plugin-react-intl": "8.2.25",
"eslint-plugin-simple-import-sort": "7.0.0",
"glob": "7.2.3",
"glob": "7.2.0",
"jest": "29.7.0",
"rosie": "2.1.1"
}

View File

@@ -175,7 +175,7 @@ const FilterBar = ({
<ActionItem
key={toString(cohort.id)}
id={toString(cohort.id)}
label={capitalize(cohort.name)}
label={cohort.name}
value={toString(cohort.id)}
selected={selectedFilters.cohort}
/>

View File

@@ -4,6 +4,7 @@ import {
} from 'react';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import isEmpty from 'lodash/isEmpty';
import { useDispatch, useSelector } from 'react-redux';
import {
matchPath, useLocation, useMatch, useNavigate,
@@ -20,10 +21,13 @@ import { ContentActions, RequestStatus, Routes } from '../../data/constants';
import { selectTopicsUnderCategory } from '../../data/selectors';
import fetchCourseBlocks from '../../data/thunks';
import DiscussionContext from '../common/context';
import { selectTopics as selectInContextTopics } from '../in-context-topics/data/selectors';
import fetchCourseTopicsV3 from '../in-context-topics/data/thunks';
import PostCommentsContext from '../post-comments/postCommentsContext';
import { clearRedirect } from '../posts/data';
import { threadsLoadingStatus } from '../posts/data/selectors';
import { selectTopics } from '../topics/data/selectors';
import fetchCourseTopics from '../topics/data/thunks';
import tourCheckpoints from '../tours/constants';
import selectTours from '../tours/data/selectors';
import { updateTourShowStatus } from '../tours/data/thunks';
@@ -32,6 +36,7 @@ import { checkPermissions, discussionsPath } from '../utils';
import { ContentSelectors } from './constants';
import {
selectAreThreadsFiltered,
selectDiscussionProvider,
selectEnableInContext,
selectIsPostingEnabled,
selectIsUserLearner,
@@ -104,6 +109,21 @@ export function useCourseBlockData(courseId) {
}, [courseId, isEnrolled, courseStatus, isUserLearner]);
}
export function useTopicsData(courseId, enableInContextSidebar) {
const dispatch = useDispatch();
const enableInContext = useSelector(selectEnableInContext);
const provider = useSelector(selectDiscussionProvider);
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
useEffect(() => {
if (isEmpty(topics) && provider) {
dispatch((enableInContext || enableInContextSidebar)
? fetchCourseTopicsV3(courseId)
: fetchCourseTopics(courseId));
}
}, [topics, provider, enableInContext, enableInContextSidebar]);
}
export function useRedirectToThread(courseId, enableInContextSidebar) {
const dispatch = useDispatch();
const navigate = useNavigate();

View File

@@ -16,7 +16,7 @@ import { ALL_ROUTES, DiscussionProvider, Routes as ROUTES } from '../../data/con
import DiscussionContext from '../common/context';
import ContentUnavailable from '../content-unavailable/ContentUnavailable';
import {
useCourseBlockData, useCourseDiscussionData, useIsOnTablet, useRedirectToThread, useSidebarVisible,
useCourseBlockData, useCourseDiscussionData, useIsOnTablet, useRedirectToThread, useSidebarVisible, useTopicsData,
} from '../data/hooks';
import { selectDiscussionProvider, selectEnableInContext, selectIsUserLearner } from '../data/selectors';
import { EmptyLearners, EmptyTopics } from '../empty-posts';
@@ -62,6 +62,7 @@ const DiscussionsHome = () => {
useCourseDiscussionData(courseId);
useRedirectToThread(courseId, enableInContextSidebar);
useCourseBlockData(courseId);
useTopicsData(courseId, enableInContextSidebar);
useFeedbackWrapper();
/* Display the content area if we are currently viewing/editing a post or creating one.
If the window is larger than a particular size, show the sidebar for navigating between posts/topics.

View File

@@ -27,10 +27,10 @@ import { fetchThreads } from '../posts/data/thunks';
import fetchCourseTopics from '../topics/data/thunks';
import DiscussionsHome from './DiscussionsHome';
import '../posts/data/__factories__/threads.factory';
import '../in-context-topics/data/__factories__/inContextTopics.factory';
import '../topics/data/__factories__/topics.factory';
import '../../components/NavigationBar/data/__factories__/navigationBar.factory';
import '../in-context-topics/data/__factories__/inContextTopics.factory';
import '../posts/data/__factories__/threads.factory';
import '../topics/data/__factories__/topics.factory';
const courseConfigApiUrl = getCourseConfigApiUrl();
let axiosMock;
@@ -224,7 +224,7 @@ describe('DiscussionsHome', () => {
it('should display post editor form when click on add a post button in legacy topics view', async () => {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
enable_in_context: false, hasModerationPrivileges: true, isEmailVerified: true,
enable_in_context: false, hasModerationPrivileges: true, isEmailVerified: true, provider: 'legacy',
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/topics`);

View File

@@ -1,6 +1,4 @@
import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { Spinner } from '@openedx/paragon';
import classNames from 'classnames';
@@ -9,8 +7,7 @@ import { useDispatch, useSelector } from 'react-redux';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus } from '../../data/constants';
import DiscussionContext from '../common/context';
import { selectAreThreadsFiltered, selectDiscussionProvider } from '../data/selectors';
import { selectAreThreadsFiltered } from '../data/selectors';
import { clearFilter, clearSort } from '../posts/data/slices';
import NoResults from '../posts/NoResults';
import { handleKeyDown } from '../utils';
@@ -19,7 +16,6 @@ import {
selectNonCoursewareTopics, selectTopicFilter, selectTopics,
} from './data/selectors';
import { setFilter } from './data/slices';
import fetchCourseTopicsV3 from './data/thunks';
import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic';
const TopicsList = () => {
@@ -71,20 +67,12 @@ const TopicsList = () => {
const TopicsView = () => {
const dispatch = useDispatch();
const { courseId } = useContext(DiscussionContext);
const provider = useSelector(selectDiscussionProvider);
const topicFilter = useSelector(selectTopicFilter);
const filteredTopics = useSelector(selectFilteredTopics);
const loadingStatus = useSelector(selectLoadingStatus);
const isPostsFiltered = useSelector(selectAreThreadsFiltered);
const topics = useSelector(selectTopics);
useEffect(() => {
if (provider) {
dispatch(fetchCourseTopicsV3(courseId));
}
}, [provider]);
useEffect(() => {
if (isPostsFiltered) {
dispatch(clearFilter());

View File

@@ -1,4 +1,4 @@
import React, {
import {
useCallback, useContext, useEffect, useMemo,
} from 'react';

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { Button, Spinner } from '@openedx/paragon';
import { useDispatch, useSelector } from 'react-redux';

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?.imageUrlLarge
state.learners.learnerProfiles[author]?.profileImage?.imageUrlSmall
);

View File

@@ -9,7 +9,9 @@ import {
} from 'react-router-dom';
import { Factory } from 'rosie';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import {
camelCaseObject, getConfig, initializeMockApp, setConfig,
} from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
@@ -738,6 +740,20 @@ 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: postUsers,
editByLabel, closedByLabel, users: commentUsers,
} = comment;
const intl = useIntl();
const hasChildren = childCount > 0;
@@ -209,7 +209,7 @@ const Comment = ({
closed={closed}
createdAt={createdAt}
lastEdit={lastEdit}
postUsers={postUsers}
commentUsers={commentUsers}
/>
{isEditing ? (
<CommentEditor

View File

@@ -19,7 +19,7 @@ const CommentHeader = ({
closed,
createdAt,
lastEdit,
postUsers,
commentUsers,
}) => {
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(postUsers ?? {})[0]?.profile?.image
? Object.values(commentUsers ?? {})[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}
src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatar?.imageUrlSmall}
style={{
width: '32px',
height: '32px',
@@ -72,7 +72,7 @@ CommentHeader.propTypes = {
editorUsername: PropTypes.string,
reason: PropTypes.string,
}),
postUsers: PropTypes.shape({}).isRequired,
commentUsers: PropTypes.shape({}).isRequired,
};
CommentHeader.defaultProps = {

View File

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

View File

@@ -5,6 +5,7 @@ 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';
@@ -24,7 +25,7 @@ import CommentEditor from './CommentEditor';
const Reply = ({ responseId }) => {
timeago.register('time-locale', timeLocale);
const {
id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy,
id, abuseFlagged, author, authorLabel, endorsed, lastEdit, closed, closedBy, users: replyUsers,
closeReason, createdAt, threadId, parentId, rawBody, renderedBody, editByLabel, closedByLabel,
} = useSelector(selectCommentOrResponseById(responseId));
const intl = useIntl();
@@ -78,6 +79,10 @@ 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
@@ -123,7 +128,7 @@ const Reply = ({ responseId }) => {
<Avatar
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={author}
src={authorAvatar?.imageUrlSmall}
src={profileImage?.hasImage ? profileImage?.imageUrlSmall : authorAvatar?.imageUrlSmall}
style={{
width: '32px',
height: '32px',

View File

@@ -30,6 +30,16 @@ 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

@@ -1,19 +1,11 @@
import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import React, { useCallback, useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
import { useDispatch, useSelector } from 'react-redux';
import SearchInfo from '../../components/SearchInfo';
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
import DiscussionContext from '../common/context';
import { selectEnableInContext } from '../data/selectors';
import { selectTopics as selectInContextTopics } from '../in-context-topics/data/selectors';
import fetchCourseTopicsV3 from '../in-context-topics/data/thunks';
import { selectTopics } from '../topics/data/selectors';
import fetchCourseTopics from '../topics/data/thunks';
import { handleKeyDown } from '../utils';
import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors';
import { setSearchQuery } from './data/slices';
@@ -51,27 +43,12 @@ CategoryPostsList.propTypes = {
};
const PostsView = () => {
const {
topicId,
category,
courseId,
enableInContextSidebar,
} = useContext(DiscussionContext);
const { topicId, category } = useContext(DiscussionContext);
const dispatch = useDispatch();
const enableInContext = useSelector(selectEnableInContext);
const searchString = useSelector(({ threads }) => threads.filters.search);
const resultsFound = useSelector(({ threads }) => threads.totalThreads);
const textSearchRewrite = useSelector(({ threads }) => threads.textSearchRewrite);
const loadingStatus = useSelector(({ threads }) => threads.status);
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
useEffect(() => {
if (isEmpty(topics)) {
dispatch((enableInContext || enableInContextSidebar)
? fetchCourseTopicsV3(courseId)
: fetchCourseTopics(courseId));
}
}, [topics]);
const handleOnClear = useCallback(() => {
dispatch(setSearchQuery(''));

View File

@@ -1,5 +1,3 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
@@ -232,7 +230,7 @@ describe('PostsView', () => {
test('test that the cohorts filter works', async () => {
await act(async () => {
fireEvent.click(screen.getByLabelText('Cohort 1'));
fireEvent.click(screen.getByLabelText('cohort 1'));
});
dropDownButton = screen.getByRole('button', {
@@ -280,7 +278,7 @@ describe('PostsView', () => {
queryParam: { group_id: undefined },
},
{
label: 'Cohort 1',
label: 'cohort 1',
queryParam: { group_id: 'cohort-1' },
},
])(

View File

@@ -165,7 +165,7 @@ const PostFilterBar = () => {
<ActionItem
key={cohort.id}
id={toString(cohort.id)}
label={capitalize(cohort.name)}
label={cohort.name}
value={toString(cohort.id)}
selected={currentFilters.cohort}
/>

View File

@@ -1,19 +1,15 @@
import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus } from '../../data/constants';
import DiscussionContext from '../common/context';
import { selectDiscussionProvider } from '../data/selectors';
import NoResults from '../posts/NoResults';
import { handleKeyDown } from '../utils';
import { selectCategories, selectNonCoursewareTopics, selectTopicFilter } from './data/selectors';
import { setFilter, setTopicsCount } from './data/slices';
import fetchCourseTopics from './data/thunks';
import LegacyTopicGroup from './topic-group/LegacyTopicGroup';
import Topic from './topic-group/topic/Topic';
import countFilteredTopics from './utils';
@@ -64,19 +60,11 @@ const TopicsView = () => {
const topicsSelector = useSelector(({ topics }) => topics);
const filteredTopicsCount = useSelector(({ topics }) => topics.results.count);
const loadingStatus = useSelector(({ topics }) => topics.status);
const { courseId } = useContext(DiscussionContext);
const handleOnClear = useCallback(() => {
dispatch(setFilter(''));
}, []);
useEffect(() => {
// Don't load till the provider information is available
if (provider) {
dispatch(fetchCourseTopics(courseId));
}
}, [provider]);
useEffect(() => {
const count = countFilteredTopics(topicsSelector, provider);
dispatch(setTopicsCount(count));

View File

@@ -1,5 +1,7 @@
import PropTypes from 'prop-types';
import { mergeConfig } from '@edx/frontend-platform';
import '@testing-library/jest-dom/extend-expect';
import 'babel-polyfill';
@@ -66,3 +68,10 @@ 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');