Compare commits

...

16 Commits

Author SHA1 Message Date
Awais Ansari
e216d847eb feat: added cohort update option in post edit 2025-12-18 16:39:24 +05:00
Awais Ansari
911b8b3fc5 fix: cohort name difference in filter and post view (#841)
* fix: cohort name difference in filter and post view

* test: fix cohort test cases

---------

Co-authored-by: Awais  Ansari <awais.ansari@A006-01824.local>
2025-12-16 20:07:40 +05:00
Awais Ansari
4917da3245 fix: fetch topics on MFE load (#840)
* fix: added fetch topic API call in learners tab

* refactor: fetch topics on MFE load

* test: added legacy param in legacy topic test cases

---------

Co-authored-by: Awais  Ansari <awais.ansari@A006-01824.local>
2025-12-16 19:39:36 +05:00
renovate[bot]
e5388690b2 fix(deps): update dependency core-js to v3.47.0 2025-12-10 12:05:24 +05:00
edX requirements bot
cefc8d9d35 chore: enable github action auto update in dependabot.yml (#737) 2025-12-10 10:40:23 +05:00
renovate[bot]
f5c5913d3f chore(deps): update dependency glob to v7.2.3 2025-12-09 20:50:15 +00:00
renovate[bot]
687dae6b21 fix(deps): update dependency tinymce to v5.10.9 (#587)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 15:26:31 +00:00
renovate[bot]
b812b96d77 fix(deps): update dependency formik to v2.4.9 2025-12-09 14:59:27 +00:00
renovate[bot]
142abd8dd4 fix(deps): update dependency @edx/openedx-atlas to ^0.7.0 (#815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 14:47:28 +00:00
renovate[bot]
ece4432f58 chore(deps): update dependency @edx/browserslist-config to v1.5.0 (#747)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 14:17:40 +00:00
renovate[bot]
c599046813 chore(deps): update dependency axios to ^0.30.0 (#780)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 14:13:55 +00:00
renovate[bot]
c323c80bc8 chore(deps): update actions/checkout action to v6 (#833)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 19:02:49 +05:00
renovate[bot]
37cec76dcb chore(deps): update dependency @openedx/paragon to v23.18.1 (#814)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 18:50:31 +05:00
Awais Ansari
00440fc15a chore: update header version to 8 (#829) 2025-11-14 15:31:25 +05:00
Tobias Macey
a4826ae62d fix: prevent generatePath error when author is invalid in AuthorLabel (#821)
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-13 16:08:51 -05:00
renovate[bot]
16c49b2404 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-13 14:36:31 -05:00
19 changed files with 1330 additions and 3388 deletions

7
.github/dependabot.yml vendored Normal file
View File

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

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Nodejs

4538
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,17 +31,17 @@
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-platform": "^8.3.3",
"@edx/openedx-atlas": "^0.6.0",
"@edx/openedx-atlas": "^0.7.0",
"@openedx/paragon": "^23.4.5",
"@reduxjs/toolkit": "1.9.7",
"@tinymce/tinymce-react": "5.1.1",
"babel-polyfill": "6.26.0",
"classnames": "2.5.1",
"core-js": "3.21.1",
"core-js": "3.47.0",
"dompurify": "^2.4.3",
"formik": "2.4.5",
"formik": "2.4.9",
"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.7",
"tinymce": "5.10.9",
"yup": "0.32.11"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/browserslist-config": "1.5.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.28.0",
"axios": "^0.30.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.0",
"glob": "7.2.3",
"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

@@ -101,17 +101,22 @@ const AuthorLabel = ({
</>
), [author, authorLabelMessage, authorToolTip, icon, isRetiredUser, postCreatedAt, showTextPrimary, alert]);
const learnerPostsLink = (
<Link
data-testid="learner-posts-link"
id="learner-posts-link"
to={generatePath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })}
className="text-decoration-none text-reset"
style={{ width: 'fit-content' }}
>
{!alert && authorName}
</Link>
);
const learnerPostsLink = useMemo(() => {
if (!showUserNameAsLink) {
return null;
}
return (
<Link
data-testid="learner-posts-link"
id="learner-posts-link"
to={generatePath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })}
className="text-decoration-none text-reset"
style={{ width: 'fit-content' }}
>
{!alert && authorName}
</Link>
);
}, [showUserNameAsLink, author, courseId, alert, authorName]);
return showUserNameAsLink
? (

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

@@ -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

@@ -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

@@ -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));