Compare commits
1 Commits
frontend-b
...
aansari/si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5236e110dd |
@@ -17,7 +17,7 @@ import {
|
||||
} 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';
|
||||
import ActionItem from '../discussions/posts/post-filter-bar/PostFilterBar';
|
||||
|
||||
const FilterBar = ({
|
||||
intl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { memo, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -6,25 +6,30 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import withConditionalInContextRendering from '../../discussions/common/withConditionalInContextRendering';
|
||||
import { useCourseId } from '../../discussions/data/hooks';
|
||||
import { fetchTab } from './data/thunks';
|
||||
import Tabs from './tabs/Tabs';
|
||||
import messages from './messages';
|
||||
|
||||
import './navBar.scss';
|
||||
|
||||
const CourseTabsNavigation = ({
|
||||
activeTab, className, courseId, rootSlug,
|
||||
}) => {
|
||||
const CourseTabsNavigation = ({ activeTab, className, rootSlug }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const courseId = useCourseId();
|
||||
const tabs = useSelector(state => state.courseTabs.tabs);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchTab(courseId, rootSlug));
|
||||
if (courseId) {
|
||||
dispatch(fetchTab(courseId, rootSlug));
|
||||
}
|
||||
}, [courseId]);
|
||||
|
||||
console.log('CourseTabsNavigation');
|
||||
|
||||
return (
|
||||
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation px-4', className)}>
|
||||
<div id="courseTabsNavigation" tabIndex="-1" className={classNames('course-tabs-navigation px-4', className)}>
|
||||
{!!tabs.length && (
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
@@ -49,13 +54,12 @@ CourseTabsNavigation.propTypes = {
|
||||
activeTab: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
rootSlug: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTab: undefined,
|
||||
activeTab: 'discussion',
|
||||
className: null,
|
||||
rootSlug: 'outline',
|
||||
};
|
||||
|
||||
export default React.memo(CourseTabsNavigation);
|
||||
export default memo(withConditionalInContextRendering(CourseTabsNavigation, false));
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
const selectCourseTabs = state => state.courseTabs;
|
||||
|
||||
export const selectCourseTabs = state => state.courseTabs;
|
||||
export default selectCourseTabs;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CourseTabsNavigation } from './CourseTabsNavigation';
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useRef, useState,
|
||||
useCallback, useEffect, useMemo, useRef, useState,
|
||||
} from 'react';
|
||||
|
||||
import camelCase from 'lodash/camelCase';
|
||||
@@ -9,7 +9,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, SearchField } from '@edx/paragon';
|
||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||
|
||||
import { DiscussionContext } from '../discussions/common/context';
|
||||
import { useCurrentPage } from '../discussions/data/hooks';
|
||||
import { setUsernameSearch } from '../discussions/learners/data';
|
||||
import { setSearchQuery } from '../discussions/posts/data';
|
||||
import postsMessages from '../discussions/posts/post-actions-bar/messages';
|
||||
@@ -18,7 +18,7 @@ import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
|
||||
const Search = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { page } = useContext(DiscussionContext);
|
||||
const page = useCurrentPage();
|
||||
const postSearch = useSelector(({ threads }) => threads.filters.search);
|
||||
const topicSearch = useSelector(({ topics }) => topics.filter);
|
||||
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
|
||||
@@ -26,15 +26,15 @@ const Search = () => {
|
||||
const isTopicSearch = 'topics'.includes(page);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const previousSearchValueRef = useRef('');
|
||||
let currentValue = '';
|
||||
|
||||
if (isPostSearch) {
|
||||
currentValue = postSearch;
|
||||
} else if (isTopicSearch) {
|
||||
currentValue = topicSearch;
|
||||
} else {
|
||||
currentValue = learnerSearch;
|
||||
}
|
||||
const currentValue = useMemo(() => {
|
||||
if (isPostSearch) {
|
||||
return postSearch;
|
||||
} if (isTopicSearch) {
|
||||
return topicSearch;
|
||||
}
|
||||
return learnerSearch;
|
||||
}, [postSearch, topicSearch, learnerSearch]);
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
dispatch(setSearchQuery(''));
|
||||
|
||||
@@ -137,7 +137,7 @@ export const DiscussionProvider = {
|
||||
OPEN_EDX: 'openedx',
|
||||
};
|
||||
|
||||
const BASE_PATH = `${getConfig().PUBLIC_PATH}:courseId`;
|
||||
export const BASE_PATH = `${getConfig().PUBLIC_PATH}:courseId`;
|
||||
|
||||
export const Routes = {
|
||||
DISCUSSIONS: {
|
||||
|
||||
11
src/discussions/common/withConditionalInContextRendering.jsx
Normal file
11
src/discussions/common/withConditionalInContextRendering.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEnableInContextSidebar } from '../data/hooks';
|
||||
|
||||
const withConditionalInContextRendering = (WrappedComponent, condition) => (
|
||||
function SidebarConditionalRenderer(props) {
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return enableInContextSidebar === condition && <WrappedComponent {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default withConditionalInContextRendering;
|
||||
@@ -11,7 +11,9 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { breakpoints, useWindowSize } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import {
|
||||
ALL_ROUTES, BASE_PATH, RequestStatus, Routes,
|
||||
} from '../../data/constants';
|
||||
import { selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { fetchCourseBlocks } from '../../data/thunks';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
@@ -20,7 +22,7 @@ import { threadsLoadingStatus } from '../posts/data/selectors';
|
||||
import { selectTopics } from '../topics/data/selectors';
|
||||
import tourCheckpoints from '../tours/constants';
|
||||
import { selectTours } from '../tours/data/selectors';
|
||||
import { updateTourShowStatus } from '../tours/data/thunks';
|
||||
import { fetchDiscussionTours, updateTourShowStatus } from '../tours/data/thunks';
|
||||
import messages from '../tours/messages';
|
||||
import { discussionsPath } from '../utils';
|
||||
import {
|
||||
@@ -69,44 +71,6 @@ export const useSidebarVisible = () => {
|
||||
return !hideSidebar;
|
||||
};
|
||||
|
||||
export function useCourseDiscussionData(courseId) {
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
await dispatch(fetchCourseConfig(courseId));
|
||||
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
|
||||
}
|
||||
|
||||
fetchBaseData();
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
export function useRedirectToThread(courseId, enableInContextSidebar) {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
const redirectToThread = useSelector(
|
||||
(state) => state.threads.redirectToThread,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
||||
// stored in redirectToThread
|
||||
if (redirectToThread) {
|
||||
dispatch(clearRedirect());
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
|
||||
courseId,
|
||||
postId: redirectToThread.threadId,
|
||||
topicId: redirectToThread.topicId,
|
||||
})(location);
|
||||
history.push(newLocation);
|
||||
}
|
||||
}, [redirectToThread]);
|
||||
}
|
||||
|
||||
export function useIsOnDesktop() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width >= breakpoints.medium.minWidth;
|
||||
@@ -260,3 +224,89 @@ export const useDebounce = (value, delay) => {
|
||||
);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
export const useEnableInContextSidebar = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
|
||||
};
|
||||
|
||||
export const useCourseId = () => {
|
||||
const { params: { courseId } } = useRouteMatch(BASE_PATH);
|
||||
|
||||
return courseId;
|
||||
};
|
||||
|
||||
export const useCurrentPage = () => {
|
||||
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||
|
||||
return page;
|
||||
};
|
||||
|
||||
export const usePostId = () => {
|
||||
const { params: { postId } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return postId;
|
||||
};
|
||||
|
||||
export const useLearnerUsername = () => {
|
||||
const { params: { learnerUsername } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return learnerUsername;
|
||||
};
|
||||
|
||||
export const useTopicId = () => {
|
||||
const { params: { topicId } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return topicId;
|
||||
};
|
||||
|
||||
export const useCategory = () => {
|
||||
const { params: { category } } = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
return category;
|
||||
};
|
||||
|
||||
export function useRedirectToThread() {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const courseId = useCourseId();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
const redirectToThread = useSelector(
|
||||
(state) => state.threads.redirectToThread,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
|
||||
// stored in redirectToThread
|
||||
if (redirectToThread) {
|
||||
dispatch(clearRedirect());
|
||||
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
|
||||
courseId,
|
||||
postId: redirectToThread.threadId,
|
||||
topicId: redirectToThread.topicId,
|
||||
})(location);
|
||||
history.push(newLocation);
|
||||
}
|
||||
}, [redirectToThread]);
|
||||
}
|
||||
|
||||
export function useCourseDiscussionData() {
|
||||
const dispatch = useDispatch();
|
||||
const courseId = useCourseId();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBaseData() {
|
||||
await Promise.all([
|
||||
dispatch(fetchCourseConfig(courseId)),
|
||||
dispatch(fetchCourseBlocks(courseId, authenticatedUser.username)),
|
||||
dispatch(fetchDiscussionTours()),
|
||||
]);
|
||||
}
|
||||
|
||||
fetchBaseData();
|
||||
}, [courseId]);
|
||||
}
|
||||
|
||||
20
src/discussions/discussions-home/CourseHeader.jsx
Normal file
20
src/discussions/discussions-home/CourseHeader.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { LearningHeader } from '@edx/frontend-component-header';
|
||||
|
||||
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
|
||||
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
|
||||
|
||||
const CourseHeader = () => {
|
||||
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
|
||||
|
||||
console.log('CourseHeader', courseNumber, courseTitle, org);
|
||||
|
||||
return (courseNumber || courseTitle || org) && (
|
||||
<LearningHeader courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withConditionalInContextRendering(CourseHeader, false));
|
||||
24
src/discussions/discussions-home/DiscussionActionBar.jsx
Normal file
24
src/discussions/discussions-home/DiscussionActionBar.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useEnableInContextSidebar } from '../data/hooks';
|
||||
import NavigationBar from '../navigation/navigation-bar/NavigationBar';
|
||||
import PostActionsBar from '../posts/post-actions-bar/PostActionsBar';
|
||||
|
||||
const DiscussionActionBar = () => {
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-3 py-0': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
<NavigationBar />
|
||||
<PostActionsBar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DiscussionActionBar);
|
||||
9
src/discussions/discussions-home/DiscussionFooter.jsx
Normal file
9
src/discussions/discussions-home/DiscussionFooter.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
|
||||
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
|
||||
|
||||
const DiscussionFooter = () => <Footer />;
|
||||
|
||||
export default memo(withConditionalInContextRendering(DiscussionFooter, false));
|
||||
17
src/discussions/discussions-home/DiscussionHeader.jsx
Normal file
17
src/discussions/discussions-home/DiscussionHeader.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import CourseTabsNavigation from '../../components/NavigationBar/CourseTabsNavigation';
|
||||
import CourseHeader from './CourseHeader';
|
||||
|
||||
const DiscussionHeader = () => {
|
||||
console.log('DiscussionHeader');
|
||||
|
||||
return (
|
||||
<>
|
||||
<CourseHeader />
|
||||
<CourseTabsNavigation />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(DiscussionHeader);
|
||||
53
src/discussions/discussions-home/DiscussionLayout.jsx
Normal file
53
src/discussions/discussions-home/DiscussionLayout.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { lazy, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useEnableInContextSidebar } from '../data/hooks';
|
||||
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
|
||||
import DiscussionActionBar from './DiscussionActionBar';
|
||||
import DiscussionFooter from './DiscussionFooter';
|
||||
import DiscussionHeader from './DiscussionHeader';
|
||||
import DiscussionSidebar from './DiscussionSidebar';
|
||||
import InfoPage from './InfoPage';
|
||||
import LayoutSwitcher from './LayoutSwitcher';
|
||||
import LegacyBreadcrumb from './LegacyBreadcrumb';
|
||||
|
||||
const DiscussionsRestrictionBanner = lazy(() => import('./DiscussionsRestrictionBanner'));
|
||||
|
||||
const DiscussionsLayout = ({ children }) => {
|
||||
const postActionBarRef = useRef(null);
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DiscussionHeader />
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||
<div
|
||||
ref={postActionBarRef}
|
||||
className={classNames('header-action-bar', {
|
||||
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
<DiscussionActionBar />
|
||||
<DiscussionsRestrictionBanner />
|
||||
</div>
|
||||
<LegacyBreadcrumb />
|
||||
<LayoutSwitcher
|
||||
sidebar={<DiscussionSidebar postActionBarRef={postActionBarRef} />}
|
||||
infoPage={<InfoPage />}
|
||||
>
|
||||
{children}
|
||||
</LayoutSwitcher>
|
||||
<DiscussionsProductTour />
|
||||
</main>
|
||||
<DiscussionFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DiscussionsLayout.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(DiscussionsLayout);
|
||||
@@ -1,23 +1,16 @@
|
||||
import React, {
|
||||
lazy, Suspense, useContext, useEffect, useRef,
|
||||
} from 'react';
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Redirect, Route, Switch, useLocation,
|
||||
} from 'react-router';
|
||||
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
|
||||
import Spinner from '../../components/Spinner';
|
||||
import { RequestStatus, Routes } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
|
||||
} from '../data/hooks';
|
||||
import { useEnableInContextSidebar, useShowLearnersTab } from '../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectEnableInContext } from '../data/selectors';
|
||||
import ResizableSidebar from './ResizableSidebar';
|
||||
|
||||
const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView'));
|
||||
const InContextTopicsView = lazy(() => import('../in-context-topics/TopicsView'));
|
||||
@@ -26,51 +19,24 @@ const LearnersView = lazy(() => import('../learners/LearnersView'));
|
||||
const PostsView = lazy(() => import('../posts/PostsView'));
|
||||
const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
|
||||
|
||||
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
|
||||
const DiscussionSidebar = ({ postActionBarRef }) => {
|
||||
const location = useLocation();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const configStatus = useSelector(selectConfigLoadingStatus);
|
||||
const redirectToLearnersTab = useShowLearnersTab();
|
||||
const sidebarRef = useRef(null);
|
||||
const postActionBarHeight = useContainerSize(postActionBarRef);
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
|
||||
if (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column position-sticky', {
|
||||
'd-none': !displaySidebar,
|
||||
'd-flex overflow-auto box-shadow-centered-1': displaySidebar,
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !enableInContextSidebar,
|
||||
})}
|
||||
data-testid="sidebar"
|
||||
>
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Switch>
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
const memoizedRedirection = React.useMemo(() => (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Switch>
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
<Route
|
||||
path={Routes.TOPICS.ALL}
|
||||
component={InContextTopicsView}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
)}
|
||||
{enableInContext && !enableInContextSidebar && (
|
||||
<Route
|
||||
path={[
|
||||
Routes.TOPICS.TOPIC,
|
||||
@@ -81,19 +47,19 @@ const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
|
||||
component={TopicPostsView}
|
||||
exact
|
||||
/>
|
||||
)}
|
||||
<Route
|
||||
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={PostsView}
|
||||
/>
|
||||
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
|
||||
{redirectToLearnersTab && (
|
||||
)}
|
||||
<Route
|
||||
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={PostsView}
|
||||
/>
|
||||
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
|
||||
{redirectToLearnersTab && (
|
||||
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
|
||||
)}
|
||||
{redirectToLearnersTab && (
|
||||
)}
|
||||
{redirectToLearnersTab && (
|
||||
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
|
||||
)}
|
||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
||||
)}
|
||||
{configStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Redirect
|
||||
from={Routes.DISCUSSIONS.PATH}
|
||||
to={{
|
||||
@@ -101,24 +67,23 @@ const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
|
||||
pathname: Routes.POSTS.ALL_POSTS,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</Switch>
|
||||
</Suspense>
|
||||
), [enableInContext, enableInContextSidebar, configStatus, location, redirectToLearnersTab]);
|
||||
|
||||
return (
|
||||
<ResizableSidebar postActionBarRef={postActionBarRef}>
|
||||
{memoizedRedirection}
|
||||
</ResizableSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
DiscussionSidebar.propTypes = {
|
||||
displaySidebar: PropTypes.bool,
|
||||
postActionBarRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]),
|
||||
};
|
||||
|
||||
DiscussionSidebar.defaultProps = {
|
||||
displaySidebar: false,
|
||||
postActionBarRef: null,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(DiscussionSidebar);
|
||||
|
||||
@@ -1,140 +1,47 @@
|
||||
/* eslint-disable react/jsx-no-constructed-context-values */
|
||||
import React, { lazy, Suspense, useRef } from 'react';
|
||||
import React, { lazy, Suspense, useMemo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Route, Switch, useLocation, useRouteMatch,
|
||||
} from 'react-router';
|
||||
|
||||
import { LearningHeader as Header } from '@edx/frontend-component-header';
|
||||
import { useRouteMatch } from 'react-router';
|
||||
|
||||
import { Spinner } from '../../components';
|
||||
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
|
||||
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
|
||||
import { ALL_ROUTES } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
|
||||
useCourseDiscussionData, useCurrentPage, useEnableInContextSidebar, useRedirectToThread,
|
||||
} from '../data/hooks';
|
||||
import { selectDiscussionProvider, selectEnableInContext } from '../data/selectors';
|
||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||
import messages from '../messages';
|
||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
import DiscussionLayout from './DiscussionLayout';
|
||||
import useFeedbackWrapper from './FeedbackWrapper';
|
||||
|
||||
const Footer = lazy(() => import('@edx/frontend-component-footer'));
|
||||
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
|
||||
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
|
||||
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
|
||||
const NavigationBar = lazy(() => import('../navigation/navigation-bar/NavigationBar'));
|
||||
const DiscussionsProductTour = lazy(() => import('../tours/DiscussionsProductTour'));
|
||||
const DiscussionsRestrictionBanner = lazy(() => import('./DiscussionsRestrictionBanner'));
|
||||
const DiscussionContent = lazy(() => import('./DiscussionContent'));
|
||||
const DiscussionSidebar = lazy(() => import('./DiscussionSidebar'));
|
||||
|
||||
const DiscussionsHome = () => {
|
||||
const location = useLocation();
|
||||
const postActionBarRef = useRef(null);
|
||||
const postEditorVisible = useSelector(selectPostEditorVisible);
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
|
||||
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
|
||||
const { params } = useRouteMatch(ALL_ROUTES);
|
||||
const isRedirectToLearners = useShowLearnersTab();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
let displaySidebar = useSidebarVisible();
|
||||
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
|
||||
const {
|
||||
courseId, postId, topicId, category, learnerUsername,
|
||||
} = params;
|
||||
|
||||
useCourseDiscussionData(courseId);
|
||||
useRedirectToThread(courseId, enableInContextSidebar);
|
||||
useCourseDiscussionData();
|
||||
useRedirectToThread();
|
||||
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.
|
||||
However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed. */
|
||||
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
|
||||
if (displayContentArea) { displaySidebar = isOnDesktop; }
|
||||
const page = useCurrentPage();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const {
|
||||
params: {
|
||||
courseId, postId, topicId, category, learnerUsername,
|
||||
},
|
||||
} = useRouteMatch(ALL_ROUTES);
|
||||
|
||||
const contextValues = useMemo(() => ({
|
||||
page,
|
||||
courseId,
|
||||
postId,
|
||||
topicId,
|
||||
enableInContextSidebar,
|
||||
category,
|
||||
learnerUsername,
|
||||
}), [page, courseId, postId, topicId, enableInContextSidebar, category, learnerUsername]);
|
||||
|
||||
return (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionContext.Provider value={{
|
||||
page,
|
||||
courseId,
|
||||
postId,
|
||||
topicId,
|
||||
enableInContextSidebar,
|
||||
category,
|
||||
learnerUsername,
|
||||
}}
|
||||
>
|
||||
{!enableInContextSidebar && (
|
||||
<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
|
||||
)}
|
||||
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
|
||||
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
|
||||
<div
|
||||
className={classNames('header-action-bar', {
|
||||
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
|
||||
})}
|
||||
ref={postActionBarRef}
|
||||
>
|
||||
<div
|
||||
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
|
||||
'pl-4 pr-3 py-0': enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
{!enableInContextSidebar && (
|
||||
<NavigationBar />
|
||||
)}
|
||||
<PostActionsBar />
|
||||
</div>
|
||||
<DiscussionsRestrictionBanner />
|
||||
</div>
|
||||
{provider === DiscussionProvider.LEGACY && (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={LegacyBreadcrumbMenu}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
<div className="d-flex flex-row position-relative">
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
|
||||
</Suspense>
|
||||
{displayContentArea && (
|
||||
<Suspense fallback={(<Spinner />)}>
|
||||
<DiscussionContent />
|
||||
</Suspense>
|
||||
)}
|
||||
{!displayContentArea && (
|
||||
<Switch>
|
||||
<Route
|
||||
path={Routes.TOPICS.PATH}
|
||||
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
|
||||
/>
|
||||
<Route
|
||||
path={Routes.POSTS.MY_POSTS}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||
/>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
|
||||
/>
|
||||
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
|
||||
</Switch>
|
||||
)}
|
||||
</div>
|
||||
{!enableInContextSidebar && (
|
||||
<DiscussionsProductTour />
|
||||
)}
|
||||
</main>
|
||||
{!enableInContextSidebar && <Footer />}
|
||||
</DiscussionContext.Provider>
|
||||
<DiscussionLayout>
|
||||
<DiscussionContext.Provider value={contextValues}>
|
||||
<DiscussionContent />
|
||||
</DiscussionContext.Provider>
|
||||
</DiscussionLayout>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
37
src/discussions/discussions-home/InfoPage.jsx
Normal file
37
src/discussions/discussions-home/InfoPage.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Route, Switch } from 'react-router';
|
||||
|
||||
import { Routes } from '../../data/constants';
|
||||
import { useEnableInContextSidebar, useShowLearnersTab } from '../data/hooks';
|
||||
import { selectEnableInContext } from '../data/selectors';
|
||||
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
|
||||
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
|
||||
import messages from '../messages';
|
||||
|
||||
const InfoPage = () => {
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const isRedirectToLearners = useShowLearnersTab();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={Routes.TOPICS.PATH}
|
||||
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
|
||||
/>
|
||||
<Route
|
||||
path={Routes.POSTS.MY_POSTS}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
|
||||
/>
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
|
||||
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
|
||||
/>
|
||||
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
|
||||
</Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(InfoPage);
|
||||
40
src/discussions/discussions-home/LayoutSwitcher.jsx
Normal file
40
src/discussions/discussions-home/LayoutSwitcher.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
useIsOnDesktop, useLearnerUsername, usePostId, useSidebarVisible,
|
||||
} from '../data/hooks';
|
||||
import { selectPostEditorVisible } from '../posts/data/selectors';
|
||||
|
||||
const LayoutSwitcher = ({ children, sidebar, infoPage }) => {
|
||||
const postId = usePostId();
|
||||
const learnerUsername = useLearnerUsername();
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
let displaySidebar = useSidebarVisible();
|
||||
const postEditorVisible = useSelector(selectPostEditorVisible);
|
||||
|
||||
const displayContentArea = useMemo(() => {
|
||||
const isContentVisible = postId || postEditorVisible || (learnerUsername && postId);
|
||||
if (isContentVisible) {
|
||||
displaySidebar = isOnDesktop;
|
||||
}
|
||||
return isContentVisible;
|
||||
}, [postId, postEditorVisible, learnerUsername, isOnDesktop]);
|
||||
|
||||
return (
|
||||
<div className="d-flex flex-row position-relative">
|
||||
{displaySidebar && sidebar }
|
||||
{displayContentArea ? children : infoPage }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
LayoutSwitcher.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
sidebar: PropTypes.node.isRequired,
|
||||
infoPage: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(LayoutSwitcher);
|
||||
23
src/discussions/discussions-home/LegacyBreadcrumb.jsx
Normal file
23
src/discussions/discussions-home/LegacyBreadcrumb.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Route } from 'react-router-dom';
|
||||
|
||||
import { DiscussionProvider, Routes } from '../../data/constants';
|
||||
import { selectDiscussionProvider } from '../data/selectors';
|
||||
import LegacyBreadcrumbMenu from '../navigation/breadcrumb-menu/LegacyBreadcrumbMenu';
|
||||
|
||||
const LegacyBreadcrumb = () => {
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
|
||||
return (
|
||||
provider === DiscussionProvider.LEGACY && (
|
||||
<Route
|
||||
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
|
||||
component={LegacyBreadcrumbMenu}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(LegacyBreadcrumb);
|
||||
54
src/discussions/discussions-home/ResizableSidebar.jsx
Normal file
54
src/discussions/discussions-home/ResizableSidebar.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useWindowSize } from '@edx/paragon';
|
||||
|
||||
import {
|
||||
useContainerSize, useEnableInContextSidebar, useIsOnDesktop, useIsOnXLDesktop,
|
||||
} from '../data/hooks';
|
||||
|
||||
const ResizableSidebar = ({ children, postActionBarRef }) => {
|
||||
const sidebarRef = useRef(null);
|
||||
const isOnDesktop = useIsOnDesktop();
|
||||
const isOnXLDesktop = useIsOnXLDesktop();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const postActionBarHeight = useContainerSize(postActionBarRef);
|
||||
const { height: windowHeight } = useWindowSize();
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
|
||||
if (isOnDesktop) {
|
||||
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
}
|
||||
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
|
||||
sidebarRef.current.style.top = `${postActionBarHeight}px`;
|
||||
}
|
||||
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="sidebar"
|
||||
ref={sidebarRef}
|
||||
className={classNames('flex-column position-sticky d-flex overflow-auto box-shadow-centered-1', {
|
||||
'w-100': !isOnDesktop,
|
||||
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
|
||||
'w-25 sidebar-XL-width': isOnXLDesktop,
|
||||
'min-content-height': !enableInContextSidebar,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ResizableSidebar.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
postActionBarRef: PropTypes.oneOfType([
|
||||
PropTypes.func,
|
||||
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(ResizableSidebar);
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useContext, useEffect } from 'react';
|
||||
import React, { memo, useCallback, useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
@@ -6,14 +6,15 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, SearchField } from '@edx/paragon';
|
||||
import { Search as SearchIcon } from '@edx/paragon/icons';
|
||||
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { useCurrentPage } from '../../data/hooks';
|
||||
import postsMessages from '../../posts/post-actions-bar/messages';
|
||||
import { setFilter as setTopicFilter } from '../data/slices';
|
||||
|
||||
const TopicSearchBar = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { page } = useContext(DiscussionContext);
|
||||
const page = useCurrentPage();
|
||||
|
||||
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
|
||||
let searchValue = '';
|
||||
|
||||
@@ -57,4 +58,4 @@ const TopicSearchBar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TopicSearchBar;
|
||||
export default memo(TopicSearchBar);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
import { matchPath } from 'react-router';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
@@ -7,14 +7,14 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Nav } from '@edx/paragon';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { useShowLearnersTab } from '../../data/hooks';
|
||||
import withConditionalInContextRendering from '../../common/withConditionalInContextRendering';
|
||||
import { useCourseId, useShowLearnersTab } from '../../data/hooks';
|
||||
import { discussionsPath } from '../../utils';
|
||||
import messages from './messages';
|
||||
|
||||
const NavigationBar = () => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
const courseId = useCourseId();
|
||||
const showLearnersTab = useShowLearnersTab();
|
||||
|
||||
const navLinks = useMemo(() => ([
|
||||
@@ -41,23 +41,28 @@ const NavigationBar = () => {
|
||||
});
|
||||
}
|
||||
}, [showLearnersTab]);
|
||||
console.log('NavigationBar');
|
||||
|
||||
const navLinksList = useMemo(() => (
|
||||
navLinks.map(link => (
|
||||
<Nav.Item key={link.route}>
|
||||
<Nav.Link
|
||||
key={link.route}
|
||||
as={NavLink}
|
||||
to={discussionsPath(link.route, { courseId })}
|
||||
isActive={link.isActive}
|
||||
>
|
||||
{intl.formatMessage(link.labelMessage)}
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
))
|
||||
), [navLinks]);
|
||||
|
||||
return (
|
||||
<Nav variant="pills" className="py-2 nav-button-group">
|
||||
{navLinks.map(link => (
|
||||
<Nav.Item key={link.route}>
|
||||
<Nav.Link
|
||||
key={link.route}
|
||||
as={NavLink}
|
||||
to={discussionsPath(link.route, { courseId })}
|
||||
isActive={link.isActive}
|
||||
>
|
||||
{intl.formatMessage(link.labelMessage)}
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
))}
|
||||
{navLinksList}
|
||||
</Nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(NavigationBar);
|
||||
export default memo(withConditionalInContextRendering(NavigationBar, false));
|
||||
|
||||
14
src/discussions/posts/AllPostsList.jsx
Normal file
14
src/discussions/posts/AllPostsList.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { selectAllThreadsIds } from './data/selectors';
|
||||
import PostsList from './PostsList';
|
||||
|
||||
const AllPostsList = () => {
|
||||
const postsIds = useSelector(selectAllThreadsIds);
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={null} />;
|
||||
};
|
||||
|
||||
export default memo(AllPostsList);
|
||||
22
src/discussions/posts/CategoryPostsList.jsx
Normal file
22
src/discussions/posts/CategoryPostsList.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { useCategory, useEnableInContextSidebar } from '../data/hooks';
|
||||
import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors';
|
||||
import PostsList from './PostsList';
|
||||
|
||||
const PostsView = () => {
|
||||
const category = useCategory();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
|
||||
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
|
||||
const postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds));
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={topicIds} />;
|
||||
};
|
||||
|
||||
export default memo(PostsView);
|
||||
90
src/discussions/posts/LoadMoreButton.jsx
Normal file
90
src/discussions/posts/LoadMoreButton.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useMemo,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { useCourseId, useCurrentPage } from '../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
||||
import { fetchUserPosts } from '../learners/data/thunks';
|
||||
import messages from '../messages';
|
||||
import { usePostList } from './data/hooks';
|
||||
import {
|
||||
selectThreadFilters, selectThreadNextPage, selectThreadSorting, threadsLoadingStatus,
|
||||
} from './data/selectors';
|
||||
import { fetchThreads } from './data/thunks';
|
||||
import NoResults from './NoResults';
|
||||
import { PostLink } from './post';
|
||||
|
||||
const PostsList = ({
|
||||
postsIds, topicsIds, isTopicTab, parentIsLoading,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const page = useCurrentPage();
|
||||
const courseId = useCourseId();
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
const orderBy = useSelector(selectThreadSorting());
|
||||
const filters = useSelector(selectThreadFilters());
|
||||
const nextPage = useSelector(selectThreadNextPage());
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsStaff = useSelector(selectUserIsStaff);
|
||||
const configStatus = useSelector(selectConfigLoadingStatus);
|
||||
const sortedPostsIds = usePostList(postsIds);
|
||||
const showOwnPosts = page === 'my-posts';
|
||||
|
||||
const loadThreads = useCallback((topicIds, pageNum = undefined, isFilterChanged = false) => {
|
||||
const params = {
|
||||
orderBy,
|
||||
filters,
|
||||
page: pageNum,
|
||||
author: showOwnPosts ? authenticatedUser.username : null,
|
||||
countFlagged: (userHasModerationPrivileges || userIsStaff) || undefined,
|
||||
topicIds,
|
||||
isFilterChanged,
|
||||
};
|
||||
|
||||
if (showOwnPosts && filters.search === '') {
|
||||
dispatch(fetchUserPosts(courseId, params));
|
||||
} else {
|
||||
dispatch(fetchThreads(courseId, params));
|
||||
}
|
||||
}, [courseId, orderBy, filters, showOwnPosts, authenticatedUser.username, userHasModerationPrivileges, userIsStaff]);
|
||||
|
||||
return (
|
||||
loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
|
||||
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
|
||||
<Spinner animation="border" variant="primary" size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
|
||||
<Button onClick={() => loadThreads(topicsIds, nextPage)} variant="primary" size="md">
|
||||
{intl.formatMessage(messages.loadMorePosts)}
|
||||
</Button>
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
PostsList.propTypes = {
|
||||
postsIds: PropTypes.arrayOf(PropTypes.string),
|
||||
topicsIds: PropTypes.arrayOf(PropTypes.string),
|
||||
isTopicTab: PropTypes.bool,
|
||||
parentIsLoading: PropTypes.bool,
|
||||
};
|
||||
|
||||
PostsList.defaultProps = {
|
||||
postsIds: [],
|
||||
topicsIds: undefined,
|
||||
isTopicTab: false,
|
||||
parentIsLoading: undefined,
|
||||
};
|
||||
|
||||
export default React.memo(PostsList);
|
||||
@@ -10,7 +10,7 @@ import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { Button, Spinner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { useCourseId, useCurrentPage } from '../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
|
||||
import { fetchUserPosts } from '../learners/data/thunks';
|
||||
import messages from '../messages';
|
||||
@@ -28,7 +28,8 @@ const PostsList = ({
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { courseId, page } = useContext(DiscussionContext);
|
||||
const page = useCurrentPage();
|
||||
const courseId = useCourseId();
|
||||
const loadingStatus = useSelector(threadsLoadingStatus());
|
||||
const orderBy = useSelector(selectThreadSorting());
|
||||
const filters = useSelector(selectThreadFilters());
|
||||
@@ -80,6 +81,8 @@ const PostsList = ({
|
||||
))
|
||||
), [sortedPostsIds]);
|
||||
|
||||
console.log('sortedPostsIds', sortedPostsIds, loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!parentIsLoading && postInstances}
|
||||
|
||||
32
src/discussions/posts/PostsSearchInfo.jsx
Normal file
32
src/discussions/posts/PostsSearchInfo.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, { memo, useCallback } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { setSearchQuery } from './data/slices';
|
||||
|
||||
const PostsSearchInfo = () => {
|
||||
const dispatch = useDispatch();
|
||||
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 handleOnClear = useCallback(() => {
|
||||
dispatch(setSearchQuery(''));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
searchString && (
|
||||
<SearchInfo
|
||||
count={resultsFound}
|
||||
text={searchString}
|
||||
loadingStatus={loadingStatus}
|
||||
onClear={handleOnClear}
|
||||
textSearchRewrite={textSearchRewrite}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(PostsSearchInfo);
|
||||
@@ -1,103 +1,30 @@
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useMemo,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
|
||||
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 { useCategory, useTopicId } from '../data/hooks';
|
||||
import { handleKeyDown } from '../utils';
|
||||
import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors';
|
||||
import { setSearchQuery } from './data/slices';
|
||||
import PostFilterBar from './post-filter-bar/PostFilterBar';
|
||||
import PostsList from './PostsList';
|
||||
|
||||
const AllPostsList = () => {
|
||||
const postsIds = useSelector(selectAllThreadsIds);
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={null} />;
|
||||
};
|
||||
|
||||
const TopicPostsList = React.memo(({ topicId }) => {
|
||||
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={[topicId]} isTopicTab />;
|
||||
});
|
||||
|
||||
TopicPostsList.propTypes = {
|
||||
topicId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const CategoryPostsList = React.memo(({ category }) => {
|
||||
const { enableInContextSidebar } = useContext(DiscussionContext);
|
||||
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
|
||||
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
|
||||
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
|
||||
const postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds));
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={topicIds} />;
|
||||
});
|
||||
|
||||
CategoryPostsList.propTypes = {
|
||||
category: PropTypes.string.isRequired,
|
||||
};
|
||||
import AllPostsList from './AllPostsList';
|
||||
import CategoryPostsList from './CategoryPostsList';
|
||||
import PostsSearchInfo from './PostsSearchInfo';
|
||||
import TopicPostsList from './TopicPostsList';
|
||||
|
||||
const PostsView = () => {
|
||||
const {
|
||||
topicId,
|
||||
category,
|
||||
courseId,
|
||||
enableInContextSidebar,
|
||||
} = 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(''));
|
||||
}, []);
|
||||
const topicId = useTopicId();
|
||||
const category = useCategory();
|
||||
|
||||
const postsListComponent = useMemo(() => {
|
||||
if (topicId) {
|
||||
return <TopicPostsList topicId={topicId} />;
|
||||
return <TopicPostsList />;
|
||||
}
|
||||
if (category) {
|
||||
return <CategoryPostsList category={category} />;
|
||||
return <CategoryPostsList />;
|
||||
}
|
||||
return <AllPostsList />;
|
||||
}, [topicId, category]);
|
||||
|
||||
return (
|
||||
<div className="discussion-posts d-flex flex-column h-100">
|
||||
{searchString && (
|
||||
<SearchInfo
|
||||
count={resultsFound}
|
||||
text={searchString}
|
||||
loadingStatus={loadingStatus}
|
||||
onClear={handleOnClear}
|
||||
textSearchRewrite={textSearchRewrite}
|
||||
/>
|
||||
)}
|
||||
<PostsSearchInfo />
|
||||
<PostFilterBar />
|
||||
<div className="border-bottom border-light-400" />
|
||||
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
|
||||
@@ -107,4 +34,4 @@ const PostsView = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PostsView;
|
||||
export default memo(PostsView);
|
||||
|
||||
35
src/discussions/posts/TopicPostsList.jsx
Normal file
35
src/discussions/posts/TopicPostsList.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { memo, useEffect } from 'react';
|
||||
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useCourseId, useEnableInContextSidebar, useTopicId } from '../data/hooks';
|
||||
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 { selectTopicThreadsIds } from './data/selectors';
|
||||
import PostsList from './PostsList';
|
||||
|
||||
const TopicPostsList = () => {
|
||||
const dispatch = useDispatch();
|
||||
const topicId = useTopicId();
|
||||
const courseId = useCourseId();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
|
||||
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEmpty(topics)) {
|
||||
dispatch((enableInContext || enableInContextSidebar)
|
||||
? fetchCourseTopicsV3(courseId)
|
||||
: fetchCourseTopics(courseId));
|
||||
}
|
||||
}, [courseId, topics]);
|
||||
|
||||
return <PostsList postsIds={postsIds} topicsIds={[topicId]} isTopicTab />;
|
||||
};
|
||||
|
||||
export default memo(TopicPostsList);
|
||||
@@ -7,20 +7,10 @@ import { selectThreadsByIds } from './selectors';
|
||||
|
||||
export const usePostList = (ids) => {
|
||||
const posts = useSelector(selectThreadsByIds(ids));
|
||||
const pinnedPostsIds = [];
|
||||
const unpinnedPostsIds = [];
|
||||
|
||||
const sortedIds = useMemo(() => {
|
||||
posts.forEach((post) => {
|
||||
if (post.pinned) {
|
||||
pinnedPostsIds.push(post.id);
|
||||
} else {
|
||||
unpinnedPostsIds.push(post.id);
|
||||
}
|
||||
});
|
||||
|
||||
return [...pinnedPostsIds, ...unpinnedPostsIds];
|
||||
}, [posts]);
|
||||
const sortedIds = useMemo(() => (
|
||||
[...posts].sort((a, b) => (b.pinned - a.pinned)).map((post) => post.id)
|
||||
), [posts]);
|
||||
|
||||
return sortedIds;
|
||||
};
|
||||
|
||||
46
src/discussions/posts/post-actions-bar/AddPostButton.jsx
Normal file
46
src/discussions/posts/post-actions-bar/AddPostButton.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { useEnableInContextSidebar, useUserPostingEnabled } from '../../data/hooks';
|
||||
import { selectConfigLoadingStatus } from '../../data/selectors';
|
||||
import { showPostEditor } from '../data';
|
||||
import messages from './messages';
|
||||
|
||||
const AddPostButton = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const loadingStatus = useSelector(selectConfigLoadingStatus);
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
|
||||
const handleAddPost = useCallback(() => {
|
||||
dispatch(showPostEditor());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
loadingStatus === RequestStatus.SUCCESSFUL && isUserPrivilegedInPostingRestriction && (
|
||||
<>
|
||||
{!enableInContextSidebar && <div className="border-right border-light-400 mx-3" />}
|
||||
<Button
|
||||
variant={enableInContextSidebar ? 'plain' : 'brand'}
|
||||
className={classNames(
|
||||
'my-0 font-style border-0 line-height-24',
|
||||
{ 'px-3 py-10px border-0': enableInContextSidebar },
|
||||
)}
|
||||
onClick={handleAddPost}
|
||||
size={enableInContextSidebar ? 'md' : 'sm'}
|
||||
>
|
||||
{intl.formatMessage(messages.addAPost)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPostButton;
|
||||
@@ -1,70 +1,36 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton,
|
||||
} from '@edx/paragon';
|
||||
import { Icon, IconButton } from '@edx/paragon';
|
||||
import { Close } from '@edx/paragon/icons';
|
||||
|
||||
import Search from '../../../components/Search';
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { useUserPostingEnabled } from '../../data/hooks';
|
||||
import { selectConfigLoadingStatus, selectEnableInContext } from '../../data/selectors';
|
||||
import { TopicSearchBar as IncontextSearch } from '../../in-context-topics/topic-search';
|
||||
import { useEnableInContextSidebar } from '../../data/hooks';
|
||||
import { postMessageToParent } from '../../utils';
|
||||
import { showPostEditor } from '../data';
|
||||
import AddPostButton from './AddPostButton';
|
||||
import messages from './messages';
|
||||
import SearchField from './SearchField';
|
||||
|
||||
import './actionBar.scss';
|
||||
|
||||
const PostActionsBar = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const loadingStatus = useSelector(selectConfigLoadingStatus);
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
|
||||
const { enableInContextSidebar, page } = useContext(DiscussionContext);
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
|
||||
const handleCloseInContext = useCallback(() => {
|
||||
postMessageToParent('learning.events.sidebar.close');
|
||||
}, []);
|
||||
|
||||
const handleAddPost = useCallback(() => {
|
||||
dispatch(showPostEditor());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
|
||||
{!enableInContextSidebar && (
|
||||
(enableInContext && ['topics', 'category'].includes(page))
|
||||
? <IncontextSearch />
|
||||
: <Search />
|
||||
)}
|
||||
<SearchField />
|
||||
{enableInContextSidebar && (
|
||||
<h4 className="d-flex flex-grow-1 font-weight-bold font-style my-0 py-10px align-self-center">
|
||||
{intl.formatMessage(messages.title)}
|
||||
</h4>
|
||||
)}
|
||||
{loadingStatus === RequestStatus.SUCCESSFUL && isUserPrivilegedInPostingRestriction && (
|
||||
<>
|
||||
{!enableInContextSidebar && <div className="border-right border-light-400 mx-3" />}
|
||||
<Button
|
||||
variant={enableInContextSidebar ? 'plain' : 'brand'}
|
||||
className={classNames(
|
||||
'my-0 font-style border-0 line-height-24',
|
||||
{ 'px-3 py-10px border-0': enableInContextSidebar },
|
||||
)}
|
||||
onClick={handleAddPost}
|
||||
size={enableInContextSidebar ? 'md' : 'sm'}
|
||||
>
|
||||
{intl.formatMessage(messages.addAPost)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<AddPostButton />
|
||||
{enableInContextSidebar && (
|
||||
<>
|
||||
<div className="border-right border-light-300 mr-3 ml-1.5 my-10px" />
|
||||
@@ -84,4 +50,4 @@ const PostActionsBar = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PostActionsBar;
|
||||
export default memo(PostActionsBar);
|
||||
|
||||
20
src/discussions/posts/post-actions-bar/SearchField.jsx
Normal file
20
src/discussions/posts/post-actions-bar/SearchField.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import Search from '../../../components/Search';
|
||||
import withConditionalInContextRendering from '../../common/withConditionalInContextRendering';
|
||||
import { useCurrentPage } from '../../data/hooks';
|
||||
import { selectEnableInContext } from '../../data/selectors';
|
||||
import { TopicSearchBar as IncontextSearch } from '../../in-context-topics/topic-search';
|
||||
|
||||
const SearchField = () => {
|
||||
const enableInContext = useSelector(selectEnableInContext);
|
||||
const page = useCurrentPage();
|
||||
|
||||
return (
|
||||
enableInContext && ['topics', 'category'].includes(page) ? <IncontextSearch /> : <Search />
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(withConditionalInContextRendering(SearchField, false));
|
||||
37
src/discussions/posts/post-filter-bar/ActionItem.jsx
Normal file
37
src/discussions/posts/post-filter-bar/ActionItem.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Form, Icon } from '@edx/paragon';
|
||||
import { Check } from '@edx/paragon/icons';
|
||||
|
||||
const ActionItem = ({
|
||||
id, label, value, selected,
|
||||
}) => (
|
||||
<label
|
||||
htmlFor={id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
className="focus border-bottom-0 d-flex align-items-center w-100 py-2 m-0 font-weight-500 filter-menu"
|
||||
data-testid={value === selected ? 'selected' : null}
|
||||
aria-checked={value === selected}
|
||||
tabIndex={value === selected ? '0' : '-1'}
|
||||
>
|
||||
<Icon src={Check} className={classNames('text-success mr-2', { invisible: value !== selected })} />
|
||||
<Form.Radio id={id} className="sr-only sr-only-focusable" value={value} tabIndex="0">
|
||||
{label}
|
||||
</Form.Radio>
|
||||
<span aria-hidden className="text-truncate">
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
ActionItem.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
selected: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(ActionItem);
|
||||
81
src/discussions/posts/post-filter-bar/CohortFilters.jsx
Normal file
81
src/discussions/posts/post-filter-bar/CohortFilters.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { capitalize, isEmpty, toString } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { Form, Spinner } from '@edx/paragon';
|
||||
|
||||
import { RequestStatus } from '../../../data/constants';
|
||||
import { selectCourseCohorts } from '../../cohorts/data/selectors';
|
||||
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
|
||||
import { useCourseId } from '../../data/hooks';
|
||||
import { selectUserHasModerationPrivileges } from '../../data/selectors';
|
||||
import { selectThreadFilters } from '../data/selectors';
|
||||
import ActionItem from './ActionItem';
|
||||
import withFilterHandleChange from './withFilterHandleChange';
|
||||
|
||||
const CohortFilters = ({ handleSortFilterChange }) => {
|
||||
const dispatch = useDispatch();
|
||||
const courseId = useCourseId();
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const currentFilters = useSelector(selectThreadFilters());
|
||||
const { status } = useSelector(state => state.cohorts);
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
|
||||
useEffect(() => {
|
||||
if (userHasModerationPrivileges && isEmpty(cohorts)) {
|
||||
dispatch(fetchCourseCohorts(courseId));
|
||||
}
|
||||
}, [userHasModerationPrivileges]);
|
||||
|
||||
const cohortsMenu = useMemo(() => (
|
||||
<>
|
||||
<ActionItem
|
||||
id="all-groups"
|
||||
label="All groups"
|
||||
value=""
|
||||
selected={currentFilters.cohort}
|
||||
/>
|
||||
{cohorts.map(cohort => (
|
||||
<ActionItem
|
||||
key={cohort.id}
|
||||
id={toString(cohort.id)}
|
||||
label={capitalize(cohort.name)}
|
||||
value={toString(cohort.id)}
|
||||
selected={currentFilters.cohort}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
), [cohorts, currentFilters.cohort]);
|
||||
|
||||
return (
|
||||
userHasModerationPrivileges && (
|
||||
<>
|
||||
<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={currentFilters.cohort}
|
||||
onChange={handleSortFilterChange}
|
||||
>
|
||||
{cohortsMenu}
|
||||
</Form.RadioSet>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
CohortFilters.propTypes = {
|
||||
handleSortFilterChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(withFilterHandleChange(CohortFilters));
|
||||
69
src/discussions/posts/post-filter-bar/CollapsibleFilter.jsx
Normal file
69
src/discussions/posts/post-filter-bar/CollapsibleFilter.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { capitalize, toString } from 'lodash';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Collapsible, Icon } from '@edx/paragon';
|
||||
import { Tune } from '@edx/paragon/icons';
|
||||
|
||||
import { selectCourseCohorts } from '../../cohorts/data/selectors';
|
||||
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
const CollapsibleFilter = ({ children }) => {
|
||||
const intl = useIntl();
|
||||
const currentSorting = useSelector(selectThreadSorting());
|
||||
const currentFilters = useSelector(selectThreadFilters());
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
const selectedCohort = useMemo(() => (
|
||||
cohorts.find(cohort => (
|
||||
toString(cohort.id) === currentFilters.cohort
|
||||
))
|
||||
), [cohorts, currentFilters.cohort]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setOpen(!isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Collapsible.Advanced
|
||||
open={isOpen}
|
||||
onToggle={handleToggle}
|
||||
className="filter-bar collapsible-card-lg border-0"
|
||||
>
|
||||
<Collapsible.Trigger className="collapsible-trigger border-0">
|
||||
<span className="text-primary-500 pr-4 font-style">
|
||||
{intl.formatMessage(messages.sortFilterStatus, {
|
||||
own: false,
|
||||
type: currentFilters.postType,
|
||||
sort: currentSorting,
|
||||
status: currentFilters.status,
|
||||
cohortType: selectedCohort?.name ? 'group' : 'all',
|
||||
cohort: capitalize(selectedCohort?.name),
|
||||
})}
|
||||
</span>
|
||||
<span id="icon-tune">
|
||||
<Collapsible.Visible whenClosed>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
|
||||
{children}
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
);
|
||||
};
|
||||
|
||||
CollapsibleFilter.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(CollapsibleFilter);
|
||||
@@ -1,317 +1,24 @@
|
||||
import React, {
|
||||
useCallback, useContext, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { capitalize, isEmpty, toString } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Collapsible, Form, Icon, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { Check, Tune } from '@edx/paragon/icons';
|
||||
import CohortFilters from './CohortFilters';
|
||||
import CollapsibleFilter from './CollapsibleFilter';
|
||||
import PostSortFilters from './PostSortFilters';
|
||||
import PostStatusFilters from './PostStatusFilters';
|
||||
import PostTypeFilters from './PostTypeFilters';
|
||||
|
||||
import {
|
||||
PostsStatusFilter, RequestStatus,
|
||||
ThreadOrdering, ThreadType,
|
||||
} from '../../../data/constants';
|
||||
import { selectCourseCohorts } from '../../cohorts/data/selectors';
|
||||
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
|
||||
import {
|
||||
setCohortFilter, setPostsTypeFilter, setSortedBy, setStatusFilter,
|
||||
} from '../data';
|
||||
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
export const ActionItem = React.memo(({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
selected,
|
||||
}) => (
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="focus border-bottom-0 d-flex align-items-center w-100 py-2 m-0 font-weight-500 filter-menu"
|
||||
data-testid={value === selected ? 'selected' : null}
|
||||
style={{ cursor: 'pointer' }}
|
||||
aria-checked={value === selected}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={value === selected ? '0' : '-1'}
|
||||
>
|
||||
<Icon src={Check} className={classNames('text-success mr-2', { invisible: value !== selected })} />
|
||||
<Form.Radio id={id} className="sr-only sr-only-focusable" value={value} tabIndex="0">
|
||||
{label}
|
||||
</Form.Radio>
|
||||
<span aria-hidden className="text-truncate">
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
));
|
||||
|
||||
ActionItem.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
selected: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const PostFilterBar = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { courseId } = useParams();
|
||||
const { page } = useContext(DiscussionContext);
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const currentSorting = useSelector(selectThreadSorting());
|
||||
const currentFilters = useSelector(selectThreadFilters());
|
||||
const { status } = useSelector(state => state.cohorts);
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
const selectedCohort = useMemo(() => (
|
||||
cohorts.find(cohort => (
|
||||
toString(cohort.id) === currentFilters.cohort
|
||||
))
|
||||
), [cohorts, currentFilters.cohort]);
|
||||
|
||||
const handleSortFilterChange = useCallback((event) => {
|
||||
const currentType = currentFilters.postType;
|
||||
const currentStatus = currentFilters.status;
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
} = event.currentTarget;
|
||||
const filterContentEventProperties = {
|
||||
statusFilter: currentStatus,
|
||||
threadTypeFilter: currentType,
|
||||
sortFilter: currentSorting,
|
||||
cohortFilter: selectedCohort,
|
||||
triggeredBy: name,
|
||||
};
|
||||
|
||||
if (name === 'type') {
|
||||
dispatch(setPostsTypeFilter(value));
|
||||
if (
|
||||
value === ThreadType.DISCUSSION && currentStatus === PostsStatusFilter.UNANSWERED
|
||||
) {
|
||||
// You can't filter discussions by unanswered
|
||||
dispatch(setStatusFilter(PostsStatusFilter.ALL));
|
||||
}
|
||||
filterContentEventProperties.threadTypeFilter = value;
|
||||
}
|
||||
|
||||
if (name === 'status') {
|
||||
dispatch(setStatusFilter(value));
|
||||
if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) {
|
||||
// You can't filter discussions by unanswered so switch type to questions
|
||||
dispatch(setPostsTypeFilter(ThreadType.QUESTION));
|
||||
}
|
||||
if (value === PostsStatusFilter.UNRESPONDED && currentType !== ThreadType.DISCUSSION) {
|
||||
// You can't filter questions by not responded so switch type to discussion
|
||||
dispatch(setPostsTypeFilter(ThreadType.DISCUSSION));
|
||||
}
|
||||
filterContentEventProperties.statusFilter = value;
|
||||
}
|
||||
|
||||
if (name === 'sort') {
|
||||
dispatch(setSortedBy(value));
|
||||
filterContentEventProperties.sortFilter = value;
|
||||
}
|
||||
|
||||
if (name === 'cohort') {
|
||||
dispatch(setCohortFilter(value));
|
||||
filterContentEventProperties.cohortFilter = value;
|
||||
}
|
||||
|
||||
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
|
||||
}, [currentFilters, currentSorting, dispatch, selectedCohort]);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setOpen(!isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userHasModerationPrivileges && isEmpty(cohorts)) {
|
||||
dispatch(fetchCourseCohorts(courseId));
|
||||
}
|
||||
}, [courseId, userHasModerationPrivileges]);
|
||||
|
||||
const renderCohortFilter = useMemo(() => (
|
||||
userHasModerationPrivileges && (
|
||||
<>
|
||||
<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={currentFilters.cohort}
|
||||
onChange={handleSortFilterChange}
|
||||
>
|
||||
<ActionItem
|
||||
id="all-groups"
|
||||
label="All groups"
|
||||
value=""
|
||||
selected={currentFilters.cohort}
|
||||
/>
|
||||
{cohorts.map(cohort => (
|
||||
<ActionItem
|
||||
key={cohort.id}
|
||||
id={toString(cohort.id)}
|
||||
label={capitalize(cohort.name)}
|
||||
value={toString(cohort.id)}
|
||||
selected={currentFilters.cohort}
|
||||
/>
|
||||
))}
|
||||
</Form.RadioSet>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
), [cohorts, currentFilters.cohort, handleSortFilterChange, status, userHasModerationPrivileges]);
|
||||
|
||||
return (
|
||||
<Collapsible.Advanced
|
||||
open={isOpen}
|
||||
onToggle={handleToggle}
|
||||
className="filter-bar collapsible-card-lg border-0"
|
||||
>
|
||||
<Collapsible.Trigger className="collapsible-trigger border-0">
|
||||
<span className="text-primary-500 pr-4 font-style">
|
||||
{intl.formatMessage(messages.sortFilterStatus, {
|
||||
own: false,
|
||||
type: currentFilters.postType,
|
||||
sort: currentSorting,
|
||||
status: currentFilters.status,
|
||||
cohortType: selectedCohort?.name ? 'group' : 'all',
|
||||
cohort: capitalize(selectedCohort?.name),
|
||||
})}
|
||||
</span>
|
||||
<span id="icon-tune">
|
||||
<Collapsible.Visible whenClosed>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<Icon src={Tune} />
|
||||
</Collapsible.Visible>
|
||||
</span>
|
||||
</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">
|
||||
<Form.RadioSet
|
||||
name="type"
|
||||
className="d-flex flex-column list-group list-group-flush"
|
||||
value={currentFilters.postType}
|
||||
onChange={handleSortFilterChange}
|
||||
>
|
||||
<ActionItem
|
||||
id="type-all"
|
||||
label={intl.formatMessage(messages.allPosts)}
|
||||
value={ThreadType.ALL}
|
||||
selected={currentFilters.postType}
|
||||
/>
|
||||
<ActionItem
|
||||
id="type-discussions"
|
||||
label={intl.formatMessage(messages.filterDiscussions)}
|
||||
value={ThreadType.DISCUSSION}
|
||||
selected={currentFilters.postType}
|
||||
/>
|
||||
<ActionItem
|
||||
id="type-questions"
|
||||
label={intl.formatMessage(messages.filterQuestions)}
|
||||
value={ThreadType.QUESTION}
|
||||
selected={currentFilters.postType}
|
||||
/>
|
||||
</Form.RadioSet>
|
||||
<Form.RadioSet
|
||||
name="status"
|
||||
className="d-flex flex-column list-group list-group-flush"
|
||||
value={currentFilters.status}
|
||||
onChange={handleSortFilterChange}
|
||||
>
|
||||
<ActionItem
|
||||
id="status-any"
|
||||
label={intl.formatMessage(messages.filterAnyStatus)}
|
||||
value={PostsStatusFilter.ALL}
|
||||
selected={currentFilters.status}
|
||||
/>
|
||||
<ActionItem
|
||||
id="status-unread"
|
||||
label={intl.formatMessage(messages.filterUnread)}
|
||||
value={PostsStatusFilter.UNREAD}
|
||||
selected={currentFilters.status}
|
||||
/>
|
||||
{page !== 'my-posts' && (
|
||||
<ActionItem
|
||||
id="status-following"
|
||||
label={intl.formatMessage(messages.filterFollowing)}
|
||||
value={PostsStatusFilter.FOLLOWING}
|
||||
selected={currentFilters.status}
|
||||
/>
|
||||
)}
|
||||
{(userHasModerationPrivileges || userIsGroupTa) && (
|
||||
<ActionItem
|
||||
id="status-reported"
|
||||
label={intl.formatMessage(messages.filterReported)}
|
||||
value={PostsStatusFilter.REPORTED}
|
||||
selected={currentFilters.status}
|
||||
/>
|
||||
)}
|
||||
<ActionItem
|
||||
id="status-unanswered"
|
||||
label={intl.formatMessage(messages.filterUnanswered)}
|
||||
value={PostsStatusFilter.UNANSWERED}
|
||||
selected={currentFilters.status}
|
||||
/>
|
||||
<ActionItem
|
||||
id="status-unresponded"
|
||||
label={intl.formatMessage(messages.filterUnresponded)}
|
||||
value={PostsStatusFilter.UNRESPONDED}
|
||||
selected={currentFilters.status}
|
||||
/>
|
||||
</Form.RadioSet>
|
||||
<Form.RadioSet
|
||||
name="sort"
|
||||
className="d-flex flex-column list-group list-group-flush"
|
||||
value={currentSorting}
|
||||
onChange={handleSortFilterChange}
|
||||
>
|
||||
<ActionItem
|
||||
id="sort-activity"
|
||||
label={intl.formatMessage(messages.lastActivityAt)}
|
||||
value={ThreadOrdering.BY_LAST_ACTIVITY}
|
||||
selected={currentSorting}
|
||||
/>
|
||||
<ActionItem
|
||||
id="sort-comments"
|
||||
label={intl.formatMessage(messages.commentCount)}
|
||||
value={ThreadOrdering.BY_COMMENT_COUNT}
|
||||
selected={currentSorting}
|
||||
/>
|
||||
<ActionItem
|
||||
id="sort-votes"
|
||||
label={intl.formatMessage(messages.voteCount)}
|
||||
value={ThreadOrdering.BY_VOTE_COUNT}
|
||||
selected={currentSorting}
|
||||
/>
|
||||
</Form.RadioSet>
|
||||
</div>
|
||||
{renderCohortFilter}
|
||||
</Form>
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
);
|
||||
};
|
||||
const PostFilterBar = () => (
|
||||
<CollapsibleFilter>
|
||||
<Form>
|
||||
<div className="d-flex flex-row py-2 justify-content-between">
|
||||
<PostTypeFilters />
|
||||
<PostStatusFilters />
|
||||
<PostSortFilters />
|
||||
</div>
|
||||
<CohortFilters />
|
||||
</Form>
|
||||
</CollapsibleFilter>
|
||||
);
|
||||
|
||||
export default React.memo(PostFilterBar);
|
||||
|
||||
52
src/discussions/posts/post-filter-bar/PostSortFilters.jsx
Normal file
52
src/discussions/posts/post-filter-bar/PostSortFilters.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
import { ThreadOrdering } from '../../../data/constants';
|
||||
import { selectThreadSorting } from '../data/selectors';
|
||||
import ActionItem from './ActionItem';
|
||||
import messages from './messages';
|
||||
import withFilterHandleChange from './withFilterHandleChange';
|
||||
|
||||
const PostSortFilters = ({ handleSortFilterChange }) => {
|
||||
const intl = useIntl();
|
||||
const currentSorting = useSelector(selectThreadSorting());
|
||||
|
||||
return (
|
||||
<Form.RadioSet
|
||||
name="sort"
|
||||
className="d-flex flex-column list-group list-group-flush"
|
||||
value={currentSorting}
|
||||
onChange={handleSortFilterChange}
|
||||
>
|
||||
<ActionItem
|
||||
id="sort-activity"
|
||||
label={intl.formatMessage(messages.lastActivityAt)}
|
||||
value={ThreadOrdering.BY_LAST_ACTIVITY}
|
||||
selected={currentSorting}
|
||||
/>
|
||||
<ActionItem
|
||||
id="sort-comments"
|
||||
label={intl.formatMessage(messages.commentCount)}
|
||||
value={ThreadOrdering.BY_COMMENT_COUNT}
|
||||
selected={currentSorting}
|
||||
/>
|
||||
<ActionItem
|
||||
id="sort-votes"
|
||||
label={intl.formatMessage(messages.voteCount)}
|
||||
value={ThreadOrdering.BY_VOTE_COUNT}
|
||||
selected={currentSorting}
|
||||
/>
|
||||
</Form.RadioSet>
|
||||
);
|
||||
};
|
||||
|
||||
PostSortFilters.propTypes = {
|
||||
handleSortFilterChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(withFilterHandleChange(PostSortFilters));
|
||||
79
src/discussions/posts/post-filter-bar/PostStatusFilters.jsx
Normal file
79
src/discussions/posts/post-filter-bar/PostStatusFilters.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
import { PostsStatusFilter } from '../../../data/constants';
|
||||
import { useCurrentPage } from '../../data/hooks';
|
||||
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
|
||||
import { selectThreadFilters } from '../data/selectors';
|
||||
import ActionItem from './ActionItem';
|
||||
import messages from './messages';
|
||||
import withFilterHandleChange from './withFilterHandleChange';
|
||||
|
||||
const PostStatusFilters = ({ handleSortFilterChange }) => {
|
||||
const intl = useIntl();
|
||||
const page = useCurrentPage();
|
||||
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
|
||||
const userIsGroupTa = useSelector(selectUserIsGroupTa);
|
||||
const { status } = useSelector(selectThreadFilters());
|
||||
|
||||
return (
|
||||
<Form.RadioSet
|
||||
name="status"
|
||||
className="d-flex flex-column list-group list-group-flush"
|
||||
value={status}
|
||||
onChange={handleSortFilterChange}
|
||||
>
|
||||
<ActionItem
|
||||
id="status-any"
|
||||
label={intl.formatMessage(messages.filterAnyStatus)}
|
||||
value={PostsStatusFilter.ALL}
|
||||
selected={status}
|
||||
/>
|
||||
<ActionItem
|
||||
id="status-unread"
|
||||
label={intl.formatMessage(messages.filterUnread)}
|
||||
value={PostsStatusFilter.UNREAD}
|
||||
selected={status}
|
||||
/>
|
||||
{page !== 'my-posts' && (
|
||||
<ActionItem
|
||||
id="status-following"
|
||||
label={intl.formatMessage(messages.filterFollowing)}
|
||||
value={PostsStatusFilter.FOLLOWING}
|
||||
selected={status}
|
||||
/>
|
||||
)}
|
||||
{(userHasModerationPrivileges || userIsGroupTa) && (
|
||||
<ActionItem
|
||||
id="status-reported"
|
||||
label={intl.formatMessage(messages.filterReported)}
|
||||
value={PostsStatusFilter.REPORTED}
|
||||
selected={status}
|
||||
/>
|
||||
)}
|
||||
<ActionItem
|
||||
id="status-unanswered"
|
||||
label={intl.formatMessage(messages.filterUnanswered)}
|
||||
value={PostsStatusFilter.UNANSWERED}
|
||||
selected={status}
|
||||
/>
|
||||
<ActionItem
|
||||
id="status-unresponded"
|
||||
label={intl.formatMessage(messages.filterUnresponded)}
|
||||
value={PostsStatusFilter.UNRESPONDED}
|
||||
selected={status}
|
||||
/>
|
||||
</Form.RadioSet>
|
||||
);
|
||||
};
|
||||
|
||||
PostStatusFilters.propTypes = {
|
||||
handleSortFilterChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(withFilterHandleChange(PostStatusFilters));
|
||||
52
src/discussions/posts/post-filter-bar/PostTypeFilters.jsx
Normal file
52
src/discussions/posts/post-filter-bar/PostTypeFilters.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@edx/paragon';
|
||||
|
||||
import { ThreadType } from '../../../data/constants';
|
||||
import { selectThreadFilters } from '../data/selectors';
|
||||
import ActionItem from './ActionItem';
|
||||
import messages from './messages';
|
||||
import withFilterHandleChange from './withFilterHandleChange';
|
||||
|
||||
const PostTypeFilters = ({ handleSortFilterChange }) => {
|
||||
const intl = useIntl();
|
||||
const currentFilters = useSelector(selectThreadFilters());
|
||||
|
||||
return (
|
||||
<Form.RadioSet
|
||||
name="type"
|
||||
className="d-flex flex-column list-group list-group-flush"
|
||||
value={currentFilters.postType}
|
||||
onChange={handleSortFilterChange}
|
||||
>
|
||||
<ActionItem
|
||||
id="type-all"
|
||||
label={intl.formatMessage(messages.allPosts)}
|
||||
value={ThreadType.ALL}
|
||||
selected={currentFilters.postType}
|
||||
/>
|
||||
<ActionItem
|
||||
id="type-discussions"
|
||||
label={intl.formatMessage(messages.filterDiscussions)}
|
||||
value={ThreadType.DISCUSSION}
|
||||
selected={currentFilters.postType}
|
||||
/>
|
||||
<ActionItem
|
||||
id="type-questions"
|
||||
label={intl.formatMessage(messages.filterQuestions)}
|
||||
value={ThreadType.QUESTION}
|
||||
selected={currentFilters.postType}
|
||||
/>
|
||||
</Form.RadioSet>
|
||||
);
|
||||
};
|
||||
|
||||
PostTypeFilters.propTypes = {
|
||||
handleSortFilterChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default React.memo(withFilterHandleChange(PostTypeFilters));
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { toString } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { PostsStatusFilter, ThreadType } from '../../../data/constants';
|
||||
import { selectCourseCohorts } from '../../cohorts/data/selectors';
|
||||
import {
|
||||
setCohortFilter, setPostsTypeFilter, setSortedBy, setStatusFilter,
|
||||
} from '../data';
|
||||
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
|
||||
|
||||
const withFilterHandleChange = WrappedComponent => (
|
||||
function FilterHandleChange(props) {
|
||||
const dispatch = useDispatch();
|
||||
const currentSorting = useSelector(selectThreadSorting());
|
||||
const currentFilters = useSelector(selectThreadFilters());
|
||||
const cohorts = useSelector(selectCourseCohorts);
|
||||
|
||||
const selectedCohort = useMemo(() => (
|
||||
cohorts?.find(cohort => (
|
||||
toString(cohort.id) === currentFilters.cohort
|
||||
))
|
||||
), [cohorts, currentFilters.cohort]);
|
||||
|
||||
const handleSortFilterChange = useCallback((event) => {
|
||||
const currentType = currentFilters.postType;
|
||||
const currentStatus = currentFilters.status;
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
} = event.currentTarget;
|
||||
const filterContentEventProperties = {
|
||||
statusFilter: currentStatus,
|
||||
threadTypeFilter: currentType,
|
||||
sortFilter: currentSorting,
|
||||
cohortFilter: selectedCohort,
|
||||
triggeredBy: name,
|
||||
};
|
||||
|
||||
if (name === 'type') {
|
||||
dispatch(setPostsTypeFilter(value));
|
||||
if (
|
||||
value === ThreadType.DISCUSSION && currentStatus === PostsStatusFilter.UNANSWERED
|
||||
) {
|
||||
// You can't filter discussions by unanswered
|
||||
dispatch(setStatusFilter(PostsStatusFilter.ALL));
|
||||
}
|
||||
filterContentEventProperties.threadTypeFilter = value;
|
||||
}
|
||||
|
||||
if (name === 'status') {
|
||||
dispatch(setStatusFilter(value));
|
||||
if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) {
|
||||
// You can't filter discussions by unanswered so switch type to questions
|
||||
dispatch(setPostsTypeFilter(ThreadType.QUESTION));
|
||||
}
|
||||
if (value === PostsStatusFilter.UNRESPONDED && currentType !== ThreadType.DISCUSSION) {
|
||||
// You can't filter questions by not responded so switch type to discussion
|
||||
dispatch(setPostsTypeFilter(ThreadType.DISCUSSION));
|
||||
}
|
||||
filterContentEventProperties.statusFilter = value;
|
||||
}
|
||||
|
||||
if (name === 'sort') {
|
||||
dispatch(setSortedBy(value));
|
||||
filterContentEventProperties.sortFilter = value;
|
||||
}
|
||||
|
||||
if (name === 'cohort') {
|
||||
dispatch(setCohortFilter(value));
|
||||
filterContentEventProperties.cohortFilter = value;
|
||||
}
|
||||
|
||||
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
|
||||
}, [currentFilters, currentSorting, selectedCohort]);
|
||||
|
||||
return <WrappedComponent {...props} handleSortFilterChange={handleSortFilterChange} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default withFilterHandleChange;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import classNames from 'classnames';
|
||||
@@ -12,7 +12,9 @@ import { CheckCircle } from '@edx/paragon/icons';
|
||||
import { PushPin } from '../../../components/icons';
|
||||
import { AvatarOutlineAndLabelColors, Routes, ThreadType } from '../../../data/constants';
|
||||
import AuthorLabel from '../../common/AuthorLabel';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import {
|
||||
useCategory, useCourseId, useCurrentPage, useEnableInContextSidebar, useLearnerUsername, usePostId,
|
||||
} from '../../data/hooks';
|
||||
import { discussionsPath, isPostPreviewAvailable } from '../../utils';
|
||||
import { selectThread } from '../data/selectors';
|
||||
import messages from './messages';
|
||||
@@ -25,18 +27,18 @@ const PostLink = ({
|
||||
showDivider,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
courseId,
|
||||
postId: selectedPostId,
|
||||
page,
|
||||
enableInContextSidebar,
|
||||
category,
|
||||
learnerUsername,
|
||||
} = useContext(DiscussionContext);
|
||||
const courseId = useCourseId();
|
||||
const selectedPostId = usePostId();
|
||||
const page = useCurrentPage();
|
||||
const enableInContextSidebar = useEnableInContextSidebar();
|
||||
const category = useCategory();
|
||||
const learnerUsername = useLearnerUsername();
|
||||
|
||||
const {
|
||||
topicId, hasEndorsed, type, author, authorLabel, abuseFlagged, abuseFlaggedCount, read, commentCount,
|
||||
unreadCommentCount, id, pinned, previewBody, title, voted, voteCount, following, groupId, groupName, createdAt,
|
||||
} = useSelector(selectThread(postId));
|
||||
|
||||
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
|
||||
0: enableInContextSidebar ? 'in-context' : undefined,
|
||||
courseId,
|
||||
@@ -45,6 +47,7 @@ const PostLink = ({
|
||||
category,
|
||||
learnerUsername,
|
||||
});
|
||||
|
||||
const showAnsweredBadge = hasEndorsed && type === ThreadType.QUESTION;
|
||||
const authorLabelColor = AvatarOutlineAndLabelColors[authorLabel];
|
||||
const canSeeReportedBadge = abuseFlagged || abuseFlaggedCount;
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { ProductTour } from '@edx/paragon';
|
||||
|
||||
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
|
||||
import { useTourConfiguration } from '../data/hooks';
|
||||
import { fetchDiscussionTours } from './data/thunks';
|
||||
|
||||
const DiscussionsProductTour = () => {
|
||||
const dispatch = useDispatch();
|
||||
const config = useTourConfiguration();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchDiscussionTours());
|
||||
}, []);
|
||||
console.log('DiscussionsProductTour');
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
<>
|
||||
{!isEmpty(config) && (
|
||||
<ProductTour
|
||||
tours={config}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
!isEmpty(config) && (
|
||||
<ProductTour
|
||||
tours={config}
|
||||
/>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscussionsProductTour;
|
||||
export default memo(withConditionalInContextRendering(DiscussionsProductTour, false));
|
||||
|
||||
Reference in New Issue
Block a user