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 (
{!inContext && ( - + )}
@@ -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, + });