feat: added filter options for learner posts (#340)

Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
This commit is contained in:
Muhammad Adeel Tajamul
2022-11-03 16:54:07 +05:00
committed by GitHub
parent 12dd08d97f
commit 7ced4b292c
11 changed files with 476 additions and 15 deletions

View File

@@ -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 (
<Collapsible.Advanced
open={isOpen}
onToggle={() => setOpen(!isOpen)}
className="filter-bar collapsible-card-lg border-0"
>
<Collapsible.Trigger className="collapsible-trigger border-0">
<span className="text-primary-700 pr-4">
{intl.formatMessage(messages.sortFilterStatus, {
own: false,
type: selectedFilters.type,
sort: selectedFilters.orderBy,
status: selectedFilters.status,
cohortType: selectedCohort?.name ? 'group' : 'all',
cohort: capitalize(selectedCohort?.name),
})}
</span>
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form>
<div className="d-flex flex-row py-2 justify-content-between">
{filters.map((value) => (
<Form.RadioSet
name={value.name}
className="d-flex flex-column list-group list-group-flush"
value={selectedFilters[value.name]}
onChange={onFilterChange}
>
{
value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})
}
</Form.RadioSet>
))}
</div>
{showCohortsFilter && (
<>
<div className="border-bottom my-2" />
{status === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
<div className="d-flex flex-row pt-2">
<Form.RadioSet
name="cohort"
className="d-flex flex-column list-group list-group-flush w-100"
value={selectedFilters.cohort}
onChange={onFilterChange}
>
<ActionItem
id="all-groups"
label="All groups"
value=""
selected={selectedFilters.cohort}
/>
{cohorts.map(cohort => (
<ActionItem
key={toString(cohort.id)}
id={toString(cohort.id)}
label={capitalize(cohort.name)}
value={toString(cohort.id)}
selected={selectedFilters.cohort}
/>
))}
</Form.RadioSet>
</div>
)}
</>
)}
</Form>
</Collapsible.Body>
</Collapsible.Advanced>
);
}
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);

View File

@@ -111,6 +111,7 @@ export const PostsStatusFilter = {
FOLLOWING: 'statusFollowing',
REPORTED: 'statusReported',
UNANSWERED: 'statusUnanswered',
UNRESPONDED: 'statusUnresponded',
};
/**

View File

@@ -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 }) {
<div style={{ padding: '18px' }} />
</div>
<div className="bg-light-400 border border-light-300" />
<LearnerPostFilterBar />
<div className="list-group list-group-flush">
{postInstances(pinnedPosts)}
{postInstances(unpinnedPosts)}

View File

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

View File

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

View File

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

View File

@@ -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 (
<FilterBar
filters={filtersToShow}
selectedFilters={postFilter}
onFilterChange={handleFilterChange}
showCohortsFilter={userHasModerationPrivileges || userIsGroupTa}
/>
);
}
export default LearnerPostFilterBar;

View File

@@ -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(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
learnerUsername: username,
courseId,
}}
>
<MemoryRouter initialEntries={[path]}>
<Route
path={Routes.LEARNERS.POSTS}
component={LearnerPostFilterBar}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
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();
});
});
});

View File

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

View File

@@ -25,7 +25,7 @@ import {
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
import messages from './messages';
const ActionItem = ({
export const ActionItem = ({
id,
label,
value,

View File

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