diff --git a/src/components/FilterBar.jsx b/src/components/FilterBar.jsx
new file mode 100644
index 00000000..828778d8
--- /dev/null
+++ b/src/components/FilterBar.jsx
@@ -0,0 +1,201 @@
+/* eslint-disable react/forbid-prop-types */
+import React, { useMemo, useState } from 'react';
+import PropTypes from 'prop-types';
+
+import { capitalize, toString } from 'lodash';
+import { useSelector } from 'react-redux';
+
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import {
+ Collapsible, Form, Icon, Spinner,
+} from '@edx/paragon';
+import { Tune } from '@edx/paragon/icons';
+
+import {
+ PostsStatusFilter, RequestStatus,
+ ThreadOrdering, ThreadType,
+} from '../data/constants';
+import { selectCourseCohorts } from '../discussions/cohorts/data/selectors';
+import messages from '../discussions/posts/post-filter-bar/messages';
+import { ActionItem } from '../discussions/posts/post-filter-bar/PostFilterBar';
+
+function FilterBar({
+ intl,
+ filters,
+ selectedFilters,
+ onFilterChange,
+ showCohortsFilter,
+}) {
+ const [isOpen, setOpen] = useState(false);
+ const cohorts = useSelector(selectCourseCohorts);
+ const { status } = useSelector(state => state.cohorts);
+ const selectedCohort = useMemo(() => cohorts.find(cohort => (
+ toString(cohort.id) === selectedFilters.cohort)),
+ [selectedFilters.cohort]);
+
+ const allFilters = [
+ {
+ id: 'type-all',
+ label: intl.formatMessage(messages.allPosts),
+ value: ThreadType.ALL,
+ },
+ {
+ id: 'type-discussions',
+ label: intl.formatMessage(messages.filterDiscussions),
+ value: ThreadType.DISCUSSION,
+ },
+ {
+ id: 'type-questions',
+ label: intl.formatMessage(messages.filterQuestions),
+ value: ThreadType.QUESTION,
+ },
+ {
+ id: 'status-any',
+ label: intl.formatMessage(messages.filterAnyStatus),
+ value: PostsStatusFilter.ALL,
+ },
+ {
+ id: 'status-unread',
+ label: intl.formatMessage(messages.filterUnread),
+ value: PostsStatusFilter.UNREAD,
+ },
+ {
+ id: 'status-reported',
+ label: intl.formatMessage(messages.filterReported),
+ value: PostsStatusFilter.REPORTED,
+ },
+ {
+ id: 'status-unanswered',
+ label: intl.formatMessage(messages.filterUnanswered),
+ value: PostsStatusFilter.UNANSWERED,
+ },
+ {
+ id: 'status-unresponded',
+ label: intl.formatMessage(messages.filterUnresponded),
+ value: PostsStatusFilter.UNRESPONDED,
+ },
+ {
+ id: 'sort-activity',
+ label: intl.formatMessage(messages.lastActivityAt),
+ value: ThreadOrdering.BY_LAST_ACTIVITY,
+ },
+ {
+ id: 'sort-comments',
+ label: intl.formatMessage(messages.commentCount),
+ value: ThreadOrdering.BY_COMMENT_COUNT,
+ },
+ {
+ id: 'sort-votes',
+ label: intl.formatMessage(messages.voteCount),
+ value: ThreadOrdering.BY_VOTE_COUNT,
+ },
+ ];
+
+ return (
+ setOpen(!isOpen)}
+ className="filter-bar collapsible-card-lg border-0"
+ >
+
+
+ {intl.formatMessage(messages.sortFilterStatus, {
+ own: false,
+ type: selectedFilters.type,
+ sort: selectedFilters.orderBy,
+ status: selectedFilters.status,
+ cohortType: selectedCohort?.name ? 'group' : 'all',
+ cohort: capitalize(selectedCohort?.name),
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+FilterBar.propTypes = {
+ intl: intlShape.isRequired,
+ filters: PropTypes.array.isRequired,
+ selectedFilters: PropTypes.object.isRequired,
+ onFilterChange: PropTypes.func.isRequired,
+ showCohortsFilter: PropTypes.bool,
+};
+
+FilterBar.defaultProps = {
+ showCohortsFilter: false,
+};
+
+export default injectIntl(FilterBar);
diff --git a/src/data/constants.js b/src/data/constants.js
index 3940f465..c4a6e0e2 100644
--- a/src/data/constants.js
+++ b/src/data/constants.js
@@ -111,6 +111,7 @@ export const PostsStatusFilter = {
FOLLOWING: 'statusFollowing',
REPORTED: 'statusReported',
UNANSWERED: 'statusUnanswered',
+ UNRESPONDED: 'statusUnresponded',
};
/**
diff --git a/src/discussions/learners/LearnerPostsView.jsx b/src/discussions/learners/LearnerPostsView.jsx
index ccf65207..ffffec31 100644
--- a/src/discussions/learners/LearnerPostsView.jsx
+++ b/src/discussions/learners/LearnerPostsView.jsx
@@ -2,6 +2,7 @@ import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
+import snakeCase from 'lodash.snakecase';
import capitalize from 'lodash/capitalize';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
@@ -12,7 +13,12 @@ import {
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
-import { RequestStatus, Routes } from '../../data/constants';
+import {
+ PostsStatusFilter,
+ RequestStatus,
+ Routes,
+ ThreadType,
+} from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import {
@@ -20,10 +26,12 @@ import {
selectThreadNextPage,
threadsLoadingStatus,
} from '../posts/data/selectors';
+import { clearPostsPages } from '../posts/data/slices';
import NoResults from '../posts/NoResults';
import { PostLink } from '../posts/post';
import { discussionsPath, filterPosts } from '../utils';
import { fetchUserPosts } from './data/thunks';
+import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
import messages from './messages';
function LearnerPostsView({ intl }) {
@@ -33,21 +41,49 @@ function LearnerPostsView({ intl }) {
const posts = useSelector(selectAllThreads);
const loadingStatus = useSelector(threadsLoadingStatus());
+ const postFilter = useSelector(state => state.learners.postFilter);
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
const nextPage = useSelector(selectThreadNextPage());
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsStaff = useSelector(selectUserIsStaff);
const countFlagged = userHasModerationPrivileges || userIsStaff;
+ const params = {
+ orderBy: snakeCase(postFilter.orderBy),
+ username,
+ page: 1,
+ };
+ if (postFilter.type !== ThreadType.ALL) {
+ params.threadType = postFilter.type;
+ }
+ if (postFilter.status !== PostsStatusFilter.ALL) {
+ const statusMapping = {
+ statusUnread: 'unread',
+ statusReported: 'flagged',
+ statusUnanswered: 'unanswered',
+ statusUnresponded: 'unresponded',
+ };
+ params.status = statusMapping[postFilter.status];
+ }
+ if (postFilter.cohort !== '') {
+ params.groupId = postFilter.cohort;
+ }
+ if (countFlagged) {
+ params.countFlagged = countFlagged;
+ }
useEffect(() => {
- dispatch(fetchUserPosts(courseId, { username, countFlagged }));
+ dispatch(fetchUserPosts(courseId, params));
}, [courseId, username]);
+ useEffect(() => {
+ dispatch(clearPostsPages());
+ dispatch(fetchUserPosts(courseId, params));
+ }, [postFilter]);
+
const loadMorePosts = () => (
dispatch(fetchUserPosts(courseId, {
- username,
+ ...params,
page: nextPage,
- countFlagged,
}))
);
@@ -84,6 +120,7 @@ function LearnerPostsView({ intl }) {
+
{postInstances(pinnedPosts)}
{postInstances(unpinnedPosts)}
diff --git a/src/discussions/learners/data/api.js b/src/discussions/learners/data/api.js
index 97738739..fa958a50 100644
--- a/src/discussions/learners/data/api.js
+++ b/src/discussions/learners/data/api.js
@@ -45,11 +45,10 @@ export async function getUserProfiles(usernames) {
* pagination: {count, num_pages, next, previous}
* }
*/
-export async function getUserPosts(courseId, { username, page, countFlagged }) {
+export async function getUserPosts(courseId, params) {
const learnerPostsApiUrl = `${coursesApiUrl}${courseId}/learner/`;
-
- const params = snakeCaseObject({ username, page, countFlagged });
+ const snakeCaseParams = snakeCaseObject(params);
const { data } = await getAuthenticatedHttpClient()
- .get(learnerPostsApiUrl, { params });
+ .get(learnerPostsApiUrl, { params: snakeCaseParams });
return data;
}
diff --git a/src/discussions/learners/data/slices.js b/src/discussions/learners/data/slices.js
index a67fd568..7336e826 100644
--- a/src/discussions/learners/data/slices.js
+++ b/src/discussions/learners/data/slices.js
@@ -3,7 +3,10 @@ import { createSlice } from '@reduxjs/toolkit';
import {
LearnersOrdering,
+ PostsStatusFilter,
RequestStatus,
+ ThreadOrdering,
+ ThreadType,
} from '../../../data/constants';
const learnersSlice = createSlice({
@@ -16,6 +19,12 @@ const learnersSlice = createSlice({
totalPages: null,
totalLearners: null,
sortedBy: LearnersOrdering.BY_LAST_ACTIVITY,
+ postFilter: {
+ type: ThreadType.ALL,
+ status: PostsStatusFilter.ALL,
+ orderBy: ThreadOrdering.BY_LAST_ACTIVITY,
+ cohort: '',
+ },
usernameSearch: null,
},
reducers: {
@@ -47,6 +56,10 @@ const learnersSlice = createSlice({
state.usernameSearch = payload;
state.pages = [];
},
+ setPostFilter: (state, { payload }) => {
+ state.pages = [];
+ state.postFilter = payload;
+ },
},
});
@@ -57,6 +70,7 @@ export const {
fetchLearnersDenied,
setSortedBy,
setUsernameSearch,
+ setPostFilter,
} = learnersSlice.actions;
export const learnersReducer = learnersSlice.reducer;
diff --git a/src/discussions/learners/data/thunks.js b/src/discussions/learners/data/thunks.js
index 673039f2..69f07e55 100644
--- a/src/discussions/learners/data/thunks.js
+++ b/src/discussions/learners/data/thunks.js
@@ -67,15 +67,17 @@ export function fetchLearners(courseId, {
* @param page
* @returns a promise that will update the state with the learner's posts
*/
-export function fetchUserPosts(courseId, { username, page = 1, countFlagged = false } = {}) {
+export function fetchUserPosts(courseId, params) {
return async (dispatch) => {
try {
- dispatch(fetchLearnerThreadsRequest({ courseId, author: username }));
-
- const data = await getUserPosts(courseId, { username, page, countFlagged });
+ dispatch(fetchLearnerThreadsRequest({ courseId, author: params?.username }));
+ const data = await getUserPosts(courseId, params);
const normalisedData = normaliseThreads(camelCaseObject(data));
-
- dispatch(fetchThreadsSuccess({ ...normalisedData, page, author: username }));
+ dispatch(fetchThreadsSuccess({
+ ...normalisedData,
+ page: params.page,
+ author: params.username,
+ }));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchThreadsDenied());
diff --git a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx
new file mode 100644
index 00000000..2b0f774d
--- /dev/null
+++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx
@@ -0,0 +1,89 @@
+import React, { useEffect } from 'react';
+
+import { isEmpty } from 'lodash';
+import { useDispatch, useSelector } from 'react-redux';
+import { useParams } from 'react-router-dom';
+
+import FilterBar from '../../../components/FilterBar';
+import { selectCourseCohorts } from '../../cohorts/data/selectors';
+import { fetchCourseCohorts } from '../../cohorts/data/thunks';
+import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
+import { setPostFilter } from '../data/slices';
+
+function LearnerPostFilterBar() {
+ const dispatch = useDispatch();
+ const { courseId } = useParams();
+ const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
+ const userIsGroupTa = useSelector(selectUserIsGroupTa);
+ const cohorts = useSelector(selectCourseCohorts);
+ const postFilter = useSelector(state => state.learners.postFilter);
+
+ const filtersToShow = [
+ {
+ name: 'type',
+ filters: ['type-all', 'type-discussions', 'type-questions'],
+ },
+ {
+ name: 'status',
+ filters: ['status-any', 'status-unread', 'status-unanswered', 'status-unresponded'],
+ },
+ {
+ name: 'orderBy',
+ filters: ['sort-activity', 'sort-comments', 'sort-votes'],
+ },
+ ];
+
+ if (userHasModerationPrivileges || userIsGroupTa) {
+ filtersToShow[1].filters.splice(2, 0, 'status-reported');
+ }
+
+ const handleFilterChange = (event) => {
+ const { name, value } = event.currentTarget;
+ if (name === 'type') {
+ if (postFilter.type !== value) {
+ dispatch(setPostFilter({
+ ...postFilter,
+ type: value,
+ }));
+ }
+ } else if (name === 'status') {
+ if (postFilter.status !== value) {
+ dispatch(setPostFilter({
+ ...postFilter,
+ status: value,
+ }));
+ }
+ } else if (name === 'orderBy') {
+ if (postFilter.orderBy !== value) {
+ dispatch(setPostFilter({
+ ...postFilter,
+ orderBy: value,
+ }));
+ }
+ } else if (name === 'cohort') {
+ if (postFilter.cohort !== value) {
+ dispatch(setPostFilter({
+ ...postFilter,
+ cohort: value,
+ }));
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (userHasModerationPrivileges && isEmpty(cohorts)) {
+ dispatch(fetchCourseCohorts(courseId));
+ }
+ }, [courseId, userHasModerationPrivileges]);
+
+ return (
+
+ );
+}
+
+export default LearnerPostFilterBar;
diff --git a/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.test.jsx b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.test.jsx
new file mode 100644
index 00000000..d4281557
--- /dev/null
+++ b/src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.test.jsx
@@ -0,0 +1,108 @@
+import {
+ act,
+ fireEvent,
+ render,
+ waitFor,
+} from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import { generatePath, MemoryRouter, Route } from 'react-router';
+
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+
+import { Routes } from '../../../data/constants';
+import { initializeStore } from '../../../store';
+import { DiscussionContext } from '../../common/context';
+import LearnerPostFilterBar from './LearnerPostFilterBar';
+
+let store;
+const username = 'abc123';
+const courseId = 'course-v1:edX+DemoX+Demo_Course';
+const path = generatePath(
+ Routes.LEARNERS.POSTS,
+ { courseId, learnerUsername: username },
+);
+
+function renderComponent() {
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe('LearnerPostFilterBar', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username,
+ administrator: true,
+ roles: [],
+ },
+ });
+ const initialData = {
+ config: {
+ hasModerationPrivileges: true,
+ isGroupTa: false,
+ },
+ cohorts: {
+ status: 'successful',
+ cohorts: [
+ {
+ name: 'Default Group',
+ id: 1,
+ userCount: 1,
+ groupId: null,
+ },
+ ],
+ },
+ };
+ store = initializeStore(initialData);
+ });
+
+ test('checks if all filters are visible', async () => {
+ const { queryAllByRole } = await renderComponent();
+ await act(async () => {
+ fireEvent.click(queryAllByRole('button')[0]);
+ });
+ await waitFor(() => {
+ expect(queryAllByRole('radiogroup')).toHaveLength(4);
+ });
+ });
+
+ test('checks if default values are selected', async () => {
+ const { queryAllByRole } = await renderComponent();
+ await act(async () => {
+ fireEvent.click(queryAllByRole('button')[0]);
+ });
+ await waitFor(() => {
+ expect(
+ queryAllByRole('radiogroup')[0].querySelector('input[value="all"]'),
+ ).toBeChecked();
+ expect(
+ queryAllByRole('radiogroup')[1].querySelector('input[value="statusAll"]'),
+ ).toBeChecked();
+ expect(
+ queryAllByRole('radiogroup')[2].querySelector('input[value="lastActivityAt"]'),
+ ).toBeChecked();
+ expect(
+ queryAllByRole('radiogroup')[3].querySelector('input[value=""]'),
+ ).toBeChecked();
+ });
+ });
+});
diff --git a/src/discussions/posts/data/slices.js b/src/discussions/posts/data/slices.js
index ba61f128..2c7779f7 100644
--- a/src/discussions/posts/data/slices.js
+++ b/src/discussions/posts/data/slices.js
@@ -61,6 +61,9 @@ const threadsSlice = createSlice({
}
state.status = RequestStatus.IN_PROGRESS;
},
+ clearPostsPages: (state) => {
+ state.pages = [];
+ },
fetchThreadsRequest: (state) => {
state.status = RequestStatus.IN_PROGRESS;
},
@@ -242,6 +245,7 @@ export const {
showPostEditor,
hidePostEditor,
clearRedirect,
+ clearPostsPages,
} = threadsSlice.actions;
export const threadsReducer = threadsSlice.reducer;
diff --git a/src/discussions/posts/post-filter-bar/PostFilterBar.jsx b/src/discussions/posts/post-filter-bar/PostFilterBar.jsx
index 0ba1d30f..8e2f7639 100644
--- a/src/discussions/posts/post-filter-bar/PostFilterBar.jsx
+++ b/src/discussions/posts/post-filter-bar/PostFilterBar.jsx
@@ -25,7 +25,7 @@ import {
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
import messages from './messages';
-const ActionItem = ({
+export const ActionItem = ({
id,
label,
value,
diff --git a/src/discussions/posts/post-filter-bar/messages.js b/src/discussions/posts/post-filter-bar/messages.js
index 9ccde1c8..5ee67ade 100644
--- a/src/discussions/posts/post-filter-bar/messages.js
+++ b/src/discussions/posts/post-filter-bar/messages.js
@@ -46,6 +46,11 @@ const messages = defineMessages({
defaultMessage: 'Unanswered',
description: 'Option in dropdown to filter to unanswered posts',
},
+ filterUnresponded: {
+ id: 'discussions.posts.status.filter.unresponded',
+ defaultMessage: 'Unresponded',
+ description: 'Option in dropdown to filter to unresponded posts',
+ },
myPosts: {
id: 'discussions.posts.filter.myPosts',
defaultMessage: 'My posts',
@@ -93,6 +98,7 @@ const messages = defineMessages({
statusFollowing {followed}
statusReported {reported}
statusUnanswered {unanswered}
+ statusUnresponded {unresponded}
other {{status}}
} {type, select,
discussion {discussions}