Merge pull request #109 from openedx/kshitij/tnl-9673/archived-topics

feat: Add support for archived topics [BD-38] [TNL-9673]
This commit is contained in:
AsadAzam
2022-06-29 18:24:56 +05:00
committed by GitHub
14 changed files with 215 additions and 137 deletions

View File

View File

@@ -34,6 +34,7 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c
// eslint-disable-next-line import/no-unresolved
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
/* istanbul ignore next */
const setup = (editor) => {
editor.ui.registry.addButton('openedx_code', {
icon: 'sourcecode',
@@ -49,6 +50,7 @@ const setup = (editor) => {
});
};
/* istanbul ignore next */
export default function TinyMCEEditor(props) {
// note that skin and content_css is disabled to avoid the normal
// loading process and is instead loaded as a string via content_style

View File

@@ -32,4 +32,10 @@ export const selectSequences = createSelector(
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
);
export const selectArchivedTopics = createSelector(
state => state.topics.topics,
state => state.topics.archivedIds || [],
(topics, ids) => ids.map(id => topics[id]),
);
export const selectTopicIds = () => (state) => state.blocks.chapters;

View File

@@ -9,9 +9,10 @@ import { DiscussionContext } from '../common/context';
import { selectDiscussionProvider } from '../data/selectors';
import { selectCategories, selectNonCoursewareTopics, selectTopicFilter } from './data/selectors';
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 TopicGroup from './topic-group/TopicGroup';
import TopicSearchBar from './topic-search-bar/TopicSearchBar';
function CourseWideTopics() {
@@ -32,13 +33,19 @@ function CourseWideTopics() {
function CoursewareTopics() {
const sequences = useSelector(selectSequences);
return sequences?.map(
sequence => (
<TopicGroup
sequence={sequence}
key={sequence.id}
/>
),
return (
<>
{ sequences?.map(
sequence => (
<SequenceTopicGroup
sequence={sequence}
key={sequence.id}
/>
),
)}
<ArchivedTopicGroup />
</>
);
}

View File

@@ -1,4 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {
fireEvent, render, screen, within,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
@@ -10,11 +12,12 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { getBlocksAPIResponse } from '../../data/__factories__';
import { blocksAPIURL } from '../../data/api';
import { API_BASE_URL } from '../../data/constants';
import { API_BASE_URL, DiscussionProvider } from '../../data/constants';
import { selectSequences } from '../../data/selectors';
import { fetchCourseBlocks } from '../../data/thunks';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { selectCoursewareTopics, selectNonCoursewareTopics } from './data/selectors';
import { fetchCourseTopics } from './data/thunks';
import TopicsView from './TopicsView';
@@ -33,20 +36,22 @@ function renderComponent() {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/${courseId}/topics/`]}>
<Route path="/:courseId/topics/">
<TopicsView />
</Route>
<Route path="/:courseId/category/:category">
<TopicsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
</MemoryRouter>
<DiscussionContext.Provider value={{ courseId }}>
<MemoryRouter initialEntries={[`/${courseId}/topics/`]}>
<Route path="/:courseId/topics/">
<TopicsView />
</Route>
<Route path="/:courseId/category/:category">
<TopicsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
@@ -95,9 +100,14 @@ describe('TopicsView', () => {
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
@@ -124,7 +134,7 @@ describe('TopicsView', () => {
});
});
it('displays non-courseware in outside of a topic group', async () => {
it('displays non-courseware outside of a topic group', async () => {
await setupMockResponse();
renderComponent();
@@ -135,9 +145,25 @@ describe('TopicsView', () => {
});
const topicGroups = screen.queryAllByTestId('topic-group');
expect(topicGroups).toHaveLength(categories.length);
// For the new provider there should be a section for archived topics
expect(topicGroups).toHaveLength(
provider === DiscussionProvider.LEGACY
? categories.length
: categories.length + 1,
);
});
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('link');
expect(archivedTopicLinks).toHaveLength(2);
});
}
it('displays courseware topics', async () => {
await setupMockResponse();
renderComponent();

View File

@@ -11,6 +11,8 @@ 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
@@ -27,6 +29,7 @@ const topicsSlice = createSlice({
state.topics = payload.topics;
state.nonCoursewareIds = payload.nonCoursewareIds;
state.categoryIds = payload.categoryIds;
state.archivedIds = payload.archivedIds;
state.topicsInCategory = payload.topicsInCategory;
},
fetchCourseTopicsFailed: (state) => {

View File

@@ -29,14 +29,17 @@ function normaliseTopics(data) {
function normaliseTopicsV2(data) {
const nonCoursewareIds = [];
const topics = {};
const archivedIds = [];
data.forEach(topic => {
if (topic.usageKey === null) {
if (!topic.enabledInContext) {
archivedIds.push(topic.id);
} else if (topic.usageKey === null) {
nonCoursewareIds.push(topic.id);
}
topics[topic.id] = topic;
});
return {
topics, nonCoursewareIds,
topics, nonCoursewareIds, archivedIds,
};
}

View File

@@ -26,6 +26,11 @@ const messages = defineMessages({
defaultMessage: 'Find a topic',
description: 'Placeholder text in search bar',
},
archivedTopics: {
id: 'discussions.topics.archived.label',
defaultMessage: 'Archived',
description: 'Heading for displaying topics that are archived.',
},
});
export default messages;

View File

@@ -0,0 +1,26 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { selectArchivedTopics } from '../../../data/selectors';
import messages from '../messages';
import TopicGroupBase from './TopicGroupBase';
function ArchivedTopicGroup({ intl }) {
const topics = useSelector(selectArchivedTopics);
return (
<TopicGroupBase
groupId="archived"
groupTitle={intl.formatMessage(messages.archivedTopics)}
linkToGroup={false}
topics={topics}
/>
);
}
ArchivedTopicGroup.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(ArchivedTopicGroup);

View File

@@ -2,56 +2,18 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Routes } from '../../../data/constants';
import { discussionsPath } from '../../utils';
import { selectTopicFilter, selectTopicsInCategory } from '../data/selectors';
import Topic from './topic/Topic';
import { selectTopicsInCategory } from '../data/selectors';
import TopicGroupBase from './TopicGroupBase';
function LegacyTopicGroup({
id,
category,
}) {
const { courseId } = useParams();
const topics = useSelector(selectTopicsInCategory(category));
const filter = useSelector(selectTopicFilter);
const matchesFilter = filter
? category?.toLowerCase()
.includes(filter)
: true;
const topicElements = topics.filter(
topic => (
filter
? topic.name.toLowerCase()
.includes(filter)
: true
),
)
.map(topic => (<Topic topic={topic} key={topic.id} />));
const hasFilteredSubtopics = (topicElements.length > 0);
if (!matchesFilter && !hasFilteredSubtopics) {
return null;
}
return (
<div
className="discussion-topic-group d-flex flex-column"
data-topic-id={id}
data-testid="topic-group"
>
<Link
className="topic-name list-group-item p-4 text-primary-500"
to={discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category,
})}
>
{category}
</Link>
{topicElements}
</div>
<TopicGroupBase groupId={id} groupTitle={category} topics={topics} />
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { selectTopicsById } from '../data/selectors';
import TopicGroupBase from './TopicGroupBase';
function SequenceTopicGroup({
sequence,
}) {
const topicsIds = sequence.topics;
const topics = useSelector(selectTopicsById(topicsIds));
return (
<TopicGroupBase groupId={sequence.id} groupTitle={sequence.displayName} topics={topics} />
);
}
SequenceTopicGroup.propTypes = {
sequence: PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
topics: PropTypes.arrayOf(PropTypes.string).isRequired,
}).isRequired,
};
export default SequenceTopicGroup;

View File

@@ -1,67 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Routes } from '../../../data/constants';
import { discussionsPath } from '../../utils';
import { selectTopicFilter, selectTopicsById } from '../data/selectors';
import Topic from './topic/Topic';
function TopicGroup({
sequence,
}) {
const { courseId } = useParams();
const topicsIds = sequence.topics;
const topics = useSelector(selectTopicsById(topicsIds));
const filter = useSelector(selectTopicFilter);
const hasTopics = topics.length > 0;
const matchesFilter = filter
? sequence.displayName?.toLowerCase()
.includes(filter)
: true;
const topicElements = topics.filter(
topic => (
filter
? topic.name.toLowerCase()
.includes(filter)
: true
),
)
.map(topic => (<Topic topic={topic} key={topic.id} />));
const hasFilteredSubtopics = (topicElements.length > 0);
if (!hasTopics || (!matchesFilter && !hasFilteredSubtopics)) {
return null;
}
return (
<div
className="discussion-topic-group d-flex flex-column"
data-topic-id={sequence.id}
data-testid="topic-group"
>
<Link
className="topic-name list-group-item p-4 text-primary-500"
to={discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: sequence.id,
})}
>
{sequence.displayName}
</Link>
{topicElements}
</div>
);
}
TopicGroup.propTypes = {
sequence: PropTypes.shape({
id: PropTypes.string.isRequired,
displayName: PropTypes.string.isRequired,
topics: PropTypes.arrayOf(PropTypes.string).isRequired,
}).isRequired,
};
export default TopicGroup;

View File

@@ -0,0 +1,77 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Routes } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { discussionsPath } from '../../utils';
import { selectTopicFilter } from '../data/selectors';
import Topic, { topicShape } from './topic/Topic';
function TopicGroupBase({
groupId,
groupTitle,
linkToGroup,
topics,
}) {
const { courseId } = useContext(DiscussionContext);
const filter = useSelector(selectTopicFilter);
const hasTopics = topics.length > 0;
const matchesFilter = filter
? groupTitle?.toLowerCase()
.includes(filter)
: true;
const topicElements = topics.filter(
topic => (
filter
? topic.name.toLowerCase()
.includes(filter)
: true
),
)
.map(topic => (<Topic topic={topic} key={topic.id} />));
const hasFilteredSubtopics = (topicElements.length > 0);
if (!hasTopics || (!matchesFilter && !hasFilteredSubtopics)) {
return null;
}
return (
<div
className="discussion-topic-group d-flex flex-column"
data-category-id={groupId}
data-testid="topic-group"
>
{linkToGroup
? (
<Link
className="list-group-item p-4 text-primary-500"
to={discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: groupId,
})}
>
{groupTitle}
</Link>
) : (
<span className="list-group-item p-4 text-primary-500">
{groupTitle}
</span>
)}
{topicElements}
</div>
);
}
TopicGroupBase.propTypes = {
groupId: PropTypes.string.isRequired,
groupTitle: PropTypes.string.isRequired,
topics: PropTypes.arrayOf(topicShape).isRequired,
linkToGroup: PropTypes.bool,
};
TopicGroupBase.defaultProps = {
linkToGroup: true,
};
export default TopicGroupBase;

View File

@@ -6,7 +6,7 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { SearchField } from '@edx/paragon';
import { setFilter } from '../data';
import messages from './messages';
import messages from '../messages';
function TopicSearchBar({ intl }) {
const dispatch = useDispatch();