feat: implements new v3 in-context topics structure (#371)
This commit is contained in:
@@ -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>
|
||||
|
||||
3
src/components/NavigationBar/data/selectors.js
Normal file
3
src/components/NavigationBar/data/selectors.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export const selectCourseTabs = state => state.courseTabs;
|
||||
@@ -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`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,7 +6,7 @@ export const DiscussionContext = React.createContext({
|
||||
courseId: null,
|
||||
postId: null,
|
||||
topicId: null,
|
||||
inContext: false,
|
||||
enableInContextSidebar: false,
|
||||
category: null,
|
||||
learnerUsername: null,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const configSlice = createSlice({
|
||||
reasonCodesEnabled: false,
|
||||
editReasons: [],
|
||||
postCloseReasons: [],
|
||||
enableInContext: false,
|
||||
},
|
||||
reducers: {
|
||||
fetchConfigRequest: (state) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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__';
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
82
src/discussions/in-context-topics/TopicPostsView.jsx
Normal file
82
src/discussions/in-context-topics/TopicPostsView.jsx
Normal 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);
|
||||
104
src/discussions/in-context-topics/TopicsView.jsx
Normal file
104
src/discussions/in-context-topics/TopicsView.jsx
Normal 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;
|
||||
41
src/discussions/in-context-topics/components/BackButton.jsx
Normal file
41
src/discussions/in-context-topics/components/BackButton.jsx
Normal 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);
|
||||
83
src/discussions/in-context-topics/components/EmptyTopics.jsx
Normal file
83
src/discussions/in-context-topics/components/EmptyTopics.jsx
Normal 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);
|
||||
29
src/discussions/in-context-topics/components/NoResults.jsx
Normal file
29
src/discussions/in-context-topics/components/NoResults.jsx
Normal 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);
|
||||
4
src/discussions/in-context-topics/components/index.js
Normal file
4
src/discussions/in-context-topics/components/index.js
Normal 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';
|
||||
11
src/discussions/in-context-topics/data/api.js
Normal file
11
src/discussions/in-context-topics/data/api.js
Normal 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);
|
||||
}
|
||||
1
src/discussions/in-context-topics/data/index.js
Normal file
1
src/discussions/in-context-topics/data/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './slices';
|
||||
51
src/discussions/in-context-topics/data/selectors.js
Normal file
51
src/discussions/in-context-topics/data/selectors.js
Normal 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)
|
||||
),
|
||||
);
|
||||
49
src/discussions/in-context-topics/data/slices.js
Normal file
49
src/discussions/in-context-topics/data/slices.js
Normal 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;
|
||||
56
src/discussions/in-context-topics/data/thunks.js
Normal file
56
src/discussions/in-context-topics/data/thunks.js
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
3
src/discussions/in-context-topics/index.js
Normal file
3
src/discussions/in-context-topics/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as TopicPostsView } from './TopicPostsView';
|
||||
export { default as TopicsView } from './TopicsView';
|
||||
74
src/discussions/in-context-topics/messages.js
Normal file
74
src/discussions/in-context-topics/messages.js
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
3
src/discussions/in-context-topics/topic-search/index.js
Normal file
3
src/discussions/in-context-topics/topic-search/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as TopicSearchBar } from './TopicSearchBar';
|
||||
export { default as TopicSearchResultBar } from './TopicSearchResultBar';
|
||||
90
src/discussions/in-context-topics/topic/SectionBaseGroup.jsx
Normal file
90
src/discussions/in-context-topics/topic/SectionBaseGroup.jsx
Normal 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);
|
||||
155
src/discussions/in-context-topics/topic/Topic.jsx
Normal file
155
src/discussions/in-context-topics/topic/Topic.jsx
Normal 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);
|
||||
3
src/discussions/in-context-topics/topic/index.js
Normal file
3
src/discussions/in-context-topics/topic/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as SectionBaseGroup } from './SectionBaseGroup';
|
||||
export { default as Topic } from './Topic';
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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] || []),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user