From 7ced4b292ca3df85d4f376cf0d534ae6859ff334 Mon Sep 17 00:00:00 2001 From: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com> Date: Thu, 3 Nov 2022 16:54:07 +0500 Subject: [PATCH] feat: added filter options for learner posts (#340) Co-authored-by: adeel.tajamul --- src/components/FilterBar.jsx | 201 ++++++++++++++++++ src/data/constants.js | 1 + src/discussions/learners/LearnerPostsView.jsx | 45 +++- src/discussions/learners/data/api.js | 7 +- src/discussions/learners/data/slices.js | 14 ++ src/discussions/learners/data/thunks.js | 14 +- .../LearnerPostFilterBar.jsx | 89 ++++++++ .../LearnerPostFilterBar.test.jsx | 108 ++++++++++ src/discussions/posts/data/slices.js | 4 + .../posts/post-filter-bar/PostFilterBar.jsx | 2 +- .../posts/post-filter-bar/messages.js | 6 + 11 files changed, 476 insertions(+), 15 deletions(-) create mode 100644 src/components/FilterBar.jsx create mode 100644 src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.jsx create mode 100644 src/discussions/learners/learner-post-filter-bar/LearnerPostFilterBar.test.jsx 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), + })} + + + + + + + + + +
+
+ {filters.map((value) => ( + + { + value.filters.map(filterName => { + const element = allFilters.find(obj => obj.id === filterName); + if (element) { + return ( + + ); + } + return false; + }) + } + + + ))} +
+ {showCohortsFilter && ( + <> +
+ {status === RequestStatus.IN_PROGRESS ? ( +
+ +
+ ) : ( +
+ + + {cohorts.map(cohort => ( + + ))} + +
+ )} + + )} + + + + ); +} + +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}