Skip to content

Commit

Permalink
feat: Endpoint usage implementation for Courseware Search
Browse files Browse the repository at this point in the history
Co-authored-by: Simon Chen <[email protected]>
  • Loading branch information
rijuma and schenedx committed Nov 1, 2023
1 parent a64f0e0 commit cec3b00
Show file tree
Hide file tree
Showing 11 changed files with 344 additions and 69 deletions.
63 changes: 38 additions & 25 deletions src/course-home/courseware-search/CoursewareResultsFilter.jsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,54 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Tabs, Tab } from '@edx/paragon';

import CoursewareSearchResultPropType from './CoursewareSearchResult.PropTypeDefinition';
import { useParams } from 'react-router';
import CoursewareSearchResults from './CoursewareSearchResults';
import messages from './messages';
import { useModel } from '../../generic/model-store';

export const filteredResultsBySelection = ({ filterKey = 'all', results = [] }) => {
if (['document', 'video', 'text'].includes(filterKey)) {
return results.filter(result => result?.type?.toLowerCase() === filterKey);
}
const noFilterKey = 'none';
const noFilterLabel = 'All content';

return results || [];
};
export const filteredResultsBySelection = ({ key = noFilterKey, results = [] }) => (
key === noFilterKey ? results : results.filter(({ type }) => type === key)
);

