Compare commits

...

1 Commits

Author SHA1 Message Date
Awais Ansari
5236e110dd feat: updated MFE structure and reduced re-rendering for sidebar 2023-11-16 13:13:01 +05:00
42 changed files with 1277 additions and 758 deletions

View File

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

View File

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

View File

@@ -1,3 +1,3 @@
/* eslint-disable import/prefer-default-export */
const selectCourseTabs = state => state.courseTabs;
export const selectCourseTabs = state => state.courseTabs;
export default selectCourseTabs;

View File

@@ -1,2 +0,0 @@
/* eslint-disable import/prefer-default-export */
export { default as CourseTabsNavigation } from './CourseTabsNavigation';

View File

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

View File

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

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

View File

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

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

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

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

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

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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