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:
0
src/components/ScrollThreshold.jsx
Normal file
0
src/components/ScrollThreshold.jsx
Normal 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
26
src/discussions/topics/topic-group/ArchivedTopicGroup.jsx
Normal file
26
src/discussions/topics/topic-group/ArchivedTopicGroup.jsx
Normal 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);
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
28
src/discussions/topics/topic-group/SequenceTopicGroup.jsx
Normal file
28
src/discussions/topics/topic-group/SequenceTopicGroup.jsx
Normal 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;
|
||||
@@ -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;
|
||||
77
src/discussions/topics/topic-group/TopicGroupBase.jsx
Normal file
77
src/discussions/topics/topic-group/TopicGroupBase.jsx
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user