feat: update search in discussions mfe (#207)
* feat: update search in discussions mfe * fix: updated code design * fix: searchbar clear and code refactor * fix: replaced existing search box * feat: added search info bar * fix: resolved failing topics test * fix: resolved intl issue * fix: resolved il8n extract check * fix: labels moved to il8n message files Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com> Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
This commit is contained in:
committed by
GitHub
parent
ca1783a2b6
commit
bf953354a1
50
src/components/Search.jsx
Normal file
50
src/components/Search.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
|
||||
import camelCase from 'lodash/camelCase';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { SearchField } from '@edx/paragon';
|
||||
|
||||
import { DiscussionContext } from '../discussions/common/context';
|
||||
import { setSearchQuery } from '../discussions/posts/data';
|
||||
import postsMessages from '../discussions/posts/post-actions-bar/messages';
|
||||
import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
|
||||
|
||||
function Search({ intl }) {
|
||||
const dispatch = useDispatch();
|
||||
const { page } = useContext(DiscussionContext);
|
||||
const postSearch = useSelector(({ threads }) => threads.filters.search);
|
||||
const topicSearch = useSelector(({ topics }) => topics.filter);
|
||||
const isPostSearch = ['posts', 'my-posts', 'learners'].includes(page);
|
||||
|
||||
const onClear = () => {
|
||||
dispatch(setSearchQuery(''));
|
||||
dispatch(setTopicFilter(''));
|
||||
};
|
||||
|
||||
const onSubmit = (query) => {
|
||||
if (isPostSearch) {
|
||||
dispatch(setSearchQuery(query));
|
||||
} else if (page === 'topics') {
|
||||
dispatch(setTopicFilter(query));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => onClear(), [page]);
|
||||
return (
|
||||
<SearchField
|
||||
onClear={onClear}
|
||||
onSubmit={onSubmit}
|
||||
value={isPostSearch ? postSearch : topicSearch}
|
||||
placeholder={intl.formatMessage(postsMessages.search, { page: camelCase(page) })}
|
||||
inputProps={{ className: 'small-font' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Search.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(Search);
|
||||
40
src/components/SearchInfo.jsx
Normal file
40
src/components/SearchInfo.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button, Icon } from '@edx/paragon';
|
||||
import { Search } from '@edx/paragon/icons';
|
||||
|
||||
import messages from '../discussions/posts/post-actions-bar/messages';
|
||||
|
||||
function SearchInfo({
|
||||
intl,
|
||||
count,
|
||||
text,
|
||||
onClear,
|
||||
}) {
|
||||
return (
|
||||
<div className="d-flex flex-row">
|
||||
<Icon src={Search} className="justify-content-start ml-3.5 mr-2 mb-2 mt-2.5" />
|
||||
<Button variant="" size="inline">
|
||||
{intl.formatMessage(messages.searchInfo, { count, text })}
|
||||
</Button>
|
||||
<Button variant="link" size="inline" className="ml-auto mr-4" onClick={onClear}>
|
||||
{intl.formatMessage(messages.clearSearch)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SearchInfo.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
count: PropTypes.number.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
onClear: PropTypes.func,
|
||||
};
|
||||
|
||||
SearchInfo.defaultProps = {
|
||||
onClear: () => {},
|
||||
};
|
||||
|
||||
export default injectIntl(SearchInfo);
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
|
||||
export { default as Search } from './Search';
|
||||
export { default as TinyMCEEditor } from './TinyMCEEditor';
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { selectTopicsUnderCategory } from '../../data/selectors';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import {
|
||||
selectAllThreads,
|
||||
selectTopicThreads,
|
||||
} from './data/selectors';
|
||||
import { setSearchQuery } from './data/slices';
|
||||
import PostFilterBar from './post-filter-bar/PostFilterBar';
|
||||
import PostsList from './PostsList';
|
||||
|
||||
@@ -42,6 +44,9 @@ function PostsView() {
|
||||
category,
|
||||
page,
|
||||
} = useContext(DiscussionContext);
|
||||
const dispatch = useDispatch();
|
||||
const searchString = useSelector(({ threads }) => threads.filters.search);
|
||||
const resultsFound = useSelector(({ threads }) => threads.totalThreads);
|
||||
|
||||
let postsListComponent;
|
||||
const showOwnPosts = page === 'my-posts';
|
||||
@@ -56,6 +61,9 @@ function PostsView() {
|
||||
|
||||
return (
|
||||
<div className="discussion-posts d-flex flex-column">
|
||||
{
|
||||
searchString && <SearchInfo count={resultsFound} text={searchString} onClear={() => dispatch(setSearchQuery(''))} />
|
||||
}
|
||||
<PostFilterBar filterSelfPosts={showOwnPosts} />
|
||||
<div className="list-group list-group-flush" role="list">
|
||||
{postsListComponent}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Icon, IconButton, SearchField,
|
||||
Button, Icon, IconButton,
|
||||
} from '@edx/paragon';
|
||||
import { Close } from '@edx/paragon/icons';
|
||||
|
||||
import { Routes } from '../../../data/constants';
|
||||
import { DiscussionContext } from '../../common/context';
|
||||
import { discussionsPath, postMessageToParent } from '../../utils';
|
||||
import { setSearchQuery, showPostEditor } from '../data';
|
||||
import Search from '../../../components/Search';
|
||||
import { postMessageToParent } from '../../utils';
|
||||
import { showPostEditor } from '../data';
|
||||
import messages from './messages';
|
||||
|
||||
import './actionBar.scss';
|
||||
@@ -24,9 +21,6 @@ function PostActionsBar({
|
||||
inContext,
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const handleCloseInContext = () => {
|
||||
postMessageToParent('learning.events.sidebar.close');
|
||||
};
|
||||
@@ -34,16 +28,7 @@ function PostActionsBar({
|
||||
<div className="d-flex justify-content-end py-1 flex-grow-1">
|
||||
{!inContext && (
|
||||
<>
|
||||
<SearchField
|
||||
onClear={() => dispatch(setSearchQuery(''))}
|
||||
onSubmit={(value) => {
|
||||
dispatch(setSearchQuery(value));
|
||||
history.push(discussionsPath(Routes.POSTS.ALL_POSTS, { courseId })(location));
|
||||
}}
|
||||
placeholder={intl.formatMessage(messages.searchAllPosts)}
|
||||
inputProps={{ className: 'small-font' }}
|
||||
/>
|
||||
<div className="border-right mr-3 ml-4" />
|
||||
<Search />
|
||||
</>
|
||||
)}
|
||||
{inContext && (
|
||||
|
||||
@@ -6,10 +6,30 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Discussions',
|
||||
},
|
||||
searchAllPosts: {
|
||||
id: 'discussions.posts.actionBar.search',
|
||||
id: 'discussions.posts.actionBar.searchAllPosts',
|
||||
defaultMessage: 'Search all posts',
|
||||
description: 'Placeholder text in search box',
|
||||
},
|
||||
search: {
|
||||
id: 'discussions.posts.actionBar.search',
|
||||
defaultMessage: `{page, select,
|
||||
topics {Find a topic}
|
||||
posts {Search all posts}
|
||||
learners {Search learner}
|
||||
myPosts {Search all posts}
|
||||
}`,
|
||||
description: 'Placeholder text in search box',
|
||||
},
|
||||
searchInfo: {
|
||||
id: 'discussions.actionBar.searchInfo',
|
||||
defaultMessage: 'Showing {count} results for "{text}"',
|
||||
description: 'Message displayed when user performs a search',
|
||||
},
|
||||
clearSearch: {
|
||||
id: 'discussions.actionBar.clearSearch',
|
||||
defaultMessage: 'Clear',
|
||||
description: 'Button to clear search',
|
||||
},
|
||||
addAPost: {
|
||||
id: 'discussion.posts.actionBar.add',
|
||||
defaultMessage: 'Add a post',
|
||||
|
||||
@@ -3,17 +3,19 @@ import React, { useContext, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import SearchInfo from '../../components/SearchInfo';
|
||||
import { DiscussionProvider } from '../../data/constants';
|
||||
import { selectSequences } from '../../data/selectors';
|
||||
import { DiscussionContext } from '../common/context';
|
||||
import { selectDiscussionProvider } from '../data/selectors';
|
||||
import { selectCategories, selectNonCoursewareTopics, selectTopicFilter } from './data/selectors';
|
||||
import { setFilter, setTopicsCount } from './data/slices';
|
||||
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 TopicSearchBar from './topic-search-bar/TopicSearchBar';
|
||||
import countFilteredTopics from './utils';
|
||||
|
||||
function CourseWideTopics() {
|
||||
const { category } = useParams();
|
||||
@@ -66,8 +68,12 @@ function LegacyCoursewareTopics() {
|
||||
|
||||
function TopicsView() {
|
||||
const provider = useSelector(selectDiscussionProvider);
|
||||
const topicFilter = useSelector(selectTopicFilter);
|
||||
const topicsSelector = useSelector(({ topics }) => topics);
|
||||
const filteredTopicsCount = useSelector(({ topics }) => topics.results.count);
|
||||
const { courseId } = useContext(DiscussionContext);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
// Don't load till the provider information is available
|
||||
if (provider) {
|
||||
@@ -75,12 +81,19 @@ function TopicsView() {
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
const count = countFilteredTopics(topicsSelector, provider);
|
||||
dispatch(setTopicsCount(count));
|
||||
}, [topicFilter]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="discussion-topics d-flex flex-column card"
|
||||
data-testid="topics-view"
|
||||
>
|
||||
<TopicSearchBar />
|
||||
{
|
||||
topicFilter && <SearchInfo text={topicFilter} count={filteredTopicsCount} onClear={() => dispatch(setFilter(''))} />
|
||||
}
|
||||
<div className="list-group list-group-flush">
|
||||
<CourseWideTopics />
|
||||
{provider === DiscussionProvider.OPEN_EDX && <CoursewareTopics />}
|
||||
|
||||
@@ -19,6 +19,9 @@ const topicsSlice = createSlice({
|
||||
topics: {},
|
||||
filter: '',
|
||||
sortBy: TopicOrdering.BY_COURSE_STRUCTURE,
|
||||
results: {
|
||||
count: 0,
|
||||
},
|
||||
},
|
||||
reducers: {
|
||||
fetchCourseTopicsRequest: (state) => {
|
||||
@@ -44,6 +47,9 @@ const topicsSlice = createSlice({
|
||||
setSortBy: (state, { payload }) => {
|
||||
state.sortBy = payload;
|
||||
},
|
||||
setTopicsCount: (state, { payload }) => {
|
||||
state.results.count = payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,6 +59,7 @@ export const {
|
||||
fetchCourseTopicsFailed,
|
||||
setFilter,
|
||||
setSortBy,
|
||||
setTopicsCount,
|
||||
} = topicsSlice.actions;
|
||||
|
||||
export const topicsReducer = topicsSlice.reducer;
|
||||
|
||||
@@ -11,7 +11,6 @@ function LegacyTopicGroup({
|
||||
category,
|
||||
}) {
|
||||
const topics = useSelector(selectTopicsInCategory(category));
|
||||
|
||||
return (
|
||||
<TopicGroupBase groupId={id} groupTitle={category} topics={topics} />
|
||||
);
|
||||
|
||||
@@ -26,8 +26,8 @@ function TopicGroupBase({
|
||||
const topicElements = topics.filter(
|
||||
topic => (
|
||||
filter
|
||||
? topic.name.toLowerCase()
|
||||
.includes(filter)
|
||||
? (topic.name.toLowerCase()
|
||||
.includes(filter) || matchesFilter)
|
||||
: true
|
||||
),
|
||||
)
|
||||
|
||||
34
src/discussions/topics/utils.js
Normal file
34
src/discussions/topics/utils.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { DiscussionProvider } from '../../data/constants';
|
||||
|
||||
export default function countFilteredTopics(topicsSelector, provider) {
|
||||
let count = 0;
|
||||
const query = topicsSelector?.filter?.trim().toLowerCase();
|
||||
// Counting non-courseware topics
|
||||
const nonCoursewareTopicsList = topicsSelector.nonCoursewareIds?.map(
|
||||
id => topicsSelector.topics[id],
|
||||
).filter(item => (query
|
||||
? item.name.toLowerCase().includes(query)
|
||||
: true
|
||||
));
|
||||
count += nonCoursewareTopicsList.length;
|
||||
// Counting legacy topics
|
||||
if (provider === DiscussionProvider.LEGACY) {
|
||||
const categories = topicsSelector?.categoryIds;
|
||||
const filteredTopics = categories?.map(categoryId => {
|
||||
const topics = topicsSelector.topicsInCategory[categoryId]?.map(
|
||||
id => topicsSelector.topics[id],
|
||||
) || [];
|
||||
const matchesFilter = query ? categoryId?.toLowerCase().includes(query) : true;
|
||||
return topics.filter(
|
||||
topic => (
|
||||
query
|
||||
? (topic.name.toLowerCase()
|
||||
.includes(query) || matchesFilter)
|
||||
: true
|
||||
),
|
||||
);
|
||||
});
|
||||
count += [].concat(...filteredTopics).length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
Reference in New Issue
Block a user