Merge pull request #36 from edx/kshitij/tnl-8519/breadcrumbs
feat: add new breadcrumb bar for navigation course topics [BD-38] [TNL-8519]
This commit is contained in:
@@ -1,3 +1,14 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export const selectTopicContext = (topicId) => (state) => state.blocks.topics[topicId];
|
||||
|
||||
export const selectBlocks = (state) => state.blocks.blocks;
|
||||
export const selectChapters = (state) => state.blocks.chapters;
|
||||
|
||||
export const selectCurrentSelection = (state) => ({
|
||||
currentChapter: state.blocks.currentChapter,
|
||||
currentSequential: state.blocks.currentSequential,
|
||||
currentVertical: state.blocks.currentVertical,
|
||||
});
|
||||
|
||||
export const selectTopicIds = () => (state) => state.blocks.chapters;
|
||||
|
||||
@@ -14,6 +14,9 @@ const blocksSlice = createSlice({
|
||||
chapters: [],
|
||||
// Mapping of block keys to block data
|
||||
blocks: {},
|
||||
currentChapter: null,
|
||||
currentSequential: null,
|
||||
currentVertical: null,
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseBlocksRequest: (state) => {
|
||||
@@ -29,6 +32,18 @@ const blocksSlice = createSlice({
|
||||
fetchCourseBlocksDenied: (state) => {
|
||||
state.status = RequestStatus.DENIED;
|
||||
},
|
||||
setCurrentChapter: (state, { payload }) => {
|
||||
state.currentChapter = payload;
|
||||
state.currentSequential = null;
|
||||
state.currentVertical = null;
|
||||
},
|
||||
setCurrentSequential: (state, { payload }) => {
|
||||
state.currentSequential = payload;
|
||||
state.currentVertical = null;
|
||||
},
|
||||
setCurrentVertical: (state, { payload }) => {
|
||||
state.currentVertical = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,6 +52,9 @@ export const {
|
||||
fetchCourseBlocksSuccess,
|
||||
fetchCourseBlocksFailed,
|
||||
fetchCourseBlocksDenied,
|
||||
setCurrentVertical,
|
||||
setCurrentChapter,
|
||||
setCurrentSequential,
|
||||
} = blocksSlice.actions;
|
||||
|
||||
export const blocksReducer = blocksSlice.reducer;
|
||||
|
||||
@@ -15,6 +15,16 @@ function normaliseCourseBlocks({
|
||||
root,
|
||||
blocks,
|
||||
}) {
|
||||
// This normalisation code is goes throught the block structure and converts it
|
||||
// to a format that's easier for the app to use.
|
||||
// It does a couple of things:
|
||||
// 1. It creates record of all topic ids, and their position in the course
|
||||
// structure, i.e. their section, sub-section, and unit.
|
||||
// 2. It keeps a record of all the topic ids under each part of the course. So
|
||||
// the app can easily query all the topic ids under any section, subsection
|
||||
// so it can display posts from all of them if needed.
|
||||
// 3. It creates a list of chapters/sections so there is a place to start
|
||||
// navigating the course structure.
|
||||
const topics = {};
|
||||
const chapters = [];
|
||||
const blockData = {};
|
||||
@@ -22,13 +32,24 @@ function normaliseCourseBlocks({
|
||||
const chapterData = camelCaseObject(blocks[chapterId]);
|
||||
chapters.push(chapterData);
|
||||
blockData[chapterId] = chapterData;
|
||||
chapterData.topics = [];
|
||||
|
||||
blocks[chapterId].children?.forEach(sequentialId => {
|
||||
blockData[sequentialId] = camelCaseObject(blocks[sequentialId]);
|
||||
blockData[sequentialId].topics = [];
|
||||
|
||||
blocks[sequentialId].children?.forEach(verticalId => {
|
||||
blockData[verticalId] = camelCaseObject(blocks[verticalId]);
|
||||
blockData[verticalId].topics = [];
|
||||
|
||||
blocks[verticalId].children?.forEach(discussionId => {
|
||||
const discussion = camelCaseObject(blocks[discussionId]);
|
||||
blockData[discussionId] = discussion;
|
||||
// Add this topic id to the list of topics for the current chapter, sequential, and vertical
|
||||
chapterData.topics.push(discussion.studentViewData.topicId);
|
||||
blockData[sequentialId].topics.push(discussion.studentViewData.topicId);
|
||||
blockData[verticalId].topics.push(discussion.studentViewData.topicId);
|
||||
// Store the topic's context in the course in a map
|
||||
topics[discussion.studentViewData.topicId] = {
|
||||
chapterName: blockData[chapterId].displayName,
|
||||
verticalName: blockData[sequentialId].displayName,
|
||||
|
||||
@@ -108,7 +108,7 @@ DiscussionCommentsView.propTypes = {
|
||||
postType: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
endorsed: PropTypes.oneOf([
|
||||
EndorsementStatus.ENDORSED, EndorsementStatus.ENDORSED, EndorsementStatus.DISCUSSION,
|
||||
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
|
||||
]).isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { fetchCourseBlocks } from '../../data/thunks';
|
||||
import { CommentsView } from '../comments';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { fetchCourseConfig } from '../data/thunks';
|
||||
import { BreadcrumbMenu, NavigationBar } from '../navigation';
|
||||
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
|
||||
import { PostEditor, PostsView } from '../posts';
|
||||
import { clearRedirect } from '../posts/data';
|
||||
import { TopicsView } from '../topics';
|
||||
@@ -69,7 +69,7 @@ export default function DiscussionsHome() {
|
||||
Routes.POSTS.PATH,
|
||||
Routes.TOPICS.CATEGORY,
|
||||
]}
|
||||
component={BreadcrumbMenu}
|
||||
component={LegacyBreadcrumbMenu}
|
||||
/>
|
||||
<div className="d-flex flex-row">
|
||||
<div className="d-flex flex-column w-25" style={{ minWidth: '30rem' }}>
|
||||
|
||||
@@ -1,66 +1,91 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useHistory, useRouteMatch } from 'react-router';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Breadcrumb } from '@edx/paragon';
|
||||
import { Dropdown, DropdownButton } from '@edx/paragon';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { selectTopic } from '../../topics/data/selectors';
|
||||
import { selectBlocks, selectChapters, selectCurrentSelection } from '../../../data/selectors';
|
||||
import { setCurrentChapter, setCurrentSequential, setCurrentVertical } from '../../../data/slices';
|
||||
import messages from './messages';
|
||||
|
||||
function BreadcrumbMenu({ intl }) {
|
||||
const dispatch = useDispatch();
|
||||
const blocks = useSelector(selectBlocks);
|
||||
const chapters = useSelector(selectChapters);
|
||||
const {
|
||||
params: {
|
||||
courseId,
|
||||
category,
|
||||
topicId,
|
||||
},
|
||||
} = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]);
|
||||
const topic = useSelector(selectTopic(topicId));
|
||||
const history = useHistory();
|
||||
currentChapter,
|
||||
currentVertical,
|
||||
currentSequential,
|
||||
} = useSelector(selectCurrentSelection);
|
||||
|
||||
const crumbs = [
|
||||
{
|
||||
url: () => generatePath(Routes.TOPICS.ALL, { courseId }),
|
||||
label: intl.formatMessage(messages.allTopics),
|
||||
},
|
||||
{
|
||||
url: () => generatePath(Routes.TOPICS.CATEGORY, {
|
||||
courseId,
|
||||
category: category || topic?.categoryId,
|
||||
}),
|
||||
label: category || topic?.categoryId,
|
||||
},
|
||||
{
|
||||
url: () => generatePath(Routes.TOPICS.TOPIC, {
|
||||
courseId,
|
||||
topicId,
|
||||
}),
|
||||
label: topic?.name,
|
||||
},
|
||||
].filter(crumb => Boolean(crumb.label))
|
||||
.map(({
|
||||
url,
|
||||
label,
|
||||
}) => ({
|
||||
url: url(),
|
||||
label,
|
||||
}));
|
||||
|
||||
const activeLabel = crumbs.pop().label;
|
||||
const showAllMsg = intl.formatMessage(messages.showAll);
|
||||
|
||||
return (
|
||||
<div className="breadcrumb-menu d-flex flex-row mt-2 mx-3">
|
||||
<Breadcrumb
|
||||
links={crumbs}
|
||||
activeLabel={activeLabel}
|
||||
clickHandler={(e) => {
|
||||
e.preventDefault();
|
||||
history.push(e.target.pathname);
|
||||
}}
|
||||
/>
|
||||
<DropdownButton
|
||||
title={blocks[currentChapter]?.displayName || showAllMsg}
|
||||
variant="outline"
|
||||
onSelect={(cId) => dispatch(setCurrentChapter(cId))}
|
||||
>
|
||||
<Dropdown.Item eventKey={null} key="null" active={currentChapter === null}>
|
||||
{showAllMsg}
|
||||
</Dropdown.Item>
|
||||
{chapters.map(chapter => (
|
||||
chapter.topics.length > 0
|
||||
&& (
|
||||
<Dropdown.Item eventKey={chapter.id} key={chapter.id} active={chapter.id === currentChapter}>
|
||||
{chapter.displayName}
|
||||
</Dropdown.Item>
|
||||
)
|
||||
))}
|
||||
</DropdownButton>
|
||||
{currentChapter
|
||||
&& (
|
||||
<>
|
||||
<div className="d-flex py-2">/</div>
|
||||
<DropdownButton
|
||||
title={blocks[currentSequential]?.displayName || showAllMsg}
|
||||
variant="outline"
|
||||
onSelect={(sId) => dispatch(setCurrentSequential(sId))}
|
||||
>
|
||||
<Dropdown.Item eventKey={null} key="null" active={currentSequential === null}>
|
||||
{showAllMsg}
|
||||
</Dropdown.Item>
|
||||
{blocks[currentChapter].children.map(seqId => (
|
||||
blocks[seqId].topics.length > 0
|
||||
&& (
|
||||
<Dropdown.Item eventKey={seqId} key={seqId} active={seqId === currentSequential}>
|
||||
{blocks[seqId].displayName}
|
||||
</Dropdown.Item>
|
||||
)
|
||||
))}
|
||||
</DropdownButton>
|
||||
</>
|
||||
)}
|
||||
{currentSequential
|
||||
&& (
|
||||
<>
|
||||
<div className="d-flex py-2">/</div>
|
||||
<DropdownButton
|
||||
title={blocks[currentVertical]?.displayName || showAllMsg}
|
||||
variant="outline"
|
||||
onSelect={(vId) => dispatch(setCurrentVertical(vId))}
|
||||
>
|
||||
<Dropdown.Item eventKey={null} key="null" active={currentVertical === null}>
|
||||
{showAllMsg}
|
||||
</Dropdown.Item>
|
||||
{blocks[currentSequential]?.children?.map(vertId => (
|
||||
blocks[vertId].topics.length > 0
|
||||
&& (
|
||||
<Dropdown.Item eventKey={vertId} key={vertId} active={vertId === currentVertical}>
|
||||
{blocks[vertId].displayName}
|
||||
</Dropdown.Item>
|
||||
)
|
||||
))}
|
||||
</DropdownButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { generatePath, useHistory, useRouteMatch } from 'react-router';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Dropdown, DropdownButton } from '@edx/paragon';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import {
|
||||
selectCategories,
|
||||
selectNonCoursewareTopics,
|
||||
selectTopic,
|
||||
selectTopicsInCategory,
|
||||
} from '../../topics/data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
function LegacyBreadcrumbMenu({ intl }) {
|
||||
const history = useHistory();
|
||||
const {
|
||||
params: {
|
||||
courseId,
|
||||
category,
|
||||
topicId: currentTopicId,
|
||||
},
|
||||
} = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]);
|
||||
|
||||
const currentTopic = useSelector(selectTopic(currentTopicId));
|
||||
const currentCategory = category || currentTopic?.categoryId;
|
||||
const topicsInCategory = useSelector(selectTopicsInCategory(currentCategory));
|
||||
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
|
||||
const categories = useSelector(selectCategories);
|
||||
const showAllMsg = intl.formatMessage(messages.showAll);
|
||||
const isNonCoursewareTopic = currentTopic && !currentCategory;
|
||||
|
||||
const navigateToCategory = (categoryId) => {
|
||||
if (!categoryId) {
|
||||
history.push(generatePath(Routes.TOPICS.ALL, {
|
||||
courseId,
|
||||
category: categoryId,
|
||||
}));
|
||||
} else {
|
||||
history.push(generatePath(Routes.TOPICS.CATEGORY, {
|
||||
courseId,
|
||||
category: categoryId,
|
||||
}));
|
||||
}
|
||||
};
|
||||
const navigateToTopic = (topicId) => {
|
||||
if (!topicId) {
|
||||
navigateToCategory(currentCategory);
|
||||
} else {
|
||||
history.push(generatePath(Routes.TOPICS.TOPIC, {
|
||||
courseId,
|
||||
topicId,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="breadcrumb-menu d-flex flex-row mt-2 mx-3">
|
||||
{isNonCoursewareTopic ? (
|
||||
<DropdownButton
|
||||
title={currentTopic.name}
|
||||
variant="outline"
|
||||
onSelect={navigateToTopic}
|
||||
>
|
||||
<Dropdown.Item eventKey={null} key="null" active={!currentTopic}>
|
||||
{showAllMsg}
|
||||
</Dropdown.Item>
|
||||
{nonCoursewareTopics.map(topic => (
|
||||
<Dropdown.Item eventKey={topic.id} key={topic.id} active={topic.id === currentTopicId}>
|
||||
{topic.name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</DropdownButton>
|
||||
) : (
|
||||
<DropdownButton
|
||||
title={currentCategory || showAllMsg}
|
||||
variant="outline"
|
||||
onSelect={navigateToCategory}
|
||||
>
|
||||
<Dropdown.Item eventKey={null} key="null" active={!currentCategory}>
|
||||
{showAllMsg}
|
||||
</Dropdown.Item>
|
||||
{categories.map(categoryId => (
|
||||
<Dropdown.Item eventKey={categoryId} key={categoryId} active={categoryId === currentCategory}>
|
||||
{categoryId}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</DropdownButton>
|
||||
)}
|
||||
{currentCategory && (
|
||||
<>
|
||||
<div className="d-flex py-2">/</div>
|
||||
<DropdownButton
|
||||
title={currentTopic?.name || showAllMsg}
|
||||
variant="outline"
|
||||
onSelect={navigateToTopic}
|
||||
>
|
||||
<Dropdown.Item eventKey={null} key="null" active={!currentTopic}>
|
||||
{showAllMsg}
|
||||
</Dropdown.Item>
|
||||
{topicsInCategory?.map(topic => (
|
||||
<Dropdown.Item eventKey={topic.id} key={topic.id} active={topic.id === currentTopicId}>
|
||||
{topic.name}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</DropdownButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LegacyBreadcrumbMenu.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(LegacyBreadcrumbMenu);
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
act, fireEvent, render, screen,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { MemoryRouter, Route } 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, Routes } from '../../../data/constants';
|
||||
import { initializeStore } from '../../../store';
|
||||
import { executeThunk } from '../../../test-utils';
|
||||
import { fetchCourseTopics } from '../../topics/data/thunks';
|
||||
import { LegacyBreadcrumbMenu } from '../index';
|
||||
|
||||
import '../../topics/data/__factories__';
|
||||
|
||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||
const topicsApiUrl = `${API_BASE_URL}/api/discussion/v1/course_topics/${courseId}`;
|
||||
let store;
|
||||
let axiosMock;
|
||||
|
||||
function renderComponent(path) {
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<AppProvider store={store}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Route
|
||||
path={[
|
||||
Routes.POSTS.PATH,
|
||||
Routes.TOPICS.CATEGORY,
|
||||
]}
|
||||
component={LegacyBreadcrumbMenu}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
</IntlProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('LegacyBreadcrumbMenu', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
blocks: {
|
||||
topics: {},
|
||||
},
|
||||
});
|
||||
Factory.resetAll();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
const data = {
|
||||
courseware_topics: Factory.buildList('category', 3),
|
||||
non_courseware_topics: Factory.buildList('topic', 3),
|
||||
};
|
||||
axiosMock
|
||||
.onGet(topicsApiUrl)
|
||||
.reply(200, data);
|
||||
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
|
||||
});
|
||||
|
||||
it('shows the category dropdown with a category selected', async () => {
|
||||
renderComponent(`/discussions/${courseId}/category/category-1`);
|
||||
|
||||
// The current category should be visible on the page
|
||||
const categoryDropdown = await screen.findByText('category-1');
|
||||
// Since a category is selected a subcategory dropdown should also be visible with "show all" selected by default
|
||||
const topicsDropdown = screen.queryByText('Show all');
|
||||
// A show all button should show up that lists topics in the current category
|
||||
expect(topicsDropdown).toBeInTheDocument();
|
||||
// Other categories should not be visible.
|
||||
expect(screen.queryByText('category-2')).not.toBeInTheDocument();
|
||||
|
||||
// Click on the category dropdown.
|
||||
act(() => {
|
||||
fireEvent.click(categoryDropdown);
|
||||
});
|
||||
// Now other categories should be visible in the dropdown.
|
||||
expect(screen.queryByText('category-2')).toBeInTheDocument();
|
||||
// There are three categories but this has a length of 4 since the selected category name appears twice.
|
||||
expect(screen.queryAllByText('category-', { exact: false })).toHaveLength(4);
|
||||
|
||||
// Now click on the topics dropdown
|
||||
act(() => {
|
||||
fireEvent.click(topicsDropdown);
|
||||
});
|
||||
// Topics in the category should be visible.
|
||||
expect(screen.queryAllByText('category-1-topic', { exact: false })).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('shows the category correct dropdown labels with a topic selected', async () => {
|
||||
renderComponent(`/discussions/${courseId}/topics/category-2-topic-1`);
|
||||
|
||||
// Since a topic is selected, we have both a category and topic, so "show all shouldn't be visible"
|
||||
expect(screen.queryByText('Show all')).not.toBeInTheDocument();
|
||||
// The name of the category and topic should be visible.
|
||||
expect(screen.queryByText('category-2')).toBeInTheDocument();
|
||||
expect(screen.queryByText('category-2-topic 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Topics',
|
||||
description: 'Topics from Breadcrumb Menu item',
|
||||
},
|
||||
showAll: {
|
||||
id: 'discussions.navigation.breadcrumbMenu.showAll',
|
||||
defaultMessage: 'Show all',
|
||||
description: 'Option to show all items in a section of the breadcrumb',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as BreadcrumbMenu } from './breadcrumb-menu/BreadcrumbMenu';
|
||||
export { default as LegacyBreadcrumbMenu } from './breadcrumb-menu/LegacyBreadcrumbMenu';
|
||||
export { default as NavigationBar } from './navigation-bar/NavigationBar';
|
||||
|
||||
@@ -74,10 +74,10 @@ function PostsView({ showOwnPosts }) {
|
||||
lastPinnedIdx = false;
|
||||
// Add a spacing after the group of pinned posts
|
||||
return (
|
||||
<>
|
||||
<React.Fragment key={post.id}>
|
||||
<div className="p-1 bg-light-300" />
|
||||
<PostLink post={post} key={post.id} />
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (<PostLink post={post} key={post.id} />);
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { Factory } from 'rosie';
|
||||
|
||||
Factory.define('topic')
|
||||
.sequence('id', (idx) => `topic-${idx}`)
|
||||
.sequence('name', (idx) => `topic ${idx}`)
|
||||
.option('topicPrefix', null, '')
|
||||
.sequence('id', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic-${idx}`)
|
||||
.sequence('name', ['topicPrefix'], (idx, topicPrefix) => `${topicPrefix}topic ${idx}`)
|
||||
.attr('thread_counts', [], {
|
||||
discussion: 0,
|
||||
question: 0,
|
||||
})
|
||||
.attr('children', []);
|
||||
|
||||
Factory.define('category')
|
||||
.attr('id', [], null)
|
||||
.sequence('name', (idx) => `category-${idx}`)
|
||||
.attr('thread_counts', [], {
|
||||
discussion: 0,
|
||||
question: 0,
|
||||
})
|
||||
.attr('children', ['name'], (name) => {
|
||||
Factory.reset('topic');
|
||||
return Factory.buildList('topic', 4, null, { topicPrefix: `${name}-` });
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ export const selectTopicFilter = state => state.topics.filter.trim().toLowerCase
|
||||
export const selectCategories = state => state.topics.categoryIds;
|
||||
|
||||
export const selectTopicsInCategory = (categoryId) => state => (
|
||||
state.topics.topicsInCategory[categoryId].map(id => state.topics.topics[id])
|
||||
state.topics.topicsInCategory[categoryId]?.map(id => state.topics.topics[id]) || []
|
||||
);
|
||||
|
||||
export const selectTopics = state => state.topics.topics;
|
||||
|
||||
Reference in New Issue
Block a user