diff --git a/src/assets/empty.svg b/src/assets/empty.svg
new file mode 100644
index 00000000..29851955
--- /dev/null
+++ b/src/assets/empty.svg
@@ -0,0 +1,44 @@
+
diff --git a/src/discussions/data/hooks.js b/src/discussions/data/hooks.js
new file mode 100644
index 00000000..3236526f
--- /dev/null
+++ b/src/discussions/data/hooks.js
@@ -0,0 +1,88 @@
+/* eslint-disable import/prefer-default-export */
+import { useContext, useEffect } from 'react';
+
+import { useDispatch, useSelector } from 'react-redux';
+import { useHistory, useLocation, useRouteMatch } from 'react-router';
+
+import { AppContext } from '@edx/frontend-platform/react';
+import { breakpoints, useWindowSize } from '@edx/paragon';
+
+import { Routes } from '../../data/constants';
+import { fetchCourseBlocks } from '../../data/thunks';
+import { clearRedirect } from '../posts/data';
+import { selectTopics } from '../topics/data/selectors';
+import { fetchCourseTopics } from '../topics/data/thunks';
+import { discussionsPath } from '../utils';
+import { selectAreThreadsFiltered, selectPostThreadCount } from './selectors';
+import { fetchCourseConfig } from './thunks';
+
+export function useTotalTopicThreadCount() {
+ const topics = useSelector(selectTopics);
+
+ if (!topics) {
+ return 0;
+ }
+
+ return Object.keys(topics).reduce((total, topicId) => {
+ const topic = topics[topicId];
+ return total + topic.threadCounts.discussion + topic.threadCounts.question;
+ }, 0);
+}
+
+export const useSidebarVisible = () => {
+ const isFiltered = useSelector(selectAreThreadsFiltered);
+ const totalThreads = useSelector(selectPostThreadCount);
+ const isViewingTopics = useRouteMatch(Routes.TOPICS.PATH);
+
+ if (isFiltered) {
+ return true;
+ }
+
+ if (isViewingTopics) {
+ return true;
+ }
+
+ return totalThreads > 0;
+};
+
+export function useCourseDiscussionData(courseId) {
+ const dispatch = useDispatch();
+ const { authenticatedUser } = useContext(AppContext);
+
+ useEffect(() => {
+ async function fetchBaseData() {
+ await dispatch(fetchCourseConfig(courseId));
+ await dispatch(fetchCourseTopics(courseId));
+ await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
+ }
+
+ fetchBaseData();
+ }, [courseId]);
+}
+
+export function useRedirectToThread(courseId) {
+ const dispatch = useDispatch();
+ const redirectToThread = useSelector(
+ (state) => state.threads.redirectToThread,
+ );
+ const history = useHistory();
+ const location = useLocation();
+
+ return useEffect(() => {
+ // After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
+ // stored in redirectToThread
+ if (redirectToThread) {
+ dispatch(clearRedirect());
+ const newLocation = discussionsPath(Routes.COMMENTS.PAGES['my-posts'], {
+ courseId,
+ postId: redirectToThread.threadId,
+ })(location);
+ history.push(newLocation);
+ }
+ }, [redirectToThread]);
+}
+
+export function useIsOnDesktop() {
+ const windowSize = useWindowSize();
+ return windowSize.width >= breakpoints.large.minWidth;
+}
diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js
index c92daa80..6edda371 100644
--- a/src/discussions/data/selectors.js
+++ b/src/discussions/data/selectors.js
@@ -1,4 +1,5 @@
/* eslint-disable import/prefer-default-export */
+import { AllPostsFilter, PostsStatusFilter } from '../../data/constants';
export const selectAnonymousPostingConfig = state => ({
allowAnonymous: state.config.allowAnonymous,
@@ -10,3 +11,30 @@ export const selectUserIsPrivileged = state => state.config.userIsPrivileged;
export const selectDivisionSettings = state => state.config.settings;
export const selectDiscussionProvider = state => state.config.provider;
+
+export function selectAreThreadsFiltered(state) {
+ const { filters } = state.threads;
+
+ if (filters.search) {
+ return true;
+ }
+
+ return !(
+ filters.status === PostsStatusFilter.ALL
+ && filters.allPosts === AllPostsFilter.ALL_POSTS
+ );
+}
+
+export function selectTopicThreadCount(topicId) {
+ return state => {
+ const topic = state.topics.topics[topicId];
+ if (!topic) {
+ return 0;
+ }
+ return topic.threadCounts.question + topic.threadCounts.discussion;
+ };
+}
+
+export function selectPostThreadCount(state) {
+ return state.threads.totalThreads;
+}
diff --git a/src/discussions/discussions-home/DiscussionContent.jsx b/src/discussions/discussions-home/DiscussionContent.jsx
new file mode 100644
index 00000000..85acf083
--- /dev/null
+++ b/src/discussions/discussions-home/DiscussionContent.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+
+import { useSelector } from 'react-redux';
+import { Route, Switch } from 'react-router';
+
+import { Routes } from '../../data/constants';
+import { CommentsView } from '../comments';
+import { PostEditor } from '../posts';
+
+export default function DiscussionContent() {
+ const postEditorVisible = useSelector(
+ (state) => state.threads.postEditorVisible,
+ );
+ return (
+
+
+ {postEditorVisible ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/discussions/discussions-home/DiscussionSidebar.jsx b/src/discussions/discussions-home/DiscussionSidebar.jsx
new file mode 100644
index 00000000..5b001b82
--- /dev/null
+++ b/src/discussions/discussions-home/DiscussionSidebar.jsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import classNames from 'classnames';
+import {
+ Redirect, Route, Switch, useLocation,
+} from 'react-router';
+
+import { Routes } from '../../data/constants';
+import { PostsView } from '../posts';
+import { TopicsView } from '../topics';
+
+export default function DiscussionSidebar({ displaySidebar }) {
+ const location = useLocation();
+
+ return (
+
+ );
+}
+
+DiscussionSidebar.defaultProps = {
+ displaySidebar: false,
+};
+
+DiscussionSidebar.propTypes = {
+ displaySidebar: PropTypes.bool,
+};
diff --git a/src/discussions/discussions-home/DiscussionSidebar.test.jsx b/src/discussions/discussions-home/DiscussionSidebar.test.jsx
new file mode 100644
index 00000000..55591738
--- /dev/null
+++ b/src/discussions/discussions-home/DiscussionSidebar.test.jsx
@@ -0,0 +1,53 @@
+import { render, screen } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import { Context as ResponsiveContext } from 'react-responsive';
+import { MemoryRouter } from 'react-router';
+
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+
+import { initializeStore } from '../../store';
+import DiscussionSidebar from './DiscussionSidebar';
+
+let store;
+
+function renderComponent(displaySidebar) {
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe('DiscussionSidebar', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ store = initializeStore();
+ });
+
+ test('component visible if displaySidebar == true', async () => {
+ renderComponent(true);
+ const element = await screen.findByTestId('sidebar');
+ expect(element).not.toHaveClass('d-none');
+ });
+
+ test('component invisible by default', async () => {
+ renderComponent(false);
+ const element = await screen.findByTestId('sidebar');
+ expect(element).toHaveClass('d-none');
+ });
+});
diff --git a/src/discussions/discussions-home/DiscussionsHome.jsx b/src/discussions/discussions-home/DiscussionsHome.jsx
index a9ac2226..4af96490 100644
--- a/src/discussions/discussions-home/DiscussionsHome.jsx
+++ b/src/discussions/discussions-home/DiscussionsHome.jsx
@@ -1,32 +1,27 @@
-import React, { useContext, useEffect } from 'react';
+import React from 'react';
-import classNames from 'classnames';
-import { useDispatch, useSelector } from 'react-redux';
+import { useSelector } from 'react-redux';
import {
- Redirect, Route, Switch, useHistory, useLocation, useRouteMatch,
+ Route, Switch, useLocation, useRouteMatch,
} from 'react-router';
-import { AppContext } from '@edx/frontend-platform/react';
-import { breakpoints, useWindowSize } from '@edx/paragon';
-
import { PostActionsBar } from '../../components';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
-import { fetchCourseBlocks } from '../../data/thunks';
-import { CommentsView } from '../comments';
import { DiscussionContext } from '../common/context';
+import {
+ useCourseDiscussionData,
+ useIsOnDesktop,
+ useRedirectToThread,
+ useSidebarVisible,
+} from '../data/hooks';
import { selectDiscussionProvider } from '../data/selectors';
-import { fetchCourseConfig } from '../data/thunks';
+import { EmptyPosts, EmptyTopics } from '../empty-posts';
+import messages from '../messages';
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
-import { PostEditor, PostsView } from '../posts';
-import { clearRedirect } from '../posts/data';
-import { TopicsView } from '../topics';
-import { fetchCourseTopics } from '../topics/data/thunks';
-import { discussionsPath } from '../utils';
+import DiscussionContent from './DiscussionContent';
+import DiscussionSidebar from './DiscussionSidebar';
export default function DiscussionsHome() {
- const dispatch = useDispatch();
- const history = useHistory();
- const { authenticatedUser } = useContext(AppContext);
const location = useLocation();
const postEditorVisible = useSelector(
(state) => state.threads.postEditorVisible,
@@ -45,34 +40,21 @@ export default function DiscussionsHome() {
// Display the content area if we are currently viewing/editing a post or creating one.
const displayContentArea = postId || postEditorVisible;
- // If the window is larger than a particular size, always show the sidebar for navigating between posts/topics.
- // However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed.
- const displaySidebar = useWindowSize().width >= breakpoints.large.minWidth || !displayContentArea;
- const redirectToThread = useSelector(
- (state) => state.threads.redirectToThread,
- );
- const provider = useSelector(selectDiscussionProvider);
- useEffect(() => {
- async function fetchBaseData() {
- await dispatch(fetchCourseConfig(courseId));
- await dispatch(fetchCourseTopics(courseId));
- await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
- }
- fetchBaseData();
- }, [courseId]);
- useEffect(() => {
- // After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
- // stored in redirectToThread
- if (redirectToThread) {
- dispatch(clearRedirect());
- const newLocation = discussionsPath(Routes.COMMENTS.PAGES['my-posts'], {
- courseId,
- postId: redirectToThread.threadId,
- })(location);
- history.push(newLocation);
- }
- }, [redirectToThread]);
+ const isSidebarVisible = useSidebarVisible();
+ let displaySidebar = isSidebarVisible;
+
+ const isOnDesktop = useIsOnDesktop();
+
+ 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);
return (
@@ -96,57 +78,21 @@ export default function DiscussionsHome() {
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
/>
-
-
-
- {postEditorVisible ? (
-
-
-
- ) : (
-
-
-
-
-
-
-
-
- )}
-
-
+
+ {displayContentArea &&
}
+ {!displayContentArea && (
+
+
+ }
+ />
+ }
+ />
+
+ )}
diff --git a/src/discussions/empty-posts/EmptyPage.jsx b/src/discussions/empty-posts/EmptyPage.jsx
new file mode 100644
index 00000000..193f647a
--- /dev/null
+++ b/src/discussions/empty-posts/EmptyPage.jsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import propTypes from 'prop-types';
+
+import classNames from 'classnames';
+
+import { Button } from '@edx/paragon';
+
+import { ReactComponent as EmptyIcon } from '../../assets/empty.svg';
+
+function EmptyPage({
+ title,
+ subTitle = null,
+ action = null,
+ actionText = null,
+ fullWidth = false,
+}) {
+ const containerClasses = classNames(
+ 'align-content-start align-items-center d-flex w-100 flex-column pt-5',
+ { 'bg-light-300': !fullWidth },
+ );
+
+ return (
+
+
+
{title}
+ {subTitle &&
{subTitle}
}
+ {action && actionText && (
+
+ )}
+
+ );
+}
+
+EmptyPage.propTypes = {
+ title: propTypes.string.isRequired,
+ subTitle: propTypes.string,
+ action: propTypes.func,
+ actionText: propTypes.string,
+ fullWidth: propTypes.bool,
+};
+
+EmptyPage.defaultProps = {
+ subTitle: null,
+ action: null,
+ fullWidth: false,
+ actionText: null,
+};
+
+export default EmptyPage;
diff --git a/src/discussions/empty-posts/EmptyPosts.jsx b/src/discussions/empty-posts/EmptyPosts.jsx
new file mode 100644
index 00000000..481c35e1
--- /dev/null
+++ b/src/discussions/empty-posts/EmptyPosts.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import propTypes from 'prop-types';
+
+import { useDispatch, useSelector } from 'react-redux';
+
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import { useIsOnDesktop } from '../data/hooks';
+import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors';
+import messages from '../messages';
+import { messages as postMessages, showPostEditor } from '../posts';
+import EmptyPage from './EmptyPage';
+
+function EmptyPosts({ intl, subTitleMessage }) {
+ const dispatch = useDispatch();
+
+ const isFiltered = useSelector(selectAreThreadsFiltered);
+ const totalThreads = useSelector(selectPostThreadCount);
+ const isOnDesktop = useIsOnDesktop();
+
+ function addPost() {
+ return dispatch(showPostEditor());
+ }
+
+ let title = messages.noPostSelected;
+ let subTitle = null;
+ let action = null;
+ let actionText = null;
+ let fullWidth = false;
+
+ const isEmpty = [0, null].includes(totalThreads) && !isFiltered;
+
+ if (!(isOnDesktop || isEmpty)) {
+ return null;
+ } if (isEmpty) {
+ subTitle = subTitleMessage;
+ title = messages.emptyTitle;
+ action = addPost;
+ actionText = postMessages.addAPost;
+ fullWidth = true;
+ }
+
+ return (
+
+ );
+}
+
+EmptyPosts.propTypes = {
+ subTitleMessage: propTypes.string.isRequired,
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(EmptyPosts);
diff --git a/src/discussions/empty-posts/EmptyPosts.test.jsx b/src/discussions/empty-posts/EmptyPosts.test.jsx
new file mode 100644
index 00000000..27554883
--- /dev/null
+++ b/src/discussions/empty-posts/EmptyPosts.test.jsx
@@ -0,0 +1,78 @@
+import { render, screen } from '@testing-library/react';
+import MockAdapter from 'axios-mock-adapter';
+import { IntlProvider } from 'react-intl';
+import { Context as ResponsiveContext } from 'react-responsive';
+import { MemoryRouter } 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 { initializeStore } from '../../store';
+import { executeThunk } from '../../test-utils';
+import messages from '../messages';
+import { threadsApiUrl } from '../posts/data/api';
+import { fetchThreads } from '../posts/data/thunks';
+import EmptyPosts from './EmptyPosts';
+
+import '../posts/data/__factories__';
+
+let store;
+const courseId = 'course-v1:edX+DemoX+Demo_Course';
+
+function renderComponent(location = `/${courseId}/`) {
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+function mockFetchThreads() {
+ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onGet(threadsApiUrl).reply(200, Factory.build('threadsResult'));
+
+ return executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
+}
+
+describe('EmptyPage', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ store = initializeStore();
+ });
+
+ test('"posts youve interacted with" message shown when no posts in system', async () => {
+ renderComponent(`/${courseId}/my-posts/`);
+ expect(
+ screen.queryByText(messages.emptyMyPosts.defaultMessage),
+ ).toBeInTheDocument();
+
+ expect(
+ screen.queryByRole('button', { name: 'Add a post' }),
+ ).toBeInTheDocument();
+ });
+
+ test('"no post selected" text shown when posts are in system', async () => {
+ await mockFetchThreads();
+ renderComponent(`/${courseId}/my-posts/`);
+
+ expect(
+ screen.queryByText(messages.noPostSelected.defaultMessage),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/discussions/empty-posts/EmptyTopics.jsx b/src/discussions/empty-posts/EmptyTopics.jsx
new file mode 100644
index 00000000..5a7aa0bb
--- /dev/null
+++ b/src/discussions/empty-posts/EmptyTopics.jsx
@@ -0,0 +1,71 @@
+import React 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 { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks';
+import { selectTopicThreadCount } from '../data/selectors';
+import messages from '../messages';
+import { messages as postMessages, showPostEditor } from '../posts';
+import EmptyPage from './EmptyPage';
+
+function EmptyTopics({ intl }) {
+ const match = useRouteMatch(ALL_ROUTES);
+ const dispatch = useDispatch();
+
+ const hasGlobalThreads = useTotalTopicThreadCount() > 0;
+ const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId));
+
+ 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 (topicThreadCount > 0) {
+ title = messages.noPostSelected;
+ } else {
+ action = addPost;
+ actionText = postMessages.addAPost;
+ subTitle = messages.emptyTopic;
+ fullWidth = true;
+ }
+ } else if (hasGlobalThreads) {
+ title = messages.noTopicSelected;
+ } else {
+ action = addPost;
+ actionText = postMessages.addAPost;
+ subTitle = messages.emptyAllTopics;
+ fullWidth = true;
+ }
+
+ return (
+
+ );
+}
+
+EmptyTopics.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(EmptyTopics);
diff --git a/src/discussions/empty-posts/EmptyTopics.test.jsx b/src/discussions/empty-posts/EmptyTopics.test.jsx
new file mode 100644
index 00000000..caa2a496
--- /dev/null
+++ b/src/discussions/empty-posts/EmptyTopics.test.jsx
@@ -0,0 +1,77 @@
+import { render, screen } from '@testing-library/react';
+import MockAdapter from 'axios-mock-adapter';
+import { IntlProvider } from 'react-intl';
+import { Context as ResponsiveContext } from 'react-responsive';
+import { MemoryRouter } 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 { API_BASE_URL } from '../../data/constants';
+import { initializeStore } from '../../store';
+import { executeThunk } from '../../test-utils';
+import messages from '../messages';
+import { fetchCourseTopics } from '../topics/data/thunks';
+import EmptyTopics from './EmptyTopics';
+
+import '../topics/data/__factories__';
+
+let store;
+const courseId = 'course-v1:edX+DemoX+Demo_Course';
+const topicsApiUrl = `${API_BASE_URL}/api/discussion/v1/course_topics/${courseId}`;
+
+function renderComponent(location = `/${courseId}/topics/`) {
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+async function setupMockResponse() {
+ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock
+ .onGet(topicsApiUrl)
+ .reply(200, {
+ courseware_topics: Factory.buildList('category', 2),
+ non_courseware_topics: Factory.buildList('topic.withThreads', 3, {}, { topicPrefix: 'ncw' }),
+ });
+ await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
+}
+
+describe('EmptyTopics', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ store = initializeStore({ config: { provider: 'legacy' } });
+ });
+
+ test('"no topic selected" text shown when viewing topics page', async () => {
+ renderComponent(`/${courseId}/topics/`);
+ expect(screen.queryByText(messages.emptyTitle.defaultMessage))
+ .toBeInTheDocument();
+ });
+
+ test('"no post selected" text shown when viewing a specific topic', async () => {
+ await setupMockResponse();
+ renderComponent(`/${courseId}/topics/ncwtopic-3/`);
+
+ expect(screen.queryByText(messages.noPostSelected.defaultMessage))
+ .toBeInTheDocument();
+ });
+});
diff --git a/src/discussions/empty-posts/index.js b/src/discussions/empty-posts/index.js
new file mode 100644
index 00000000..3b9263a5
--- /dev/null
+++ b/src/discussions/empty-posts/index.js
@@ -0,0 +1,3 @@
+export { default as EmptyPage } from './EmptyPage';
+export { default as EmptyPosts } from './EmptyPosts';
+export { default as EmptyTopics } from './EmptyTopics';
diff --git a/src/discussions/messages.js b/src/discussions/messages.js
index 212b1c4e..bec97c12 100644
--- a/src/discussions/messages.js
+++ b/src/discussions/messages.js
@@ -76,6 +76,63 @@ const messages = defineMessages({
defaultMessage: 'Delete',
description: 'Delete button shown on delete confirmation dialog',
},
+ emptyAllTopics: {
+ id: 'discussions.empty.allTopics',
+ defaultMessage:
+ 'All discussion activity for these topics will show up here.',
+ description: 'Message shown on page when no posts found related to topic.',
+ },
+ emptyAllPosts: {
+ id: 'discussions.empty.allPosts',
+ defaultMessage:
+ 'All discussion activity for your course will show up here.',
+ description: 'Message shown on page when no posts found for the course.',
+ },
+ emptyMyPosts: {
+ id: 'discussions.empty.myPosts',
+ defaultMessage: "Posts you've interacted with will show up here.",
+ description: 'Message shown on page when no messages found for the user.',
+ },
+ emptyTopic: {
+ id: 'discussions.empty.topic',
+ defaultMessage: 'All discussion activity for this topic will show up here.',
+ description: 'Message shown when visiting a topic with no comments.',
+ },
+ emptyTitle: {
+ id: 'discussions.empty.title',
+ defaultMessage: 'Nothing here yet',
+ description: 'Title shown on empty pages below image.',
+ },
+ noPostSelected: {
+ id: 'discussions.empty.noPostSelected',
+ defaultMessage: 'No post selected',
+ description: 'Title on posts pages when user has yet to select a post to display.',
+ },
+ noTopicSelected: {
+ id: 'discussions.empty.noTopicSelected',
+ defaultMessage: 'No topic selected',
+ description: 'Title on topic pages when user has yet to select a topic.',
+ },
+ noResultsFound: {
+ id: 'discussions.sidebar.noResultsFound',
+ defaultMessage: 'No results found',
+ description: 'Title on the discussion sidebar when there are now results after filtering',
+ },
+ removeKeywords: {
+ id: 'discussions.sidebar.removeKeywords',
+ defaultMessage: 'Try searching different keywords or removing some filters',
+ description: 'Message shown on discussion sidebar if user searched with keywords.',
+ },
+ removeFilters: {
+ id: 'discussions.sidebar.removeFilters',
+ defaultMessage: 'Try removing some filters',
+ description: 'Message shown on discussion sidebar if user filtered results.',
+ },
+ emptyIconAlt: {
+ id: 'discussions.empty.iconAlt',
+ defaultMessage: 'Empty',
+ description: 'Alt-text for image showing empty state',
+ },
});
export default messages;
diff --git a/src/discussions/posts/NoResults.jsx b/src/discussions/posts/NoResults.jsx
new file mode 100644
index 00000000..463f86af
--- /dev/null
+++ b/src/discussions/posts/NoResults.jsx
@@ -0,0 +1,31 @@
+import { useSelector } from 'react-redux';
+
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import { selectAreThreadsFiltered } from '../data/selectors';
+import messages from '../messages';
+
+function NoResults({ intl }) {
+ const isFiltered = useSelector(selectAreThreadsFiltered);
+ const filters = useSelector((state) => state.threads.filters);
+
+ let helpMessage = messages.removeFilters;
+ if (!isFiltered) {
+ return null;
+ } if (filters.search) {
+ helpMessage = messages.removeKeywords;
+ }
+
+ return (
+
+
{intl.formatMessage(messages.noResultsFound)}
+ {intl.formatMessage(helpMessage)}
+
+ );
+}
+
+NoResults.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(NoResults);
diff --git a/src/discussions/posts/NoResults.test.jsx b/src/discussions/posts/NoResults.test.jsx
new file mode 100644
index 00000000..78dc4eea
--- /dev/null
+++ b/src/discussions/posts/NoResults.test.jsx
@@ -0,0 +1,70 @@
+import { render, screen } from '@testing-library/react';
+import { IntlProvider } from 'react-intl';
+import { Context as ResponsiveContext } from 'react-responsive';
+import { MemoryRouter } from 'react-router';
+
+import { initializeMockApp } from '@edx/frontend-platform';
+import { AppProvider } from '@edx/frontend-platform/react';
+
+import { PostsStatusFilter } from '../../data/constants';
+import { initializeStore } from '../../store';
+import messages from '../messages';
+import { setSearchQuery, setStatusFilter } from './data';
+import NoResults from './NoResults';
+
+import './data/__factories__';
+
+let store;
+const courseId = 'course-v1:edX+DemoX+Demo_Course';
+
+function renderComponent(location = `/${courseId}/`) {
+ return render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe('NoResults', () => {
+ beforeEach(async () => {
+ initializeMockApp({
+ authenticatedUser: {
+ userId: 3,
+ username: 'abc123',
+ administrator: true,
+ roles: [],
+ },
+ });
+
+ store = initializeStore();
+ });
+
+ test('component skips rendering if there are no filters', async () => {
+ renderComponent();
+ expect(
+ screen.queryByText(messages.removeFilters.defaultMessage),
+ ).not.toBeInTheDocument();
+ });
+
+ test('remove filters displays when filter is added', async () => {
+ store.dispatch(setStatusFilter(PostsStatusFilter.UNANSWERED));
+ renderComponent();
+ expect(
+ screen.queryByText(messages.removeFilters.defaultMessage),
+ ).toBeInTheDocument();
+ });
+
+ test('remove keywords displays when filter is added', async () => {
+ store.dispatch(setSearchQuery('test'));
+ renderComponent();
+ expect(
+ screen.queryByText(messages.removeKeywords.defaultMessage),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/discussions/posts/PostsView.jsx b/src/discussions/posts/PostsView.jsx
index 691e3717..31fbfd91 100644
--- a/src/discussions/posts/PostsView.jsx
+++ b/src/discussions/posts/PostsView.jsx
@@ -20,11 +20,12 @@ import {
} from './data/selectors';
import { fetchThreads } from './data/thunks';
import PostFilterBar from './post-filter-bar/PostFilterBar';
+import NoResults from './NoResults';
import { PostLink } from './post';
function PostsList({ posts }) {
let lastPinnedIdx = null;
- return posts && posts.map((post, idx) => {
+ const postInstances = posts && posts.map((post, idx) => {
if (post.pinned && lastPinnedIdx !== false) {
lastPinnedIdx = idx;
} else if (lastPinnedIdx != null && lastPinnedIdx !== false) {
@@ -39,6 +40,12 @@ function PostsList({ posts }) {
}
return ();
});
+ return (
+ <>
+ {postInstances}
+ {posts && posts.length === 0 && }
+ >
+ );
}
PostsList.propTypes = {
@@ -48,6 +55,10 @@ PostsList.propTypes = {
})),
};
+PostsList.defaultProps = {
+ posts: null,
+};
+
function AllPostsList() {
const posts = useSelector(selectAllThreads);
return ;
diff --git a/src/discussions/posts/index.js b/src/discussions/posts/index.js
index 73c46675..aa8b35ac 100644
--- a/src/discussions/posts/index.js
+++ b/src/discussions/posts/index.js
@@ -1,4 +1,6 @@
/* eslint-disable import/prefer-default-export */
+export { showPostEditor } from './data';
export { default as Post } from './post/Post';
+export { default as messages } from './post-actions-bar/messages';
export { default as PostEditor } from './post-editor/PostEditor';
export { default as PostsView } from './PostsView';
diff --git a/src/discussions/topics/data/__factories__/topics.factory.js b/src/discussions/topics/data/__factories__/topics.factory.js
index 3a0e4ec0..a581a14f 100644
--- a/src/discussions/topics/data/__factories__/topics.factory.js
+++ b/src/discussions/topics/data/__factories__/topics.factory.js
@@ -33,3 +33,15 @@ Factory.define('topic.v2')
discussion: 0,
question: 0,
});
+
+Factory.define('topic.withThreads')
+ .option('topicPrefix', null, '')
+ .option('courseId', null, 'course-v1:edX+DemoX+Demo_Course')
+ .sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic-${idx}`)
+ .sequence('name', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic ${idx}`)
+ .sequence('usage_key', ['id', 'courseId'], (idx, id, courseId) => `block-v1:${courseId.replace('course-v1:', '')}+type@vertical+block@${id}`)
+ .attr('enabled_in_context', null, true)
+ .attr('thread_counts', [], {
+ discussion: 1,
+ question: 0,
+ });