feat: show empty state on posts pages [BD-38] [TNL-9484] [SE-5347] (#65)

When a user browses the discussion pages, show a page showing
that there is no data, rather than a blank page.

This PR implements the designs for this page while refactoring the
DiscussionHome component so that it doesn't get much larger.

- Created a hooks file and move some of the larger hooks in there.
- Added selectors for the state that would be needed for the
  components.
- Split the DiscussionHome into DiscussionContent and
  DiscussionSidebar to make it clearer where certain things get
  rendered.
- Adds a message to the sidebar to display No Results when
  appropriate.
- Added the NoResults component to show when filtering posts and there
  is nothing to show.
This commit is contained in:
Keith Grootboom
2022-03-09 14:15:32 +02:00
committed by GitHub
parent 4d8dff93a6
commit c65230d5da
19 changed files with 865 additions and 98 deletions

View File

@@ -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 (
<div className={containerClasses}>
<EmptyIcon />
<h3 className="pt-3">{title}</h3>
{subTitle && <p className="pb-2">{subTitle}</p>}
{action && actionText && (
<Button onClick={action} variant="outline-dark">
{actionText}
</Button>
)}
</div>
);
}
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;

View File

@@ -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 (
<EmptyPage
title={intl.formatMessage(title)}
subTitle={subTitle ? intl.formatMessage(subTitle) : null}
action={action}
actionText={actionText ? intl.formatMessage(actionText) : null}
fullWidth={fullWidth}
/>
);
}
EmptyPosts.propTypes = {
subTitleMessage: propTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(EmptyPosts);

View File

@@ -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(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<MemoryRouter initialEntries={[location]}>
<EmptyPosts subTitleMessage={messages.emptyMyPosts} />
</MemoryRouter>
</AppProvider>
</ResponsiveContext.Provider>
</IntlProvider>,
);
}
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();
});
});

View File

@@ -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 (
<EmptyPage
title={intl.formatMessage(title)}
subTitle={subTitle ? intl.formatMessage(subTitle) : null}
action={action}
actionText={actionText ? intl.formatMessage(actionText) : null}
fullWidth={fullWidth}
/>
);
}
EmptyTopics.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EmptyTopics);

View File

@@ -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(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<MemoryRouter initialEntries={[location]}>
<EmptyTopics />
</MemoryRouter>
</AppProvider>
</ResponsiveContext.Provider>
</IntlProvider>,
);
}
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();
});
});

View File

@@ -0,0 +1,3 @@
export { default as EmptyPage } from './EmptyPage';
export { default as EmptyPosts } from './EmptyPosts';
export { default as EmptyTopics } from './EmptyTopics';