feat: implements new v3 in-context topics structure (#371)

This commit is contained in:
Awais Ansari
2022-12-30 17:15:47 +05:00
committed by GitHub
parent 3622d46538
commit 3f890401e8
58 changed files with 1305 additions and 663 deletions

View File

@@ -120,28 +120,27 @@ function FilterBar({
<div className="d-flex flex-row py-2 justify-content-between">
{filters.map((value) => (
<Form.RadioSet
key={value.name}
name={value.name}
className="d-flex flex-column list-group list-group-flush"
value={selectedFilters[value.name]}
onChange={onFilterChange}
>
{
value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})
}
{value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
key={element.id}
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})}
</Form.RadioSet>
))}
</div>

View File

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

View File

@@ -190,6 +190,8 @@ export const Routes = {
CATEGORY: `${BASE_PATH}/category/:category`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
TOPIC: `${BASE_PATH}/topics/:topicId`,
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId`,
TOPIC_POST_EDIT: `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
},
};

View File

@@ -38,12 +38,6 @@ export const selectTopicsUnderCategory = createSelector(
),
);
export const selectSequences = createSelector(
selectChapters,
selectBlocks,
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
);
export const selectArchivedTopics = createSelector(
state => state.topics.topics,
state => state.topics.archivedIds || [],

View File

@@ -159,7 +159,7 @@ function CommentsView({ intl }) {
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const {
courseId, learnerUsername, category, topicId, page, inContext,
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
} = useContext(DiscussionContext);
useEffect(() => {
@@ -180,7 +180,7 @@ function CommentsView({ intl }) {
return (
<>
{!isOnDesktop && (
inContext ? (
enableInContextSidebar ? (
<>
<div className="px-4 py-1.5 bg-white">
<Button
@@ -212,8 +212,8 @@ function CommentsView({ intl }) {
)
)}
<div className={classNames('discussion-comments d-flex flex-column card', {
'm-4 p-4.5': !inContext,
'p-4 rounded-0 border-0 mb-4': inContext,
'm-4 p-4.5': !enableInContextSidebar,
'p-4 rounded-0 border-0 mb-4': enableInContextSidebar,
})}
>
<Post post={thread} />

View File

@@ -16,7 +16,7 @@ function ResponseEditor({
intl,
addWrappingDiv,
}) {
const { inContext } = useContext(DiscussionContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const [addingResponse, setAddingResponse] = useState(false);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
@@ -38,7 +38,7 @@ function ResponseEditor({
<div className={classNames({ 'mb-4': addWrappingDiv }, 'actions d-flex')}>
<Button
variant="primary"
className={classNames('px-2.5 py-2 font-size-14', { 'w-100': inContext })}
className={classNames('px-2.5 py-2 font-size-14', { 'w-100': enableInContextSidebar })}
onClick={() => setAddingResponse(true)}
style={{
lineHeight: '20px',

View File

@@ -27,7 +27,7 @@ function ActionsDropdown({
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const actions = useActions(commentOrPost);
const { inContext } = useContext(DiscussionContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const handleActions = (action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
@@ -57,7 +57,7 @@ function ActionsDropdown({
onClose={close}
positionRef={target}
isOpen={isOpen}
placement={inContext ? 'left' : 'auto-start'}
placement={enableInContextSidebar ? 'left' : 'auto-start'}
>
<div
className="bg-white p-1 shadow d-flex flex-column"

View File

@@ -6,7 +6,7 @@ export const DiscussionContext = React.createContext({
courseId: null,
postId: null,
topicId: null,
inContext: false,
enableInContextSidebar: false,
category: null,
learnerUsername: null,
});

View File

@@ -16,12 +16,12 @@ import { fetchCourseBlocks } from '../../data/thunks';
import { DiscussionContext } from '../common/context';
import { clearRedirect } from '../posts/data';
import { threadsLoadingStatus } from '../posts/data/selectors';
import { selectTopics, topicsLoadingStatus } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks';
import { selectTopics } from '../topics/data/selectors';
import { discussionsPath, inBlackoutDateRange } from '../utils';
import {
selectAreThreadsFiltered,
selectBlackoutDate,
selectEnableInContext,
selectIsCourseAdmin,
selectIsCourseStaff,
selectLearnersTabEnabled,
@@ -47,33 +47,20 @@ export function useTotalTopicThreadCount() {
}
export const useSidebarVisible = () => {
const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount);
const threadsCallStatus = useSelector(threadsLoadingStatus);
const isViewingSpecificTopic = useRouteMatch(Routes.TOPICS.TOPIC);
const enableInContext = useSelector(selectEnableInContext);
const isViewingTopics = useRouteMatch(Routes.TOPICS.ALL);
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
const topicsLoading = useSelector(topicsLoadingStatus);
const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount);
const isThreadsEmpty = Boolean(useSelector(threadsLoadingStatus()) === RequestStatus.SUCCESSFUL && !totalThreads);
const isIncontextTopicsView = Boolean(useRouteMatch(Routes.TOPICS.PATH) && enableInContext);
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics?.isExact || isViewingLearners));
if (
isViewingSpecificTopic
&& isViewingSpecificTopic.isExact
&& totalThreads > 0
&& topicsLoading === RequestStatus.SUCCESSFUL
&& threadsCallStatus === RequestStatus.SUCCESSFUL
) {
return false;
}
if (isFiltered) {
if (isIncontextTopicsView) {
return true;
}
if ((isViewingTopics && isViewingTopics.isExact) || isViewingLearners) {
return true;
}
return totalThreads > 0;
return !hideSidebar;
};
export function useCourseDiscussionData(courseId) {
@@ -83,7 +70,6 @@ export function useCourseDiscussionData(courseId) {
useEffect(() => {
async function fetchBaseData() {
await dispatch(fetchCourseConfig(courseId));
await dispatch(fetchCourseTopics(courseId));
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
}
@@ -91,7 +77,7 @@ export function useCourseDiscussionData(courseId) {
}, [courseId]);
}
export function useRedirectToThread(courseId, inContext) {
export function useRedirectToThread(courseId, enableInContextSidebar) {
const dispatch = useDispatch();
const redirectToThread = useSelector(
(state) => state.threads.redirectToThread,
@@ -104,7 +90,7 @@ export function useRedirectToThread(courseId, inContext) {
// stored in redirectToThread
if (redirectToThread) {
dispatch(clearRedirect());
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[inContext ? 'topics' : 'my-posts'], {
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
courseId,
postId: redirectToThread.threadId,
topicId: redirectToThread.topicId,

View File

@@ -28,6 +28,8 @@ export const selectIsCourseAdmin = state => state.config.isCourseAdmin;
export const selectIsCourseStaff = state => state.config.isCourseStaff;
export const selectEnableInContext = state => state.config.enableInContext;
export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,
@@ -51,7 +53,7 @@ export function selectAreThreadsFiltered(state) {
export function selectTopicThreadCount(topicId) {
return state => {
const topic = state.topics.topics[topicId];
const topic = topicId && state.topics?.topics[topicId];
if (!topic) {
return 0;
}

View File

@@ -27,6 +27,7 @@ const configSlice = createSlice({
reasonCodesEnabled: false,
editReasons: [],
postCloseReasons: [],
enableInContext: false,
},
reducers: {
fetchConfigRequest: (state) => {

View File

@@ -3,7 +3,7 @@ import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import {
LearnersOrdering,
DiscussionProvider, LearnersOrdering,
PostsStatusFilter,
} from '../../data/constants';
import { setSortedBy } from '../learners/data';
@@ -36,7 +36,10 @@ export function fetchCourseConfig(courseId) {
learnerSort = LearnersOrdering.BY_FLAG;
}
dispatch(fetchConfigSuccess(camelCaseObject(config)));
dispatch(fetchConfigSuccess(camelCaseObject({
...config,
enable_in_context: config.provider === DiscussionProvider.OPEN_EDX,
})));
dispatch(setSortedBy(learnerSort));
dispatch(setStatusFilter(postsFilterStatus));
} catch (error) {

View File

@@ -14,51 +14,72 @@ import { DiscussionContext } from '../common/context';
import {
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
} from '../data/hooks';
import { selectconfigLoadingStatus } from '../data/selectors';
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
import { TopicPostsView, TopicsView as InContextTopicsView } from '../in-context-topics';
import { LearnerPostsView, LearnersView } from '../learners';
import { PostsView } from '../posts';
import { TopicsView } from '../topics';
import { TopicsView as LegacyTopicsView } from '../topics';
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
const { enableInContextSidebar } = useContext(DiscussionContext);
const enableInContext = useSelector(selectEnableInContext);
const configStatus = useSelector(selectconfigLoadingStatus);
const redirectToLearnersTab = useShowLearnersTab();
const sidebarRef = useRef(null);
const postActionBarHeight = useContainerSize(postActionBarRef);
const { height: windowHeight } = useWindowSize();
const { inContext } = useContext(DiscussionContext);
useEffect(() => {
if (sidebarRef && postActionBarHeight && !inContext) {
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, inContext]);
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
return (
<div
ref={sidebarRef}
className={classNames('flex-column position-sticky', {
className={classNames('flex-column position-sticky', {
'd-none': !displaySidebar,
'd-flex overflow-auto': displaySidebar,
'w-100': !isOnDesktop,
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
'w-25 sidebar-XL-width': isOnXLDesktop,
'min-content-height': !inContext,
'min-content-height': !enableInContextSidebar,
})}
data-testid="sidebar"
>
<Switch>
{enableInContext && !enableInContextSidebar && (
<Route
path={Routes.TOPICS.ALL}
component={InContextTopicsView}
exact
/>
)}
{enableInContext && !enableInContextSidebar && (
<Route
path={[
Routes.TOPICS.TOPIC,
Routes.TOPICS.CATEGORY,
Routes.TOPICS.TOPIC_POST,
Routes.TOPICS.TOPIC_POST_EDIT,
]}
component={TopicPostsView}
exact
/>
)}
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]}
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={PostsView}
/>
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
)}
@@ -66,13 +87,13 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
</div>

View File

@@ -12,60 +12,52 @@ import { getConfig } from '@edx/frontend-platform';
import { PostActionsBar } from '../../components';
import { CourseTabsNavigation } from '../../components/NavigationBar';
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
} from '../data/hooks';
import { selectDiscussionProvider } from '../data/selectors';
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 { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { selectPostEditorVisible } from '../posts/data/selectors';
import { postMessageToParent } from '../utils';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionContent from './DiscussionContent';
import DiscussionSidebar from './DiscussionSidebar';
import InformationBanner from './InformationsBanner';
import InformationBanner from './InformationBanner';
export default function DiscussionsHome() {
const location = useLocation();
const postActionBarRef = useRef(null);
const postEditorVisible = useSelector(
(state) => state.threads.postEditorVisible,
);
const {
params: { page },
} = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
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: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
const { params } = useRouteMatch(ALL_ROUTES);
const isRedirectToLearners = useShowLearnersTab();
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const {
courseId,
postId,
topicId,
category,
learnerUsername,
} = params;
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
// Display the content area if we are currently viewing/editing a post or creating one.
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
let displaySidebar = useSidebarVisible();
const isOnDesktop = useIsOnDesktop();
let displaySidebar = useSidebarVisible();
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const {
courseId, postId, topicId, category, learnerUsername,
} = params;
const { courseNumber, courseTitle, org } = useSelector((state) => state.courseTabs);
if (displayContentArea) {
// 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.
displaySidebar = isOnDesktop;
}
const provider = useSelector(selectDiscussionProvider);
useCourseDiscussionData(courseId);
useRedirectToThread(courseId, inContext);
useRedirectToThread(courseId, enableInContextSidebar);
/* 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, onlyshow the sidebar if the content area isn't displayed. */
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
if (displayContentArea) { displaySidebar = isOnDesktop; }
useEffect(() => {
if (path && path !== 'undefined') {
postMessageToParent('discussions.navigate', { path });
@@ -78,33 +70,35 @@ export default function DiscussionsHome() {
courseId,
postId,
topicId,
inContext,
enableInContextSidebar,
category,
learnerUsername,
}}
>
{!inContext && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
{!enableInContextSidebar && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
{!inContext && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
<div
className={classNames('header-action-bar', { 'shadow-none border-light-300 border-bottom': inContext })}
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-2.5 py-1.5': inContext,
'pl-4 pr-2.5 py-1.5': enableInContextSidebar,
})}
>
{!inContext && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
<PostActionsBar inContext={inContext} />
{!enableInContextSidebar && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
<PostActionsBar />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<BlackoutInformationBanner />
</div>
{!inContext && (
{provider === DiscussionProvider.LEGACY && (
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
component={LegacyBreadcrumbMenu}
/>
)}
<div className="d-flex flex-row">
@@ -112,7 +106,10 @@ export default function DiscussionsHome() {
{displayContentArea && <DiscussionContent />}
{!displayContentArea && (
<Switch>
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
<Route
path={Routes.TOPICS.PATH}
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
/>
<Route
path={Routes.POSTS.MY_POSTS}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
@@ -126,7 +123,7 @@ export default function DiscussionsHome() {
)}
</div>
</main>
{!inContext && <Footer />}
{!enableInContextSidebar && <Footer />}
</DiscussionContext.Provider>
);
}

View File

@@ -1,18 +1,25 @@
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import navigationBarMessages from '../navigation/navigation-bar/messages';
import DiscussionsHome from './DiscussionsHome';
const courseConfigApiUrl = getCourseConfigApiUrl();
let axiosMock;
let store;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -40,7 +47,7 @@ describe('DiscussionsHome', () => {
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
});
@@ -63,7 +70,9 @@ describe('DiscussionsHome', () => {
});
test('in-context view should show close button', async () => {
renderComponent(`/${courseId}/topics?inContext`);
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
renderComponent(`/${courseId}/topics?inContextSidebar`);
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
.not
@@ -73,10 +82,12 @@ describe('DiscussionsHome', () => {
});
test('the close button should post a message', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { parent } = window;
delete window.parent;
window.parent = { ...window, postMessage: jest.fn() };
renderComponent(`/${courseId}/topics?inContext`);
renderComponent(`/${courseId}/topics?inContextSidebar`);
const closeButton = screen.queryByRole('button', { name: 'Close' });
@@ -88,7 +99,7 @@ describe('DiscussionsHome', () => {
window.parent = parent;
});
test('header, course navigation bar and footer are visible', async () => {
test('header, course navigation bar and footer are only visible in Discussions MFE', async () => {
renderComponent();
expect(screen.queryByRole('banner')).toBeInTheDocument();
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();

View File

@@ -8,7 +8,7 @@ import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import InformationBanner from './InformationsBanner';
import InformationBanner from './InformationBanner';
import '../posts/data/__factories__';

View File

@@ -52,7 +52,11 @@ function EmptyPosts({ intl, subTitleMessage }) {
}
EmptyPosts.propTypes = {
subTitleMessage: propTypes.string.isRequired,
subTitleMessage: propTypes.shape({
id: propTypes.string,
defaultMessage: propTypes.string,
description: propTypes.string,
}).isRequired,
intl: intlShape.isRequired,
};

View File

@@ -0,0 +1,82 @@
import React, { useContext } from 'react';
import first from 'lodash/first';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Spinner } from '@edx/paragon';
import { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectTopicThreads } from '../posts/data/selectors';
import PostsList from '../posts/PostsList';
import { discussionsPath, handleKeyDown } from '../utils';
import {
selectLoadingStatus, selectNonCoursewareTopics, selectSubsectionUnits, selectUnits,
} from './data/selectors';
import { BackButton, NoResults } from './components';
import messages from './messages';
import { Topic } from './topic';
function TopicPostsView({ intl }) {
const location = useLocation();
const { courseId, topicId, category } = useContext(DiscussionContext);
const topicsLoadingStatus = useSelector(selectLoadingStatus);
const posts = useSelector(selectTopicThreads([topicId]));
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
const selectedUnit = useSelector(selectUnits)?.find(unit => unit.id === topicId);
const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId);
const backButtonPath = () => {
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
return discussionsPath(path, params)(location);
};
return (
<div className="discussion-posts d-flex flex-column h-100">
{topicId ? (
<BackButton
path={backButtonPath()}
title={selectedUnit?.name || selectedNonCoursewareTopic?.name || intl.formatMessage(messages.unnamedTopic)}
/>
) : (
<BackButton
path={discussionsPath(Routes.TOPICS.ALL, { courseId })(location)}
title={first(selectedSubsectionUnits)?.parentTitle || intl.formatMessage(messages.unnamedSubsection)}
/>
)}
<div className="border-bottom border-light-400" />
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
{topicId ? (
<PostsList
posts={posts}
topics={[topicId]}
/>
) : (
selectedSubsectionUnits?.map((unit) => (
<Topic
key={unit.id}
topic={unit}
/>
))
)}
{(category && selectedSubsectionUnits.length === 0 && topicsLoadingStatus === RequestStatus.SUCCESSFUL) && (
<NoResults />
)}
{(category && topicsLoadingStatus === RequestStatus.IN_PROGRESS) && (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
)}
</div>
</div>
);
}
TopicPostsView.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TopicPostsView);

View File

@@ -0,0 +1,104 @@
import React, { useContext, useEffect } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { Spinner } from '@edx/paragon';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectDiscussionProvider } from '../data/selectors';
import NoResults from '../posts/NoResults';
import { handleKeyDown } from '../utils';
import {
selectCoursewareTopics,
selectFilteredTopics, selectLoadingStatus,
selectNonCoursewareTopics, selectTopicFilter,
} from './data/selectors';
import { setFilter } from './data/slices';
import { fetchCourseTopicsV3 } from './data/thunks';
import { SectionBaseGroup, Topic } from './topic';
function TopicsList() {
const loadingStatus = useSelector(selectLoadingStatus);
const coursewareTopics = useSelector(selectCoursewareTopics);
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
return (
<>
{nonCoursewareTopics?.map((topic, index) => (
<Topic
key={topic.id}
topic={topic}
showDivider={(nonCoursewareTopics.length - 1) !== index}
/>
))}
{coursewareTopics?.map((topic, index) => (
<SectionBaseGroup
key={topic.id}
section={topic?.children}
sectionId={topic.id}
sectionTitle={topic.displayName}
showDivider={(coursewareTopics.length - 1) !== index}
/>
))}
{loadingStatus === RequestStatus.IN_PROGRESS && (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
)}
</>
);
}
function TopicsView() {
const dispatch = useDispatch();
const { courseId } = useContext(DiscussionContext);
const provider = useSelector(selectDiscussionProvider);
const topicFilter = useSelector(selectTopicFilter);
const filteredTopics = useSelector(selectFilteredTopics);
const loadingStatus = useSelector(selectLoadingStatus);
useEffect(() => {
if (provider) {
dispatch(fetchCourseTopicsV3(courseId));
}
}, [provider]);
return (
<div className="d-flex flex-column h-100" data-testid="inContext-topics-view">
{topicFilter && (
<>
<SearchInfo
text={topicFilter}
count={filteredTopics.length}
loadingStatus={loadingStatus}
onClear={() => dispatch(setFilter(''))}
/>
{filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
</>
)}
<div
className={classNames('list-group list-group-flush flex-fill', {
'justify-content-center': loadingStatus === RequestStatus.IN_PROGRESS,
})}
role="list"
onKeyDown={e => handleKeyDown(e)}
>
{topicFilter ? (
filteredTopics?.map((topic) => (
<Topic
key={topic.id}
topic={topic}
/>
))
) : (
<TopicsList />
)}
</div>
</div>
);
}
export default TopicsView;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import messages from '../messages';
function BackButton({ intl, path, title }) {
const history = useHistory();
return (
<>
<div className="d-flex py-2.5 px-3 font-weight-bold border-light-400 border-bottom">
<IconButton
src={ArrowBack}
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(path)}
alt={intl.formatMessage(messages.backAlt)}
/>
<div className="d-flex flex-fill justify-content-center align-items-center mr-4.5">
{title}
</div>
</div>
<div className="border-bottom border-light-400" />
</>
);
}
BackButton.propTypes = {
intl: intlShape.isRequired,
path: PropTypes.shape({}).isRequired,
title: PropTypes.string.isRequired,
};
export default injectIntl(BackButton);

View File

@@ -0,0 +1,83 @@
import React, { useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useIsOnDesktop } from '../../data/hooks';
import { selectPostThreadCount } from '../../data/selectors';
import EmptyPage from '../../empty-posts/EmptyPage';
import messages from '../../messages';
import { messages as postMessages, showPostEditor } from '../../posts';
import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors';
function EmptyTopics({ intl }) {
const match = useRouteMatch(ALL_ROUTES);
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
const topicThreadsCount = useSelector(selectPostThreadCount);
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
function addPost() {
return dispatch(showPostEditor());
}
const isOnDesktop = useIsOnDesktop();
let title = messages.emptyTitle;
let fullWidth = false;
let subTitle;
let action;
let actionText;
if (!isOnDesktop) {
return null;
}
if (match.params.topicId) {
if (topicThreadsCount > 0) {
title = messages.noPostSelected;
} else {
action = addPost;
actionText = postMessages.addAPost;
subTitle = messages.emptyTopic;
fullWidth = true;
}
} else if (match.params.category) {
if (enableInContextSidebar && topicThreadsCount > 0) {
title = messages.noPostSelected;
} else if (courseWareThreadsCount > 0) {
title = messages.noTopicSelected;
} else {
action = addPost;
actionText = postMessages.addAPost;
subTitle = messages.emptyTopic;
fullWidth = true;
}
} else if (hasGlobalThreads) {
title = messages.noTopicSelected;
} else {
fullWidth = true;
}
return (
<EmptyPage
title={intl.formatMessage(title)}
subTitle={subTitle && intl.formatMessage(subTitle)}
action={action}
actionText={actionText && intl.formatMessage(actionText)}
fullWidth={fullWidth}
/>
);
}
EmptyTopics.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EmptyTopics);

View File

@@ -0,0 +1,29 @@
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { selectTopics } from '../data/selectors';
import messages from '../messages';
function NoResults({ intl }) {
const topics = useSelector(selectTopics);
let title = messages.nothingHere;
const helpMessage = '';
if (topics.length === 0) {
title = messages.noTopicExists;
}
return (
<div className="h-100 mt-5 align-self-center mx-auto w-50 d-flex flex-column justify-content-center text-center">
<h4 className="font-weight-normal text-primary-500">{intl.formatMessage(title)}</h4>
{ helpMessage && <small className="font-weight-normal text-gray-700">{intl.formatMessage(helpMessage)}</small>}
</div>
);
}
NoResults.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(NoResults);

View File

@@ -0,0 +1,4 @@
/* eslint-disable import/prefer-default-export */
export { default as BackButton } from './BackButton';
export { default as EmptyTopic } from './EmptyTopics';
export { default as NoResults } from './NoResults';

View File

@@ -0,0 +1,11 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getApiBaseUrl } from '../../../data/constants';
export async function getCourseTopicsV3(courseId) {
const url = `${getApiBaseUrl()}/api/discussion/v3/course_topics/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}

View File

@@ -0,0 +1 @@
export * from './slices';

View File

@@ -0,0 +1,51 @@
/* eslint-disable import/prefer-default-export */
import { createSelector } from '@reduxjs/toolkit';
export const selectTopicFilter = state => state.inContextTopics.filter.trim().toLowerCase();
export const selectTopics = state => state.inContextTopics.topics;
export const selectCoursewareTopics = state => state.inContextTopics.coursewareTopics;
export const selectNonCoursewareTopics = state => state.inContextTopics.nonCoursewareTopics;
export const selectNonCoursewareIds = state => state.inContextTopics.nonCoursewareIds;
export const selectUnits = state => state.inContextTopics.units;
export const selectSubsectionUnits = subsectionId => state => state.inContextTopics.units?.filter(
unit => unit.parentId === subsectionId,
);
export const selectLoadingStatus = state => state.inContextTopics.status;
export const selectFilteredTopics = createSelector(
selectUnits,
selectNonCoursewareTopics,
selectTopicFilter,
(units, nonCoursewareTopics, filter) => (
(units && nonCoursewareTopics && filter) && [...units, ...nonCoursewareTopics]?.filter(
topic => topic.name.toLowerCase().includes(filter),
)
),
);
export const selectTotalTopicsThreadsCount = createSelector(
selectUnits,
selectNonCoursewareTopics,
(units, nonCoursewareTopics) => (
(units && nonCoursewareTopics) && [...units, ...nonCoursewareTopics]?.reduce((total, topic) => (
total + topic.threadCounts.discussion + topic.threadCounts.question
), 0)
),
);
export const selectCourseWareThreadsCount = category => createSelector(
selectSubsectionUnits(category),
(units) => (
units?.reduce((total, unit) => (
total + unit.threadCounts.discussion + unit.threadCounts.question
), 0)
),
);

View File

@@ -0,0 +1,49 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../../data/constants';
const topicsSlice = createSlice({
name: 'inContextTopics',
initialState: {
status: RequestStatus.IN_PROGRESS,
topics: [],
coursewareTopics: [],
nonCoursewareTopics: [],
nonCoursewareIds: [],
units: [],
filter: '',
},
reducers: {
fetchCourseTopicsRequest: (state) => {
state.status = RequestStatus.IN_PROGRESS;
},
fetchCourseTopicsSuccess: (state, { payload }) => {
state.status = RequestStatus.SUCCESSFUL;
state.topics = payload.topics;
state.coursewareTopics = payload.coursewareTopics;
state.nonCoursewareTopics = payload.nonCoursewareTopics;
state.nonCoursewareIds = payload.nonCoursewareIds;
state.units = payload.units;
},
fetchCourseTopicsFailed: (state) => {
state.status = RequestStatus.FAILED;
},
fetchCourseTopicsDenied: (state) => {
state.status = RequestStatus.DENIED;
},
setFilter: (state, { payload }) => {
state.filter = payload;
},
},
});
export const {
fetchCourseTopicsRequest,
fetchCourseTopicsSuccess,
fetchCourseTopicsFailed,
setFilter,
setSortBy,
} = topicsSlice.actions;
export const inContextTopicsReducer = topicsSlice.reducer;

View File

@@ -0,0 +1,56 @@
/* eslint-disable import/prefer-default-export */
import { reduce } from 'lodash';
import { logError } from '@edx/frontend-platform/logging';
import { getCourseTopicsV3 } from './api';
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
function normalizeTopicsV3(topics) {
const coursewareUnits = reduce(topics, (arrayOfUnits, chapter) => {
if (chapter?.children) {
return [
...arrayOfUnits,
...reduce(chapter.children, (units, sequential) => {
if (sequential?.children) {
return [
...units,
...sequential.children.map((unit) => ({
...unit,
parentId: sequential.id,
parentTitle: sequential.displayName,
})),
];
}
return units;
}, []),
];
}
return arrayOfUnits;
}, []);
const coursewareTopics = topics.filter((topic) => topic.courseware);
const nonCoursewareTopics = topics.filter((topic) => !topic.courseware);
const nonCoursewareIds = nonCoursewareTopics?.map((topic) => topic.id);
return {
topics,
units: coursewareUnits,
coursewareTopics,
nonCoursewareTopics,
nonCoursewareIds,
};
}
export function fetchCourseTopicsV3(courseId) {
return async (dispatch) => {
try {
dispatch(fetchCourseTopicsRequest({ courseId }));
const data = await getCourseTopicsV3(courseId);
dispatch(fetchCourseTopicsSuccess(normalizeTopicsV3(data)));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
logError(error);
}
};
}

View File

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

View File

@@ -0,0 +1,74 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
backAlt: {
id: 'discussions.topics.backAlt',
defaultMessage: 'Back to topics list',
description: 'Display back button text used to navigate back to topics list',
},
discussions: {
id: 'discussions.topics.discussions',
defaultMessage: `{count, plural,
=0 {Discussion}
one {# Discussion}
other {# Discussions}
}`,
description: 'Display tooltip text used to indicate how many posts type are discussion',
},
questions: {
id: 'discussions.topics.questions',
defaultMessage: `{count, plural,
=0 {Question}
one {# Question}
other {# Questions}
}`,
description: 'Display tooltip text used to indicate how many posts type are questions',
},
reported: {
id: 'discussions.topics.reported',
defaultMessage: '{reported} reported',
description: 'Display tooltip text used to indicate how many posts are reported',
},
previouslyReported: {
id: 'discussions.topics.previouslyReported',
defaultMessage: '{previouslyReported} previously reported',
description: 'Display tooltip text used to indicate how many posts are previously reported',
},
searchTopics: {
id: 'discussions.topics.find.label',
defaultMessage: 'Search topics',
description: 'Placeholder text in search bar',
},
unnamedSection: {
id: 'discussions.topics.unnamed.section.label',
defaultMessage: 'Unnamed Section',
description: 'Text to display in place of section name if section name is empty',
},
unnamedSubsection: {
id: 'discussions.topics.unnamed.subsection.label',
defaultMessage: 'Unnamed Subsection',
description: 'Text to display in place of subsection name if subsection name is empty',
},
unnamedTopic: {
id: 'discussions.subtopics.unnamed.topic.label',
defaultMessage: 'Unnamed Topic',
description: 'Text to display in place of topic name if topic name is empty',
},
noTopicExists: {
id: 'discussions.topics.title',
defaultMessage: 'No topic exists',
description: 'Text to display in place of topic list if topic does not exist',
},
createTopic: {
id: 'discussions.topics.createTopic',
defaultMessage: 'Please contact you admin to create a topic',
description: 'Helping Text to display in place of topic list if topic does not exist',
},
nothingHere: {
id: 'discussions.topics.nothing',
defaultMessage: 'Nothing here yet',
description: 'Helping Text to display if nothing here yet',
},
});
export default messages;

View File

@@ -0,0 +1,64 @@
import React, { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } 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 postsMessages from '../../posts/post-actions-bar/messages';
import { setFilter as setTopicFilter } from '../data/slices';
function TopicSearchBar({ intl }) {
const dispatch = useDispatch();
const { page } = useContext(DiscussionContext);
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
let searchValue = '';
const onClear = () => {
dispatch(setTopicFilter(''));
};
const onChange = (query) => {
searchValue = query;
};
const onSubmit = (query) => {
if (query === '') {
return;
}
dispatch(setTopicFilter(query));
};
useEffect(() => onClear(), [page]);
return (
<>
<SearchField.Advanced
onClear={onClear}
onChange={onChange}
onSubmit={onSubmit}
value={topicSearch}
>
<SearchField.Label />
<SearchField.Input
style={{ paddingRight: '1rem' }}
placeholder={intl.formatMessage(postsMessages.search, { page: 'topics' })}
/>
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
/>
</span>
</SearchField.Advanced>
</>
);
}
TopicSearchBar.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TopicSearchBar);

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { SearchField } from '@edx/paragon';
import { setFilter } from '../data';
import messages from '../messages';
function TopicSearchResultBar({ intl }) {
const dispatch = useDispatch();
return (
<div className="d-flex flex-row p-1 align-items-center">
<SearchField
className="flex-fill m-1 border-0"
placeholder={intl.formatMessage(messages.searchTopics)}
onSubmit={(query) => dispatch(setFilter(query))}
onChange={(query) => dispatch(setFilter(query))}
/>
</div>
);
}
TopicSearchResultBar.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TopicSearchResultBar);

View File

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

View File

@@ -0,0 +1,90 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Routes } from '../../../data/constants';
import { discussionsPath } from '../../utils';
import messages from '../messages';
import { topicShape } from './Topic';
function SectionBaseGroup({
section,
sectionTitle,
sectionId,
showDivider,
intl,
}) {
const { courseId } = useParams();
const isSelected = (id) => window.location.pathname.includes(id);
const sectionUrl = (id) => discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: id,
});
return (
<div
className="discussion-topic-group d-flex flex-column text-primary-500"
data-section-id={sectionId}
data-testid="section-group"
>
<div className="pt-3 px-4 font-weight-bold">
{sectionTitle || intl.formatMessage(messages.unnamedSection)}
</div>
{section.map((subsection, index) => (
<Link
className={classNames('subsection p-0 text-decoration-none text-primary-500', {
'border-bottom border-light-400': (section.length - 1 !== index),
})}
key={subsection.id}
role="option"
data-subsection-id={subsection.id}
data-testid="subsection-group"
to={sectionUrl(subsection.id)}
onClick={() => isSelected(subsection.id)}
aria-current={isSelected(section.id) ? 'page' : undefined}
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
>
<div className="d-flex flex-row py-3.5 px-4">
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="topic-name text-truncate">
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
</div>
</div>
</div>
</div>
</Link>
))}
{showDivider && (
<>
<div className="divider border-top border-light-500" />
<div className="divider pt-1 bg-light-300" />
</>
)}
</div>
);
}
SectionBaseGroup.propTypes = {
section: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
blockId: PropTypes.string,
lmsWebUrl: PropTypes.string,
legacyWebUrl: PropTypes.string,
studentViewUrl: PropTypes.string,
type: PropTypes.string,
displayName: PropTypes.string,
children: PropTypes.arrayOf(topicShape),
})).isRequired,
sectionTitle: PropTypes.string.isRequired,
sectionId: PropTypes.string.isRequired,
showDivider: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(SectionBaseGroup);

View File

@@ -0,0 +1,155 @@
/* eslint-disable no-unused-vars, react/forbid-prop-types */
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
import { Routes } from '../../../data/constants';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
import { discussionsPath } from '../../utils';
import messages from '../messages';
function Topic({
topic,
showDivider,
index,
intl,
}) {
const { courseId } = useParams();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { inactiveFlags, activeFlags } = topic;
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
const isSelected = (id) => window.location.pathname.includes(id);
const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, {
courseId,
topicId: topic.id,
});
return (
<>
<Link
className={classNames('discussion-topic p-0 text-decoration-none text-primary-500', {
'border-light-400 border-bottom': showDivider,
})}
data-topic-id={topic.id}
to={topicUrl}
onClick={() => isSelected(topic.id)}
aria-current={isSelected(topic.id) ? 'page' : undefined}
role="option"
tabIndex={(isSelected(topic.id) || index === 0) ? 0 : -1}
>
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="topic-name text-truncate">
{topic?.name || topic?.displayName || intl.formatMessage(messages.unnamedTopicSubCategories)}
</div>
</div>
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
<OverlayTrigger
overlay={(
<Tooltip>
<div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.discussions, {
count: topic.threadCounts?.discussion || 0,
})}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center mr-3.5">
<Icon src={PostOutline} className="icon-size mr-2" />
{topic.threadCounts?.discussion || 0}
</div>
</OverlayTrigger>
<OverlayTrigger
overlay={(
<Tooltip>
<div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.questions, {
count: topic.threadCounts?.question || 0,
})}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center mr-3.5">
<Icon src={HelpOutline} className="icon-size mr-2" />
{topic.threadCounts?.question || 0}
</div>
</OverlayTrigger>
{Boolean(canSeeReportedStats) && (
<OverlayTrigger
overlay={(
<Tooltip>
<div className="d-flex flex-column align-items-start">
{Boolean(activeFlags) && (
<span>
{intl.formatMessage(messages.reported, { reported: activeFlags })}
</span>
)}
{Boolean(inactiveFlags) && (
<span>
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
</span>
)}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center">
<Icon src={Report} className="icon-size mr-2 text-danger" />
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
</div>
</OverlayTrigger>
)}
</div>
</div>
</div>
</Link>
{!showDivider && (
<>
<div className="divider border-top border-light-500" />
<div className="divider pt-1 bg-light-300" />
</>
)}
</>
);
}
export const topicShape = PropTypes.shape({
id: PropTypes.string,
usage_key: PropTypes.string,
name: PropTypes.string,
thread_counts: PropTypes.shape({
discussions: PropTypes.number,
questions: PropTypes.number,
}),
enabled_in_context: PropTypes.bool,
flags: PropTypes.number,
});
Topic.propTypes = {
intl: intlShape.isRequired,
topic: topicShape,
showDivider: PropTypes.bool,
index: PropTypes.number,
};
Topic.defaultProps = {
showDivider: true,
index: -1,
topic: {
usage_key: '',
},
};
export default injectIntl(Topic);

View File

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

View File

@@ -16,9 +16,9 @@ function LearnerCard({
learner,
courseId,
}) {
const { inContext, learnerUsername } = useContext(DiscussionContext);
const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext);
const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, {
0: inContext ? 'in-context' : undefined,
0: enableInContextSidebar ? 'in-context' : undefined,
learnerUsername: learner.username,
courseId,
});

View File

@@ -1,90 +0,0 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import { Routes } from '../../../data/constants';
import { selectBlocks, selectChapters } from '../../../data/selectors';
import { DiscussionContext } from '../../common/context';
import { selectTopic } from '../../topics/data/selectors';
import { discussionsPath } from '../../utils';
import BreadcrumbDropdown from './BreadcrumbDropdown';
function BreadcrumbMenu() {
const {
courseId,
topicId,
category,
} = useContext(DiscussionContext);
const blocks = useSelector(selectBlocks);
const chapters = useSelector(selectChapters);
const blockKey = useSelector(selectTopic(topicId))?.usageKey || category;
let currentChapter = null;
let currentVertical = null;
let currentSequential = null;
if (!blocks[blockKey]) {
// Data is still loading
return null;
}
if (blocks[blockKey].type === 'chapter') {
currentChapter = blockKey;
} else if (blocks[blockKey].type === 'sequential') {
currentSequential = blockKey;
currentChapter = blocks[currentSequential].parent;
} else if (blocks[blockKey].type === 'vertical') {
currentVertical = blockKey;
currentSequential = blocks[currentVertical].parent;
currentChapter = blocks[currentSequential].parent;
}
const getItemDisplayName = itemId => blocks[itemId]?.displayName;
const getItemPath = itemId => discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: itemId,
});
return (
<div className="breadcrumb-menu d-flex flex-row bg-light-200 box-shadow-down-1 px-2.5 py-1">
<BreadcrumbDropdown
currentItem={currentChapter}
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
items={chapters}
itemPathFunc={getItemPath}
itemActiveFunc={item => item === currentChapter}
itemLabelFunc={getItemDisplayName}
/>
{currentChapter
&& (
<>
<div className="d-flex py-2">/</div>
<BreadcrumbDropdown
currentItem={currentSequential}
showAllPath={getItemPath(currentChapter)}
items={blocks[currentChapter].children}
itemPathFunc={getItemPath}
itemActiveFunc={seqId => seqId === currentChapter}
itemLabelFunc={getItemDisplayName}
/>
</>
)}
{currentSequential
&& (
<>
<div className="d-flex py-2">/</div>
<BreadcrumbDropdown
currentItem={currentVertical}
showAllPath={getItemPath(currentSequential)}
items={blocks[currentSequential].children}
itemPathFunc={getItemPath}
itemActiveFunc={vertId => vertId === currentChapter}
itemLabelFunc={getItemDisplayName}
/>
</>
)}
</div>
);
}
BreadcrumbMenu.propTypes = {};
export default BreadcrumbMenu;

View File

@@ -1,151 +0,0 @@
import React from 'react';
import {
act, fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getBlocksAPIResponse } from '../../../data/__factories__';
import { getBlocksAPIURL } from '../../../data/api';
import { getApiBaseUrl, Routes } from '../../../data/constants';
import { fetchCourseBlocks } from '../../../data/thunks';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { DiscussionContext } from '../../common/context';
import { fetchCourseTopics } from '../../topics/data/thunks';
import { BreadcrumbMenu } from '../index';
import '../../topics/data/__factories__';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
let store;
let axiosMock;
function renderComponent(path, topicId = null, category = null) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
courseId,
topicId,
category,
}}
>
<MemoryRouter initialEntries={[path]}>
<Route
path={[
Routes.POSTS.PATH,
Routes.TOPICS.CATEGORY,
]}
component={BreadcrumbMenu}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe('BreadcrumbMenu', () => {
let blocksAPIResponse;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore({
config: {
provider: 'openedx',
},
blocks: {
topics: {},
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
blocksAPIResponse = getBlocksAPIResponse();
axiosMock.onGet(getBlocksAPIURL())
.reply(200, blocksAPIResponse);
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
const data = [
...Factory.buildList('topic.v2', 3, { usage_key: null }, { topicPrefix: 'ncw' }),
Factory.build('topic.v2', { id: 'vertical_0270f6de40fc' }),
Factory.build('topic.v2', { id: '867dddb6f55d410caaa9c1eb9c6743ec' }),
Factory.build('topic.v2', { id: '4f6c1b4e316a419ab5b6bf30e6c708e9' }),
];
axiosMock
.onGet(topicsApiUrl)
.reply(200, data);
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
});
it('shows the category dropdown with a category selected', async () => {
const chapterKey = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b';
const sectionKey = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
renderComponent(`/${courseId}/category/${chapterKey}`, null, chapterKey);
await waitFor(() => screen.findByText(blocksAPIResponse.blocks[chapterKey].display_name));
const chapterDropdown = screen.queryByText(blocksAPIResponse.blocks[chapterKey].display_name);
// Since a category is selected a subcategory dropdown should also be visible with "show all" selected by default
const sectionDropdown = screen.queryByRole('button', { name: 'Show all' });
// A show all button should show up that lists topics in the current category
expect(sectionDropdown)
.toBeInTheDocument();
// Other categories should not be visible.
expect(screen.queryByText(blocksAPIResponse.blocks[sectionKey].display_name))
.not
.toBeInTheDocument();
// Click on the category dropdown.
act(() => {
fireEvent.click(chapterDropdown);
});
// Now other categories should be visible in the dropdown.
expect(screen.queryByText(blocksAPIResponse.blocks[chapterKey].display_name))
.toBeInTheDocument();
// There are 4 categories but this has a length of 5 since there is also a link to show all.
expect(screen.queryAllByRole('link', { exact: false }))
.toHaveLength(5);
// Now click on the topics dropdown
act(() => {
fireEvent.click(sectionDropdown);
});
// Topics in the category should be visible.
expect(screen.queryByRole('link', { name: 'Demo Course Overview' }))
.toBeInTheDocument();
});
it('shows the category correct dropdown labels with a topic selected', async () => {
const topicId = 'vertical_0270f6de40fc';
renderComponent(`/${courseId}/topics/${topicId}`, topicId);
// Since a topic is selected, we have both a category and topic, so "show all shouldn't be visible"
expect(screen.queryByText('Show all'))
.not
.toBeInTheDocument();
// The name of the category and topic should be visible.
expect(await screen.findByRole('button', { name: 'Introduction' }))
.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Demo Course Overview' }))
.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Introduction: Video and Sequences' }))
.toBeInTheDocument();
});
});

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
export { default as BreadcrumbMenu } from './breadcrumb-menu/BreadcrumbMenu';
export { default as LegacyBreadcrumbMenu } from './breadcrumb-menu/LegacyBreadcrumbMenu';
export { default as NavigationBar } from './navigation-bar/NavigationBar';

View File

@@ -4,21 +4,24 @@ import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { selectAreThreadsFiltered } from '../data/selectors';
import { selectTopicFilter } from '../in-context-topics/data/selectors';
import messages from '../messages';
function NoResults({ intl }) {
const postsFiltered = useSelector(selectAreThreadsFiltered);
const inContextTopicsFilter = useSelector(selectTopicFilter);
const topicsFilter = useSelector(({ topics }) => topics.filter);
const filters = useSelector((state) => state.threads.filters);
const learnersFilter = useSelector(({ learners }) => learners.usernameSearch);
const isFiltered = postsFiltered || (topicsFilter !== '') || (learnersFilter !== null);
const isFiltered = postsFiltered || (topicsFilter !== '')
|| (learnersFilter !== null) || (inContextTopicsFilter !== '');
let helpMessage = messages.removeFilters;
if (!isFiltered) {
return null;
} if (filters.search || learnersFilter) {
helpMessage = messages.removeKeywords;
} if (topicsFilter) {
} if (topicsFilter || inContextTopicsFilter) {
helpMessage = messages.removeKeywordsOnly;
}
const titleCssClasses = classNames(

View File

@@ -1,11 +1,18 @@
import React, { useContext } from 'react';
import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
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 { handleKeyDown } from '../utils';
import {
selectAllThreads,
selectTopicThreads,
@@ -29,10 +36,10 @@ TopicPostsList.propTypes = {
};
function CategoryPostsList({ category }) {
const { inContext } = useContext(DiscussionContext);
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)(inContext ? groupedCategory : category);
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
const posts = useSelector(selectTopicThreads(topicIds));
return <PostsList posts={posts} topics={topicIds} />;
}
@@ -45,12 +52,24 @@ function 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]);
let postsListComponent;
@@ -62,20 +81,6 @@ function PostsView() {
postsListComponent = <AllPostsList />;
}
const handleKeyDown = (event) => {
const { key } = event;
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
const option = event.target;
let selectedOption;
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
if (selectedOption) {
selectedOption.focus();
}
};
return (
<div className="discussion-posts d-flex flex-column h-100">
{searchString && (

View File

@@ -13,27 +13,31 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { Routes, ThreadType } from '../../data/constants';
import { getApiBaseUrl, Routes, ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCohortsApiUrl } from '../cohorts/data/api';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import { getCoursesApiUrl } from '../learners/data/api';
import { fetchCourseTopics } from '../topics/data/thunks';
import { getThreadsApiUrl } from './data/api';
import { PostsView } from './index';
import './data/__factories__';
import '../cohorts/data/__factories__';
import '../topics/data/__factories__';
const courseId = 'course-v1:edX+TestX+Test_Course';
const coursesApiUrl = getCoursesApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
let store;
let axiosMock;
const username = 'abc123';
async function renderComponent({
postId, topicId, category, myPosts, inContext = false,
postId, topicId, category, myPosts, enableInContextSidebar = false,
} = { myPosts: false }) {
let path = generatePath(Routes.POSTS.ALL_POSTS, { courseId });
let page;
@@ -60,7 +64,7 @@ async function renderComponent({
topicId,
category,
page,
inContext,
enableInContextSidebar,
}}
>
<Switch>
@@ -106,6 +110,12 @@ describe('PostsView', () => {
pageSize: 6,
})];
});
axiosMock
.onGet(topicsApiUrl)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
});
});
function setupStore(data = {}) {
@@ -172,7 +182,7 @@ describe('PostsView', () => {
config: { groupAtSubsection: grouping, hasModerationPrivileges: true, provider: 'openedx' },
});
await act(async () => {
await renderComponent({ category: 'test-usage-key', inContext: true, p: true });
await renderComponent({ category: 'test-usage-key', enableInContextSidebar: true, p: true });
});
const topicThreadCount = Math.ceil(threadCount / 3);
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-2/i))
@@ -196,6 +206,8 @@ describe('PostsView', () => {
beforeEach(async () => {
setupStore();
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
await act(async () => {
await renderComponent();
});

View File

@@ -6,6 +6,8 @@ const selectThreads = state => state.threads.threadsById;
const mapIdsToThreads = (ids, threads) => ids.map(id => threads?.[id]);
export const selectPostEditorVisible = state => state.threads.postEditorVisible;
export const selectTopicThreads = topicIds => createSelector(
[
state => (topicIds || []).flatMap(topicId => state.threads.threadsInTopic[topicId] || []),

View File

@@ -1,5 +1,4 @@
import React from 'react';
import PropTypes from 'prop-types';
import React, { useContext } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -12,8 +11,10 @@ import { Close } from '@edx/paragon/icons';
import Search from '../../../components/Search';
import { RequestStatus } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import { selectconfigLoadingStatus } from '../../data/selectors';
import { selectconfigLoadingStatus, selectEnableInContext } from '../../data/selectors';
import { TopicSearchBar as IncontextSearch } from '../../in-context-topics/topic-search';
import { postMessageToParent } from '../../utils';
import { showPostEditor } from '../data';
import messages from './messages';
@@ -22,20 +23,25 @@ import './actionBar.scss';
function PostActionsBar({
intl,
inContext,
}) {
const dispatch = useDispatch();
const loadingStatus = useSelector(selectconfigLoadingStatus);
const enableInContext = useSelector(selectEnableInContext);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const { enableInContextSidebar, page } = useContext(DiscussionContext);
const handleCloseInContext = () => {
postMessageToParent('learning.events.sidebar.close');
};
return (
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !inContext })}>
{!inContext && <Search />}
{inContext && (
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
{!enableInContextSidebar && (
(enableInContext && ['topics', 'category'].includes(page))
? <IncontextSearch />
: <Search />
)}
{enableInContextSidebar && (
<h4 className="d-flex flex-grow-1 font-weight-bold my-0 py-0 align-self-center">
{intl.formatMessage(messages.title)}
</h4>
@@ -43,18 +49,18 @@ function PostActionsBar({
{loadingStatus === RequestStatus.SUCCESSFUL && userCanAddThreadInBlackoutDate
&& (
<>
{!inContext && <div className="border-right border-light-400 mx-3" />}
{!enableInContextSidebar && <div className="border-right border-light-400 mx-3" />}
<Button
variant={inContext ? 'plain' : 'brand'}
className={classNames('my-0', { 'p-0': inContext })}
variant={enableInContextSidebar ? 'plain' : 'brand'}
className={classNames('my-0', { 'p-0': enableInContextSidebar })}
onClick={() => dispatch(showPostEditor())}
size={inContext ? 'md' : 'sm'}
size={enableInContextSidebar ? 'md' : 'sm'}
>
{intl.formatMessage(messages.addAPost)}
</Button>
</>
)}
{inContext && (
{enableInContextSidebar && (
<>
<div className="border-right border-light-300 mr-2 ml-3.5 my-2" />
<IconButton
@@ -71,7 +77,6 @@ function PostActionsBar({
PostActionsBar.propTypes = {
intl: intlShape.isRequired,
inContext: PropTypes.bool.isRequired,
};
export default injectIntl(PostActionsBar);

View File

@@ -28,12 +28,18 @@ import { useCurrentDiscussionTopic } from '../../data/hooks';
import {
selectAnonymousPostingConfig,
selectDivisionSettings,
selectEnableInContext,
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from '../../data/selectors';
import { EmptyPage } from '../../empty-posts';
import {
selectCoursewareTopics as inContextCourseware,
selectNonCoursewareIds as inContextCoursewareIds,
selectNonCoursewareTopics as inContextNonCourseware,
} from '../../in-context-topics/data/selectors';
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
import {
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
@@ -94,10 +100,12 @@ function PostEditor({
courseId,
postId,
} = useParams();
const { category, enableInContextSidebar } = useContext(DiscussionContext);
const topicId = useCurrentDiscussionTopic();
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(selectNonCoursewareIds);
const coursewareTopics = useSelector(selectCoursewareTopics);
const enableInContext = useSelector(selectEnableInContext);
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
const coursewareTopics = useSelector(enableInContext ? inContextCourseware : selectCoursewareTopics);
const cohorts = useSelector(selectCourseCohorts);
const post = useSelector(selectThread(postId));
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
@@ -106,7 +114,6 @@ function PostEditor({
const { allowAnonymous, allowAnonymousToPeers } = useSelector(selectAnonymousPostingConfig);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const userIsStaff = useSelector(selectUserIsStaff);
const { category, inContext } = useContext(DiscussionContext);
const canDisplayEditReason = (reasonCodesEnabled && editExisting
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -240,6 +247,10 @@ function PostEditor({
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const handleInContextSelectLabel = (section, subsection) => (
`${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics)
);
return (
<Formik
enableReinitialize
@@ -296,7 +307,7 @@ function PostEditor({
onBlur={handleBlur}
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
disabled={inContext}
disabled={enableInContextSidebar}
>
{nonCoursewareTopics.map(topic => (
<option
@@ -305,15 +316,35 @@ function PostEditor({
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
{coursewareTopics.map(categoryObj => (
<optgroup label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)} key={categoryObj.id}>
{categoryObj.topics.map(subtopic => (
<option key={subtopic.id} value={subtopic.id}>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))}
{enableInContext ? (
coursewareTopics?.map(section => (
section?.children?.map(subsection => (
<optgroup
label={handleInContextSelectLabel(section, subsection)}
key={subsection.id}
>
{subsection?.children?.map(unit => (
<option key={unit.id} value={unit.id}>
{unit.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
))
) : (
coursewareTopics.map(categoryObj => (
<optgroup
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
key={categoryObj.id}
>
{categoryObj.topics.map(subtopic => (
<option key={subtopic.id} value={subtopic.id}>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
)}
</Form.Control>
</Form.Group>
{canSelectCohort(values.topic) && (

View File

@@ -58,7 +58,7 @@ function Post({
hideReportConfirmation();
};
const { inContext } = useContext(DiscussionContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => history.push({
...location,
@@ -111,14 +111,14 @@ function Post({
</div>
{topicContext && topic && (
<div className={classNames('border px-3 rounded mb-4 border-light-400 align-self-start py-2.5',
{ 'w-100': inContext })}
{ 'w-100': enableInContextSidebar })}
>
<span className="text-gray-500">{intl.formatMessage(messages.relatedTo)}{' '}</span>
<Hyperlink
destination={topicContext.unitLink}
target="_top"
>
{inContext
{enableInContextSidebar
? (
<>
<span className="w-auto">{topicContext.chapterName}</span>

View File

@@ -29,12 +29,12 @@ function PostLink({
const {
page,
postId,
inContext,
enableInContextSidebar,
category,
learnerUsername,
} = useContext(DiscussionContext);
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
0: inContext ? 'in-context' : undefined,
0: enableInContextSidebar ? 'in-context' : undefined,
courseId: post.courseId,
topicId: post.topicId,
postId: post.id,

View File

@@ -4,17 +4,15 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import SearchInfo from '../../components/SearchInfo';
import { DiscussionProvider, RequestStatus } from '../../data/constants';
import { selectSequences } from '../../data/selectors';
import { RequestStatus } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectDiscussionProvider } from '../data/selectors';
import NoResults from '../posts/NoResults';
import { handleKeyDown } from '../utils';
import { selectCategories, selectNonCoursewareTopics, selectTopicFilter } from './data/selectors';
import { setFilter, setTopicsCount } from './data/slices';
import { fetchCourseTopics } from './data/thunks';
import ArchivedTopicGroup from './topic-group/ArchivedTopicGroup';
import LegacyTopicGroup from './topic-group/LegacyTopicGroup';
import SequenceTopicGroup from './topic-group/SequenceTopicGroup';
import Topic from './topic-group/topic/Topic';
import countFilteredTopics from './utils';
@@ -38,24 +36,6 @@ function CourseWideTopics() {
));
}
function CoursewareTopics() {
const sequences = useSelector(selectSequences);
return (
<>
{ sequences?.map(
sequence => (
<SequenceTopicGroup
sequence={sequence}
key={sequence.id}
/>
),
)}
<ArchivedTopicGroup />
</>
);
}
function LegacyCoursewareTopics() {
const { category } = useParams();
const categories = useSelector(selectCategories)
@@ -80,20 +60,6 @@ function TopicsView() {
const { courseId } = useContext(DiscussionContext);
const dispatch = useDispatch();
const handleKeyDown = (event) => {
const { key } = event;
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
const option = event.target;
let selectedOption;
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
if (selectedOption) {
selectedOption.focus();
}
};
useEffect(() => {
// Don't load till the provider information is available
if (provider) {
@@ -118,8 +84,7 @@ function TopicsView() {
)}
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
<CourseWideTopics />
{provider === DiscussionProvider.OPEN_EDX && <CoursewareTopics />}
{provider === DiscussionProvider.LEGACY && <LegacyCoursewareTopics />}
<LegacyCoursewareTopics />
</div>
{
filteredTopicsCount === 0

View File

@@ -1,5 +1,5 @@
import {
fireEvent, render, screen, within,
fireEvent, render, screen,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
@@ -10,11 +10,7 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getBlocksAPIResponse } from '../../data/__factories__';
import { getBlocksAPIURL } from '../../data/api';
import { DiscussionProvider, getApiBaseUrl } from '../../data/constants';
import { selectSequences } from '../../data/selectors';
import { fetchCourseBlocks } from '../../data/thunks';
import { getApiBaseUrl } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
@@ -27,7 +23,6 @@ import './data/__factories__';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
const topicsv2ApiUrl = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
let store;
let axiosMock;
let lastLocation;
@@ -57,131 +52,86 @@ function renderComponent() {
);
}
describe('TopicsView', () => {
describe.each(['legacy', 'openedx'])('%s provider', (provider) => {
let inContextTopics;
let globalTopics;
let categories;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore({
config: { provider },
blocks: {
topics: {},
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
lastLocation = undefined;
describe('Legacy Topics View', () => {
let inContextTopics;
let globalTopics;
let categories;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
async function setupMockResponse() {
if (provider === 'legacy') {
axiosMock
.onGet(topicsApiUrl)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
const state = store.getState();
categories = state.topics.categoryIds;
globalTopics = selectNonCoursewareTopics(state);
inContextTopics = selectCoursewareTopics(state);
} else {
const blocksAPIResponse = getBlocksAPIResponse(true);
const ids = Object.values(blocksAPIResponse.blocks).filter(block => block.type === 'vertical')
.map(block => block.block_id);
const deletedIds = [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@deleted-vertical-1',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@deleted-vertical-2',
];
const data = [
...Factory.buildList('topic.v2', 2, { usage_key: null }, { topicPrefix: 'ncw' }),
...ids.map(id => Factory.build('topic.v2', { id })),
...deletedIds.map(id => Factory.build('topic.v2', { id, enabled_in_context: false }, { topicPrefix: 'archived ' })),
];
axiosMock
.onGet(topicsv2ApiUrl)
.reply(200, data);
axiosMock.onGet(getBlocksAPIURL())
.reply(200, getBlocksAPIResponse(true));
axiosMock.onAny().networkError();
await executeThunk(fetchCourseBlocks(courseId, 'abc123'), store.dispatch, store.getState);
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
const state = store.getState();
categories = selectSequences(state);
globalTopics = selectNonCoursewareTopics(state);
inContextTopics = selectCoursewareTopics(state);
}
}
it('displays non-courseware topics', async () => {
await setupMockResponse();
renderComponent();
globalTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
store = initializeStore({
config: { provider: 'legacy' },
blocks: {
topics: {},
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
lastLocation = undefined;
});
it('displays non-courseware outside of a topic group', async () => {
await setupMockResponse();
renderComponent();
categories.forEach(category => {
// For the new provider categories are blocks so use the display name
// otherwise use the category itself which is a string
expect(screen.queryByText(category.displayName || category)).toBeInTheDocument();
async function setupMockResponse() {
axiosMock
.onGet(topicsApiUrl)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
const state = store.getState();
categories = state.topics.categoryIds;
globalTopics = selectNonCoursewareTopics(state);
inContextTopics = selectCoursewareTopics(state);
}
const topicGroups = screen.queryAllByTestId('topic-group');
// For the new provider there should be a section for archived topics
expect(topicGroups).toHaveLength(
provider === DiscussionProvider.LEGACY
? categories.length
: categories.length + 1,
);
});
it('displays non-courseware topics', async () => {
await setupMockResponse();
renderComponent();
if (provider === DiscussionProvider.OPEN_EDX) {
it('displays archived topics', async () => {
await setupMockResponse();
renderComponent();
const archivedTopicGroup = screen.queryAllByTestId('topic-group').pop();
expect(archivedTopicGroup).toHaveTextContent(/archived/i);
const archivedTopicLinks = within(archivedTopicGroup).queryAllByRole('option');
expect(archivedTopicLinks).toHaveLength(2);
});
}
it('displays courseware topics', async () => {
await setupMockResponse();
renderComponent();
inContextTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
});
it('clicking on courseware topic (category) takes to category page', async () => {
await setupMockResponse();
renderComponent();
const categoryName = categories[0].displayName || categories[0];
const categoryPath = provider === 'legacy' ? categoryName : categories[0].id;
const topic = await screen.findByText(categoryName);
fireEvent.click(topic);
expect(lastLocation.pathname.endsWith(`/category/${categoryPath}`)).toBeTruthy();
globalTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
});
it('displays non-courseware outside of a topic group', async () => {
await setupMockResponse();
renderComponent();
categories.forEach(category => {
// For the new provider categories are blocks so use the display name
// otherwise use the category itself which is a string
expect(screen.queryByText(category.displayName || category)).toBeInTheDocument();
});
const topicGroups = screen.queryAllByTestId('topic-group');
// For the new provider there should be a section for archived topics
expect(topicGroups).toHaveLength(categories.length);
});
it('displays courseware topics', async () => {
await setupMockResponse();
renderComponent();
inContextTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
});
it('clicking on courseware topic (category) takes to category page', async () => {
await setupMockResponse();
renderComponent();
const categoryName = categories[0].displayName || categories[0];
const categoryPath = categoryName;
const topic = await screen.findByText(categoryName);
fireEvent.click(topic);
expect(lastLocation.pathname.endsWith(`/category/${categoryPath}`)).toBeTruthy();
});
});

View File

@@ -13,14 +13,3 @@ export async function getCourseTopics(courseId, topicIds) {
.get(url);
return data;
}
export async function getCourseTopicsV2(courseId, topicIds) {
const url = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
const params = {};
if (topicIds) {
params.topic_id = topicIds.join(',');
}
const { data } = await getAuthenticatedHttpClient()
.get(url);
return data;
}

View File

@@ -2,10 +2,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { DiscussionProvider } from '../../../data/constants';
import { selectSequences } from '../../../data/selectors';
import { selectDiscussionProvider } from '../../data/selectors';
export const selectTopicFilter = state => state.topics.filter.trim()
.toLowerCase();
@@ -19,29 +15,22 @@ export const selectTopicsInCategory = (categoryId) => state => (
export const selectTopics = state => state.topics.topics;
export const selectCoursewareTopics = createSelector(
selectDiscussionProvider,
selectCategories,
selectTopicCategoryMap,
selectTopics,
selectSequences,
(provider, categoryIds, topicsInCategory, topics, sequences) => (
provider === DiscussionProvider.LEGACY
? categoryIds.map(category => ({
id: category,
name: category,
topics: topicsInCategory[category].map(id => topics[id]),
}))
: sequences.map(sequence => ({
id: sequence.id,
name: sequence.displayName,
topics: sequence.topics.map(topicId => ({ id: topicId, name: topics[topicId]?.name || 'unnamed' })),
}))
(categoryIds, topicsInCategory, topics) => (
categoryIds.map(category => ({
id: category,
name: category,
topics: topicsInCategory[category].map(id => topics[id]),
}))
),
);
export const selectNonCoursewareIds = state => state.topics.nonCoursewareIds;
export const selectNonCoursewareTopics = state => state.topics.nonCoursewareIds.map(id => state.topics.topics[id]);
export const selectNonCoursewareTopics = state => state.topics.nonCoursewareIds?.map(id => state.topics.topics[id])
|| [];
export const selectTopic = topicId => state => state.topics.topics[topicId];

View File

@@ -11,8 +11,6 @@ const topicsSlice = createSlice({
categoryIds: [],
// List of all non-courseware topics
nonCoursewareIds: [],
// Topics that have been archived
archivedIds: [],
// Mapping of all topics in each category
topicsInCategory: {},
// Map of topics ids to topic data
@@ -32,7 +30,6 @@ const topicsSlice = createSlice({
state.topics = payload.topics;
state.nonCoursewareIds = payload.nonCoursewareIds;
state.categoryIds = payload.categoryIds;
state.archivedIds = payload.archivedIds;
state.topicsInCategory = payload.topicsInCategory;
},
fetchCourseTopicsFailed: (state) => {

View File

@@ -2,8 +2,7 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { DiscussionProvider } from '../../../data/constants';
import { getCourseTopics, getCourseTopicsV2 } from './api';
import { getCourseTopics } from './api';
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
function normaliseTopics(data) {
@@ -26,34 +25,12 @@ function normaliseTopics(data) {
};
}
function normaliseTopicsV2(data) {
const nonCoursewareIds = [];
const topics = {};
const archivedIds = [];
data.forEach(topic => {
if (!topic.enabledInContext) {
archivedIds.push(topic.id);
} else if (topic.usageKey === null) {
nonCoursewareIds.push(topic.id);
}
topics[topic.id] = topic;
});
return {
topics, nonCoursewareIds, archivedIds,
};
}
export function fetchCourseTopics(courseId) {
return async (dispatch, getState) => {
return async (dispatch) => {
try {
const { config } = getState();
dispatch(fetchCourseTopicsRequest({ courseId }));
let data = {};
if (config.provider === DiscussionProvider.LEGACY) {
data = normaliseTopics(camelCaseObject(await getCourseTopics(courseId)));
} else if (config.provider === DiscussionProvider.OPEN_EDX) {
data = normaliseTopicsV2(camelCaseObject(await getCourseTopicsV2(courseId)));
}
const data = normaliseTopics(camelCaseObject(await getCourseTopics(courseId)));
dispatch(fetchCourseTopicsSuccess(data));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
@@ -61,16 +38,3 @@ export function fetchCourseTopics(courseId) {
}
};
}
export function fetchCourseTopicsV2(courseId) {
return async (dispatch) => {
try {
dispatch(fetchCourseTopicsRequest({ courseId }));
const data = await getCourseTopicsV2(courseId);
dispatch(fetchCourseTopicsSuccess(normaliseTopicsV2(camelCaseObject(data))));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
logError(error);
}
};
}

View File

@@ -10,7 +10,7 @@ export default function countFilteredTopics(topicsSelector, provider) {
? item.name.toLowerCase().includes(query)
: true
));
count += nonCoursewareTopicsList.length;
count += nonCoursewareTopicsList?.length;
// Counting legacy topics
if (provider === DiscussionProvider.LEGACY) {
const categories = topicsSelector?.categoryIds;

View File

@@ -281,3 +281,17 @@ export function inBlackoutDateRange(blackoutDateRanges) {
(blackoutDateRange) => dateInDateRange(now, new Date(blackoutDateRange.start), new Date(blackoutDateRange.end)),
);
}
export function handleKeyDown(event) {
const { key } = event;
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
const option = event.target;
let selectedOption;
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
if (selectedOption) {
selectedOption.focus();
}
}

View File

@@ -5,6 +5,7 @@ import { blocksReducer } from './data/slices';
import { cohortsReducer } from './discussions/cohorts/data';
import { commentsReducer } from './discussions/comments/data';
import { configReducer } from './discussions/data/slices';
import { inContextTopicsReducer } from './discussions/in-context-topics/data';
import { learnersReducer } from './discussions/learners/data';
import { threadsReducer } from './discussions/posts/data';
import { topicsReducer } from './discussions/topics/data';
@@ -17,6 +18,7 @@ export function initializeStore(preloadedState = undefined) {
comments: commentsReducer,
cohorts: cohortsReducer,
config: configReducer,
inContextTopics: inContextTopicsReducer,
blocks: blocksReducer,
learners: learnersReducer,
courseTabs: courseTabsReducer,