export const CoursewareSearchResultsFilter = ({ intl }) => {
const { courseId } = useParams();
const lastSearch = useModel('contentSearchResults', courseId);

if (!lastSearch || !lastSearch.results.length) { return null; }

const tabConfiguration = [
{ eventKey: 'all', title: 'All content' },
{ eventKey: 'document', title: 'Course outline' },
{ eventKey: 'text', title: 'Text' },
{ eventKey: 'video', title: 'Video' },
];
const { total, results } = lastSearch;

export const CoursewareSearchResultsFilter = ({ intl, results }) => {
if (!results || !results.length) { return null; }
const filters = [
{
key: noFilterKey,
label: noFilterLabel,
count: total,
},
...lastSearch.filters,
];

const getFilterTitle = (key, fallback) => {
const msg = messages[`filter:${key}`];
if (!msg) { return fallback; }
return intl.formatMessage(msg);
};

return (
<Tabs id="courseware-search-results-tabs" data-testid="courseware-search-results-tabs" variant="tabs" defaultActiveKey="all">
{tabConfiguration.map((tab) => (
<Tab {...tab} key={tab.eventKey}>
<Tabs
id="courseware-search-results-tabs"
data-testid="courseware-search-results-tabs"
variant="tabs"
defaultActiveKey={noFilterKey}
>
{filters.map(({ key, label }) => (
<Tab eventKey={key} title={getFilterTitle(key, label)}>
<CoursewareSearchResults
intl={intl}
results={filteredResultsBySelection({ filterKey: tab.eventKey, results })}
results={filteredResultsBySelection({ key, results })}
/>
</Tab>
))}
Expand All @@ -40,11 +58,6 @@ export const CoursewareSearchResultsFilter = ({ intl, results }) => {

CoursewareSearchResultsFilter.propTypes = {
intl: intlShape.isRequired,
results: PropTypes.arrayOf(CoursewareSearchResultPropType),
};

CoursewareSearchResultsFilter.defaultProps = {
results: [],
};

export default injectIntl(CoursewareSearchResultsFilter);
81 changes: 72 additions & 9 deletions src/course-home/courseware-search/CoursewareSearch.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React, { useState } from 'react';
import { useParams } from 'react-router';
import { useDispatch } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import { Button, Icon, Spinner } from '@edx/paragon';
import {
Close,
} from '@edx/paragon/icons';
Expand All @@ -11,24 +13,65 @@ import messages from './messages';

import CoursewareSearchForm from './CoursewareSearchForm';
import CoursewareSearchResultsFilterContainer from './CoursewareResultsFilter';
import mockedData from './test-data/mockedResults';
import { updateModel, useModel } from '../../generic/model-store';
import { searchCourseContent } from '../data/thunks';

const CoursewareSearch = ({ intl, ...sectionProps }) => {
const [results, setResults] = useState();
const { courseId } = useParams();
const dispatch = useDispatch();
const { org } = useModel('courseHomeMeta', courseId);
const {
loading,
searchKeyword: lastSearchKeyword,
total,
} = useModel('contentSearchResults', courseId);
const [searchKeyword, setSearchKeyword] = useState(lastSearchKeyword);

useLockScroll();

const info = useElementBoundingBox('courseTabsNavigation');
const top = info ? `${Math.floor(info.top)}px` : 0;

const handleSubmit = (search) => {
if (!search) {
setResults(undefined);
const clearSearch = () => {
dispatch(updateModel({
modelType: 'contentSearchResults',
model: {
id: courseId,
searchKeyword: '',
results: [],
loading: false,
},
}));
};

const handleSubmit = () => {
if (!searchKeyword) {
clearSearch();
return;
}

setResults(search.toLowerCase() !== 'lorem ipsum' ? mockedData : []);
const eventProperties = {
org_key: org,
courserun_key: courseId,
};

sendTrackingLogEvent('edx.course.home.courseware_search.submit', {
...eventProperties,
event_type: 'searchKeyword',
keyword: searchKeyword,
});

dispatch(searchCourseContent(courseId, searchKeyword));
};

const handleOnChange = (value) => {
if (value === searchKeyword) { return; }

setSearchKeyword(value);

if (!value) {
clearSearch();
}
};

return (
Expand All @@ -47,16 +90,36 @@ const CoursewareSearch = ({ intl, ...sectionProps }) => {
<div className="courseware-search__content">
<h2>{intl.formatMessage(messages.searchModuleTitle)}</h2>
<CoursewareSearchForm
value={searchKeyword}
onSubmit={handleSubmit}
onChange={handleOnChange}
placeholder={intl.formatMessage(messages.searchBarPlaceholderText)}
/>
<CoursewareSearchResultsFilterContainer results={results} />
{loading ? (
<div className="courseware-search__spinner">
<Spinner animation="border" variant="light" screenReaderText={intl.formatMessage(messages.loading)} />
</div>
) : null}
{!loading && lastSearchKeyword ? (
<>
<div className="courseware-search__results-summary">{total > 0
? (
intl.formatMessage(
total === 1
? messages.searchResultsSingular
: messages.searchResultsPlural,
{ total, keyword: lastSearchKeyword },
)
) : intl.formatMessage(messages.searchResultsNone)}
</div>
<CoursewareSearchResultsFilterContainer />
</>
) : null}
</div>
</div>
</section>
);
};

CoursewareSearch.propTypes = {
intl: intlShape.isRequired,
};
Expand Down
15 changes: 15 additions & 0 deletions src/course-home/courseware-search/CoursewareSearchEmpty.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';

const CoursewareSearchEmpty = ({ intl }) => (
<div className="courseware-search-results">
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
</div>
);

CoursewareSearchEmpty.propTypes = {
intl: intlShape.isRequired,
};

export default injectIntl(CoursewareSearchEmpty);
6 changes: 5 additions & 1 deletion src/course-home/courseware-search/CoursewareSearchForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,36 @@ import { SearchField } from '@edx/paragon';
import PropTypes from 'prop-types';

const CoursewareSearchForm = ({
value,
onSubmit,
onChange,
placeholder,
}) => (
<SearchField.Advanced
value={value}
onSubmit={onSubmit}
onChange={onChange}
submitButtonLocation="external"
className="courseware-search-form"
>
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
<SearchField.Label />
<SearchField.Input placeholder={placeholder} />
<SearchField.Input placeholder={placeholder} autoFocus />
<SearchField.ClearButton />
</div>
<SearchField.SubmitButton submitButtonLocation="external" />
</SearchField.Advanced>
);

CoursewareSearchForm.propTypes = {
value: PropTypes.string,
onSubmit: PropTypes.func,
onChange: PropTypes.func,
placeholder: PropTypes.string,
};

CoursewareSearchForm.defaultProps = {
value: undefined,
onSubmit: undefined,
onChange: undefined,
placeholder: undefined,
Expand Down

This file was deleted.

46 changes: 25 additions & 21 deletions src/course-home/courseware-search/CoursewareSearchResults.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Folder, TextFields, VideoCamera, Article,
} from '@edx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { Icon } from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from './messages';
import CoursewareSearchEmpty from './CoursewareSearchEmpty';

const iconTypeMapping = {
document: Folder,
Expand All @@ -14,40 +14,44 @@ const iconTypeMapping = {
};
const defaultIcon = Article;

const CoursewareSearchResults = ({ intl, results }) => {
const CoursewareSearchResults = ({ results }) => {
if (!results?.length) {
return (
<div className="courseware-search-results">
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
</div>
);
return <CoursewareSearchEmpty />;
}

const baseUrl = `${getConfig().LMS_BASE_URL}`;

return (
<div className="courseware-search-results" data-testid="search-results">
{results.map(({
title, href, type, breadcrumbs, contentMatches, isExternal,
title,
type,
location,
url,
contentHits,
}) => {
const key = type.toLowerCase();
const icon = iconTypeMapping[key] || defaultIcon;

const isExternal = !url.startsWith('/');

const linkProps = isExternal ? {
href,
href: url,
target: '_blank',
rel: 'nofollow',
} : { href };
} : { href: `${baseUrl}${url}` };

return (
<a className="courseware-search-results__item" {...linkProps}>
<div className="courseware-search-results__icon"><Icon src={icon} /></div>
<div className="courseware-search-results__info">
<div className="courseware-search-results__title">
<span>{title}</span>
{contentMatches ? (<em>{contentMatches}</em>) : null }
{contentHits ? (<em>{contentHits}</em>) : null }
</div>
{breadcrumbs?.length ? (
{location?.length ? (
<ul className="courseware-search-results__breadcrumbs">
{breadcrumbs.map(bc => (<li><div>{bc}</div></li>))}
{location.map(bc => (<li><div>{bc}</div></li>))}
</ul>
) : null}
</div>
Expand All @@ -59,19 +63,19 @@ const CoursewareSearchResults = ({ intl, results }) => {
};

CoursewareSearchResults.propTypes = {
intl: intlShape.isRequired,
results: PropTypes.arrayOf(PropTypes.objectOf({
title: PropTypes.string.isRequired,
href: PropTypes.string.isRequired,
id: PropTypes.string,
title: PropTypes.string,
type: PropTypes.string,
breadcrumbs: PropTypes.arrayOf(PropTypes.string),
contentMatches: PropTypes.number,
isExternal: PropTypes.bool,
location: PropTypes.arrayOf(PropTypes.string),
url: PropTypes.string,
contentHits: PropTypes.number,
score: PropTypes.number,
})),
};

CoursewareSearchResults.defaultProps = {
results: [],
};

export default injectIntl(CoursewareSearchResults);
export default CoursewareSearchResults;
12 changes: 12 additions & 0 deletions src/course-home/courseware-search/courseware-search.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@
margin-bottom: 2rem;
}
}

&__results-summary {
font-size: .9rem;
color: $gray-400;
padding: 1rem 0 .5rem;
}

&__spinner {
display: grid;
place-items: center;
min-height: 20vh;
}
}

.courseware-search-results {
Expand Down
Loading

0 comments on commit cec3b00

Please sign in to comment.