From dbf6679c9d847fa9399fffaa609569777b845e90 Mon Sep 17 00:00:00 2001 From: Kshitij Sobti Date: Tue, 29 Mar 2022 12:44:06 +0530 Subject: [PATCH] feat: Add support for archived topics Topics that belong to deleted sections/subsections/units in a course are considered archived. This commit adds a new UI section for such topics and lists them there. --- src/components/ScrollThreshold.jsx | 0 src/components/TinyMCEEditor.jsx | 2 + src/data/selectors.js | 6 ++ src/discussions/topics/TopicsView.jsx | 23 ++++-- src/discussions/topics/TopicsView.test.jsx | 62 ++++++++++----- src/discussions/topics/data/slices.js | 3 + src/discussions/topics/data/thunks.js | 7 +- .../topics/{topic-search-bar => }/messages.js | 5 ++ .../topics/topic-group/ArchivedTopicGroup.jsx | 26 +++++++ .../topics/topic-group/LegacyTopicGroup.jsx | 44 +---------- .../topics/topic-group/SequenceTopicGroup.jsx | 28 +++++++ .../topics/topic-group/TopicGroup.jsx | 67 ---------------- .../topics/topic-group/TopicGroupBase.jsx | 77 +++++++++++++++++++ .../topic-search-bar/TopicSearchBar.jsx | 2 +- 14 files changed, 215 insertions(+), 137 deletions(-) create mode 100644 src/components/ScrollThreshold.jsx rename src/discussions/topics/{topic-search-bar => }/messages.js (85%) create mode 100644 src/discussions/topics/topic-group/ArchivedTopicGroup.jsx create mode 100644 src/discussions/topics/topic-group/SequenceTopicGroup.jsx delete mode 100644 src/discussions/topics/topic-group/TopicGroup.jsx create mode 100644 src/discussions/topics/topic-group/TopicGroupBase.jsx diff --git a/src/components/ScrollThreshold.jsx b/src/components/ScrollThreshold.jsx new file mode 100644 index 00000000..e69de29b diff --git a/src/components/TinyMCEEditor.jsx b/src/components/TinyMCEEditor.jsx index e4dc653b..f1937eac 100644 --- a/src/components/TinyMCEEditor.jsx +++ b/src/components/TinyMCEEditor.jsx @@ -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 diff --git a/src/data/selectors.js b/src/data/selectors.js index e0faa3ca..3207deff 100644 --- a/src/data/selectors.js +++ b/src/data/selectors.js @@ -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; diff --git a/src/discussions/topics/TopicsView.jsx b/src/discussions/topics/TopicsView.jsx index ea382437..7c68a064 100644 --- a/src/discussions/topics/TopicsView.jsx +++ b/src/discussions/topics/TopicsView.jsx @@ -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 => ( - - ), + + return ( + <> + { sequences?.map( + sequence => ( + + ), + )} + + ); } diff --git a/src/discussions/topics/TopicsView.test.jsx b/src/discussions/topics/TopicsView.test.jsx index 57292c1d..09c573c2 100644 --- a/src/discussions/topics/TopicsView.test.jsx +++ b/src/discussions/topics/TopicsView.test.jsx @@ -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( - - - - - - - - { - lastLocation = location; - return null; - }} - /> - + + + + + + + + + { + lastLocation = location; + return null; + }} + /> + + , ); @@ -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(); diff --git a/src/discussions/topics/data/slices.js b/src/discussions/topics/data/slices.js index 1860577a..97707f88 100644 --- a/src/discussions/topics/data/slices.js +++ b/src/discussions/topics/data/slices.js @@ -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) => { diff --git a/src/discussions/topics/data/thunks.js b/src/discussions/topics/data/thunks.js index 3c1e967a..d52568c4 100644 --- a/src/discussions/topics/data/thunks.js +++ b/src/discussions/topics/data/thunks.js @@ -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, }; } diff --git a/src/discussions/topics/topic-search-bar/messages.js b/src/discussions/topics/messages.js similarity index 85% rename from src/discussions/topics/topic-search-bar/messages.js rename to src/discussions/topics/messages.js index 9c7185d2..c0c6d409 100644 --- a/src/discussions/topics/topic-search-bar/messages.js +++ b/src/discussions/topics/messages.js @@ -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; diff --git a/src/discussions/topics/topic-group/ArchivedTopicGroup.jsx b/src/discussions/topics/topic-group/ArchivedTopicGroup.jsx new file mode 100644 index 00000000..21b64a82 --- /dev/null +++ b/src/discussions/topics/topic-group/ArchivedTopicGroup.jsx @@ -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 ( + + ); +} +ArchivedTopicGroup.propTypes = { + intl: intlShape.isRequired, +}; + +export default injectIntl(ArchivedTopicGroup); diff --git a/src/discussions/topics/topic-group/LegacyTopicGroup.jsx b/src/discussions/topics/topic-group/LegacyTopicGroup.jsx index 0e4daf7a..ca2a5067 100644 --- a/src/discussions/topics/topic-group/LegacyTopicGroup.jsx +++ b/src/discussions/topics/topic-group/LegacyTopicGroup.jsx @@ -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 => ()); - const hasFilteredSubtopics = (topicElements.length > 0); - if (!matchesFilter && !hasFilteredSubtopics) { - return null; - } return ( -
- - {category} - - {topicElements} -
+ ); } diff --git a/src/discussions/topics/topic-group/SequenceTopicGroup.jsx b/src/discussions/topics/topic-group/SequenceTopicGroup.jsx new file mode 100644 index 00000000..7c9f74a0 --- /dev/null +++ b/src/discussions/topics/topic-group/SequenceTopicGroup.jsx @@ -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 ( + + ); +} + +SequenceTopicGroup.propTypes = { + sequence: PropTypes.shape({ + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + topics: PropTypes.arrayOf(PropTypes.string).isRequired, + }).isRequired, +}; + +export default SequenceTopicGroup; diff --git a/src/discussions/topics/topic-group/TopicGroup.jsx b/src/discussions/topics/topic-group/TopicGroup.jsx deleted file mode 100644 index 2bf8f49c..00000000 --- a/src/discussions/topics/topic-group/TopicGroup.jsx +++ /dev/null @@ -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 => ()); - const hasFilteredSubtopics = (topicElements.length > 0); - if (!hasTopics || (!matchesFilter && !hasFilteredSubtopics)) { - return null; - } - - return ( -
- - {sequence.displayName} - - {topicElements} -
- ); -} - -TopicGroup.propTypes = { - sequence: PropTypes.shape({ - id: PropTypes.string.isRequired, - displayName: PropTypes.string.isRequired, - topics: PropTypes.arrayOf(PropTypes.string).isRequired, - }).isRequired, -}; - -export default TopicGroup; diff --git a/src/discussions/topics/topic-group/TopicGroupBase.jsx b/src/discussions/topics/topic-group/TopicGroupBase.jsx new file mode 100644 index 00000000..61eda60a --- /dev/null +++ b/src/discussions/topics/topic-group/TopicGroupBase.jsx @@ -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 => ()); + const hasFilteredSubtopics = (topicElements.length > 0); + if (!hasTopics || (!matchesFilter && !hasFilteredSubtopics)) { + return null; + } + return ( +
+ {linkToGroup + ? ( + + {groupTitle} + + ) : ( + + {groupTitle} + + )} + {topicElements} +
+ ); +} + +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; diff --git a/src/discussions/topics/topic-search-bar/TopicSearchBar.jsx b/src/discussions/topics/topic-search-bar/TopicSearchBar.jsx index da403c6d..080066ec 100644 --- a/src/discussions/topics/topic-search-bar/TopicSearchBar.jsx +++ b/src/discussions/topics/topic-search-bar/TopicSearchBar.jsx @@ -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();