feat: improve search to target single collections (#3760)

This commit is contained in:
Hannes Küttner 2020-05-18 09:52:06 +02:00 committed by GitHub
parent 72596bbbec
commit 588622adb2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 569 additions and 82 deletions

View File

@ -0,0 +1,78 @@
import { login } from '../utils/steps';
const search = (term, collection) => {
cy.get('[class*=SearchInput]').clear({ force: true });
cy.get('[class*=SearchInput]').type(term, { force: true });
cy.get('[class*=SuggestionsContainer]').within(() => {
cy.contains(collection).click();
});
};
const assertSearchHeading = title => {
cy.get('[class*=SearchResultHeading]').should('have.text', title);
};
const assertSearchResult = (text, collection) => {
cy.get('[class*=ListCardLink]').within(() => {
if (collection) {
cy.contains('h2', collection);
}
cy.contains('h2', text);
});
};
const assertNotInSearch = text => {
cy.get('[class*=ListCardLink]').within(() => {
cy.contains('h2', text).should('not.exist');
});
};
describe('Search Suggestion', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
beforeEach(() => {
login();
});
it('can search in all collections', () => {
search('this', 'All Collections');
assertSearchHeading('Search Results for "this"');
assertSearchResult('This is post # 20', 'Posts');
assertSearchResult('This is a TOML front matter post', 'Posts');
assertSearchResult('This is a JSON front matter post', 'Posts');
assertSearchResult('This is a YAML front matter post', 'Posts');
assertSearchResult('This FAQ item # 5', 'FAQ');
});
it('can search in posts collection', () => {
search('this', 'Posts');
assertSearchHeading('Search Results for "this" in Posts');
assertSearchResult('This is post # 20');
assertSearchResult('This is a TOML front matter post');
assertSearchResult('This is a JSON front matter post');
assertSearchResult('This is a YAML front matter post');
assertNotInSearch('This FAQ item # 5');
});
it('can search in faq collection', () => {
search('this', 'FAQ');
assertSearchHeading('Search Results for "this" in FAQ');
assertSearchResult('This FAQ item # 5');
assertNotInSearch('This is post # 20');
});
});

View File

@ -19,7 +19,7 @@ describe('search', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should search entries using integration', async () => {
it('should search entries in all collections using integration', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}),
@ -39,6 +39,7 @@ describe('search', () => {
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
page: 0,
},
});
@ -46,6 +47,7 @@ describe('search', () => {
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
entries: response.entries,
page: response.pagination,
},
@ -55,7 +57,45 @@ describe('search', () => {
expect(integration.search).toHaveBeenCalledWith(['posts', 'pages'], 'find me', 0);
});
it('should search entries using backend', async () => {
it('should search entries in a subset of collections using integration', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}),
});
selectIntegration.mockReturnValue('search_integration');
currentBackend.mockReturnValue({});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
const integration = { search: jest.fn().mockResolvedValue(response) };
getIntegrationProvider.mockReturnValue(integration);
await store.dispatch(searchEntries('find me', ['pages']));
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
searchCollections: ['pages'],
page: 0,
},
});
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
searchCollections: ['pages'],
entries: response.entries,
page: response.pagination,
},
});
expect(integration.search).toHaveBeenCalledTimes(1);
expect(integration.search).toHaveBeenCalledWith(['pages'], 'find me', 0);
});
it('should search entries in all collections using backend', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}),
@ -74,6 +114,7 @@ describe('search', () => {
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
page: 0,
},
});
@ -81,6 +122,7 @@ describe('search', () => {
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
entries: response.entries,
page: response.pagination,
},
@ -93,10 +135,47 @@ describe('search', () => {
);
});
it('should ignore identical search', async () => {
it('should search entries in a subset of collections using backend', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({ isFetching: true, term: 'find me' }),
search: fromJS({}),
});
const response = { entries: [{ name: '1' }, { name: '' }], pagination: 1 };
const backend = { search: jest.fn().mockResolvedValue(response) };
currentBackend.mockReturnValue(backend);
await store.dispatch(searchEntries('find me', ['pages']));
const actions = store.getActions();
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
type: 'SEARCH_ENTRIES_REQUEST',
payload: {
searchTerm: 'find me',
searchCollections: ['pages'],
page: 0,
},
});
expect(actions[1]).toEqual({
type: 'SEARCH_ENTRIES_SUCCESS',
payload: {
searchTerm: 'find me',
searchCollections: ['pages'],
entries: response.entries,
page: response.pagination,
},
});
expect(backend.search).toHaveBeenCalledTimes(1);
expect(backend.search).toHaveBeenCalledWith([fromJS({ name: 'pages' })], 'find me');
});
it('should ignore identical search in all collections', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({ isFetching: true, term: 'find me', collections: ['posts', 'pages'] }),
});
await store.dispatch(searchEntries('find me'));
@ -104,5 +183,34 @@ describe('search', () => {
const actions = store.getActions();
expect(actions).toHaveLength(0);
});
it('should ignore identical search in a subset of collections', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({ isFetching: true, term: 'find me', collections: ['pages'] }),
});
await store.dispatch(searchEntries('find me', ['pages']));
const actions = store.getActions();
expect(actions).toHaveLength(0);
});
it('should not ignore same search term in different search collections', async () => {
const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({ isFetching: true, term: 'find me', collections: ['pages'] }),
});
const backend = { search: jest.fn().mockResolvedValue({}) };
currentBackend.mockReturnValue(backend);
await store.dispatch(searchEntries('find me', ['posts', 'pages']));
expect(backend.search).toHaveBeenCalledTimes(1);
expect(backend.search).toHaveBeenCalledWith(
[fromJS({ name: 'posts' }), fromJS({ name: 'pages' })],
'find me',
);
});
});
});

View File

@ -1,8 +1,12 @@
import history from 'Routing/history';
import { getCollectionUrl, getNewEntryUrl } from 'Lib/urlHelper';
export function searchCollections(query) {
history.push(`/search/${query}`);
export function searchCollections(query, collection) {
if (collection) {
history.push(`/collections/${collection}/search/${query}`);
} else {
history.push(`/search/${query}`);
}
}
export function showCollection(collectionName) {

View File

@ -5,6 +5,7 @@ import { currentBackend } from '../backend';
import { getIntegrationProvider } from '../integrations';
import { selectIntegration } from '../reducers';
import { EntryValue } from '../valueObjects/Entry';
import { List } from 'immutable';
/*
* Constant Declarations
@ -23,18 +24,24 @@ export const SEARCH_CLEAR = 'SEARCH_CLEAR';
* Simple Action Creators (Internal)
* We still need to export them for tests
*/
export function searchingEntries(searchTerm: string, page: number) {
export function searchingEntries(searchTerm: string, searchCollections: string[], page: number) {
return {
type: SEARCH_ENTRIES_REQUEST,
payload: { searchTerm, page },
payload: { searchTerm, searchCollections, page },
};
}
export function searchSuccess(searchTerm: string, entries: EntryValue[], page: number) {
export function searchSuccess(
searchTerm: string,
searchCollections: string[],
entries: EntryValue[],
page: number,
) {
return {
type: SEARCH_ENTRIES_SUCCESS,
payload: {
searchTerm,
searchCollections,
entries,
page,
},
@ -124,12 +131,16 @@ export function clearSearch() {
*/
// SearchEntries will search for complete entries in all collections.
export function searchEntries(searchTerm: string, page = 0) {
export function searchEntries(
searchTerm: string,
searchCollections: string[] | null = null,
page = 0,
) {
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
const state = getState();
const { search } = state;
const backend = currentBackend(state.config);
const allCollections = state.collections.keySeq().toArray();
const allCollections = searchCollections || state.collections.keySeq().toArray();
const collections = allCollections.filter(collection =>
selectIntegration(state, collection as string, 'search'),
);
@ -139,12 +150,14 @@ export function searchEntries(searchTerm: string, page = 0) {
if (
search.get('isFetching') === true &&
search.get('term') === searchTerm &&
search.get('collections') !== null &&
List(allCollections).equals(search.get('collections') as List<string>) &&
// if an integration doesn't exist, 'page' is not used
(search.get('page') === page || !integration)
) {
return;
}
dispatch(searchingEntries(searchTerm, page));
dispatch(searchingEntries(searchTerm, allCollections as string[], page));
const searchPromise = integration
? getIntegrationProvider(state.integrations, backend.getToken, integration).search(
@ -152,11 +165,24 @@ export function searchEntries(searchTerm: string, page = 0) {
searchTerm,
page,
)
: backend.search(state.collections.valueSeq().toArray(), searchTerm);
: backend.search(
state.collections
.filter((_, key: string) => allCollections.indexOf(key) !== -1)
.valueSeq()
.toArray(),
searchTerm,
);
return searchPromise.then(
(response: Response) =>
dispatch(searchSuccess(searchTerm, response.entries, response.pagination)),
dispatch(
searchSuccess(
searchTerm,
allCollections as string[],
response.entries,
response.pagination,
),
),
(error: Error) => dispatch(searchFailure(searchTerm, error)),
);
};

View File

@ -200,6 +200,12 @@ class App extends React.Component {
<Switch>
<Redirect exact from="/" to={defaultPath} />
<Redirect exact from="/search/" to={defaultPath} />
<RouteInCollection
exact
collections={collections}
path="/collections/:name/search/"
render={({ match }) => <Redirect to={`/collections/${match.params.name}`} />}
/>
<Redirect
// This happens on Identity + Invite Only + External Provider email not matching
// the registered user
@ -223,6 +229,11 @@ class App extends React.Component {
collections={collections}
render={props => <Editor {...props} />}
/>
<RouteInCollection
path="/collections/:name/search/:searchTerm"
collections={collections}
render={props => <Collection {...props} isSearchResults isSingleSearchResult />}
/>
<Route
path="/search/:searchTerm"
render={props => <Collection {...props} isSearchResults />}

View File

@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled';
import { connect } from 'react-redux';
import { translate } from 'react-polyglot';
import { lengths } from 'netlify-cms-ui-default';
import { lengths, components } from 'netlify-cms-ui-default';
import { getNewEntryUrl } from 'Lib/urlHelper';
import Sidebar from './Sidebar';
import CollectionTop from './CollectionTop';
@ -24,11 +24,21 @@ const CollectionMain = styled.main`
padding-left: 280px;
`;
const SearchResultContainer = styled.div`
${components.cardTop};
margin-bottom: 22px;
`;
const SearchResultHeading = styled.h1`
${components.cardTopHeading};
`;
class Collection extends React.Component {
static propTypes = {
searchTerm: PropTypes.string,
collectionName: PropTypes.string,
isSearchResults: PropTypes.bool,
isSingleSearchResult: PropTypes.bool,
collection: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.orderedMap.isRequired,
sortableFields: PropTypes.array,
@ -46,8 +56,13 @@ class Collection extends React.Component {
};
renderEntriesSearch = () => {
const { searchTerm, collections } = this.props;
return <EntriesSearch collections={collections} searchTerm={searchTerm} />;
const { searchTerm, collections, collection, isSingleSearchResult } = this.props;
return (
<EntriesSearch
collections={isSingleSearchResult ? collections.filter(c => c === collection) : collections}
searchTerm={searchTerm}
/>
);
};
handleChangeViewStyle = viewStyle => {
@ -62,17 +77,33 @@ class Collection extends React.Component {
collections,
collectionName,
isSearchResults,
isSingleSearchResult,
searchTerm,
sortableFields,
onSortClick,
sort,
t,
} = this.props;
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
const searchResultKey =
'collection.collectionTop.searchResults' + (isSingleSearchResult ? 'InCollection' : '');
return (
<CollectionContainer>
<Sidebar collections={collections} searchTerm={searchTerm} />
<Sidebar
collections={collections}
collection={(!isSearchResults || isSingleSearchResult) && collection}
searchTerm={searchTerm}
/>
<CollectionMain>
{isSearchResults ? null : (
{isSearchResults ? (
<SearchResultContainer>
<SearchResultHeading>
{t(searchResultKey, { searchTerm, collection: collection.get('label') })}
</SearchResultHeading>
</SearchResultContainer>
) : (
<>
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
<CollectionControls

View File

@ -0,0 +1,238 @@
import React from 'react';
import styled from '@emotion/styled';
import { colorsRaw, colors, Icon, lengths, zIndex } from 'netlify-cms-ui-default';
import { translate } from 'react-polyglot';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
const SearchContainer = styled.div`
margin: 0 12px;
position: relative;
${Icon} {
position: absolute;
top: 0;
left: 6px;
z-index: ${zIndex.zIndex2};
height: 100%;
display: flex;
align-items: center;
pointer-events: none;
}
`;
const InputContainer = styled.div`
display: flex;
align-items: center;
position: relative;
`;
const SearchInput = styled.input`
background-color: #eff0f4;
border-radius: ${lengths.borderRadius};
font-size: 14px;
padding: 10px 6px 10px 32px;
width: 100%;
position: relative;
z-index: ${zIndex.zIndex1};
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px ${colorsRaw.blue};
}
`;
const SuggestionsContainer = styled.div`
position: relative;
width: 100%;
`;
const Suggestions = styled.ul`
position: absolute;
top: 6px;
left: 0px;
right: 0px;
padding: 10px 0;
margin: 0;
list-style: none;
background-color: #fff;
border-radius: ${lengths.borderRadius};
border: 1px solid ${colors.textFieldBorder};
z-index: ${zIndex.zIndex1};
`;
const SuggestionHeader = styled.li`
padding: 0px 6px 6px 32px;
font-size: 12px;
color: ${colors.text};
`;
const SuggestionItem = styled.li(
({ isActive }) => `
color: ${isActive ? colors.active : colorsRaw.grayDark};
background-color: ${isActive ? colors.activeBackground : 'inherit'};
padding: 6px 6px 6px 32px;
cursor: pointer;
position: relative;
&:hover {
color: ${colors.active};
background-color: ${colors.activeBackground};
}
`,
);
const SuggestionDivider = styled.div`
width: 100%;
`;
class CollectionSearch extends React.Component {
static propTypes = {
collections: ImmutablePropTypes.orderedMap.isRequired,
collection: ImmutablePropTypes.map,
searchTerm: PropTypes.string.isRequired,
onSubmit: PropTypes.func.isRequired,
t: PropTypes.func.isRequired,
};
state = {
query: this.props.searchTerm,
suggestionsVisible: false,
// default to the currently selected
selectedCollectionIdx: this.getSelectedSelectionBasedOnProps(),
};
componentDidUpdate(prevProps) {
if (prevProps.collection !== this.props.collection) {
const selectedCollectionIdx = this.getSelectedSelectionBasedOnProps();
this.setState({ selectedCollectionIdx });
}
}
getSelectedSelectionBasedOnProps() {
const { collection, collections } = this.props;
return collection ? collections.keySeq().indexOf(collection.get('name')) : -1;
}
toggleSuggestions(visible) {
this.setState({ suggestionsVisible: visible });
}
selectNextSuggestion() {
const { collections } = this.props;
const { selectedCollectionIdx } = this.state;
this.setState({
selectedCollectionIdx: Math.min(selectedCollectionIdx + 1, collections.size - 1),
});
}
selectPreviousSuggestion() {
const { selectedCollectionIdx } = this.state;
this.setState({
selectedCollectionIdx: Math.max(selectedCollectionIdx - 1, -1),
});
}
resetSelectedSuggestion() {
this.setState({
selectedCollectionIdx: -1,
});
}
submitSearch = () => {
const { onSubmit, collections } = this.props;
const { selectedCollectionIdx, query } = this.state;
this.toggleSuggestions(false);
if (selectedCollectionIdx !== -1) {
onSubmit(query, collections.toIndexedSeq().getIn([selectedCollectionIdx, 'name']));
} else {
onSubmit(query);
}
};
handleKeyDown = event => {
const { suggestionsVisible } = this.state;
if (event.key === 'Enter') {
this.submitSearch();
}
if (suggestionsVisible) {
// allow closing of suggestions with escape key
if (event.key === 'Escape') {
this.toggleSuggestions(false);
}
if (event.key === 'ArrowDown') {
this.selectNextSuggestion();
event.preventDefault();
} else if (event.key === 'ArrowUp') {
this.selectPreviousSuggestion();
event.preventDefault();
}
}
};
handleQueryChange = query => {
this.setState({ query });
this.toggleSuggestions(query !== '');
if (query === '') {
this.resetSelectedSuggestion();
}
};
handleSuggestionClick = (event, idx) => {
this.setState({ selectedCollectionIdx: idx }, this.submitSearch);
event.preventDefault();
};
render() {
const { collections, t } = this.props;
const { suggestionsVisible, selectedCollectionIdx, query } = this.state;
return (
<SearchContainer
onBlur={() => this.toggleSuggestions(false)}
onFocus={() => this.toggleSuggestions(query !== '')}
>
<InputContainer>
<Icon type="search" />
<SearchInput
onChange={e => this.handleQueryChange(e.target.value)}
onKeyDown={this.handleKeyDown}
onClick={() => this.toggleSuggestions(true)}
placeholder={t('collection.sidebar.searchAll')}
value={query}
/>
</InputContainer>
{suggestionsVisible && (
<SuggestionsContainer>
<Suggestions>
<SuggestionHeader>{t('collection.sidebar.searchIn')}</SuggestionHeader>
<SuggestionItem
isActive={selectedCollectionIdx === -1}
onClick={e => this.handleSuggestionClick(e, -1)}
onMouseDown={e => e.preventDefault()}
>
{t('collection.sidebar.allCollections')}
</SuggestionItem>
<SuggestionDivider />
{collections.toIndexedSeq().map((collection, idx) => (
<SuggestionItem
key={idx}
isActive={idx === selectedCollectionIdx}
onClick={e => this.handleSuggestionClick(e, idx)}
onMouseDown={e => e.preventDefault()}
>
{collection.get('label')}
</SuggestionItem>
))}
</Suggestions>
</SuggestionsContainer>
)}
</SearchContainer>
);
}
}
export default translate()(CollectionSearch);

View File

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { Cursor } from 'netlify-cms-lib-util';
import { selectSearchedEntries } from 'Reducers';
import {
@ -17,19 +18,25 @@ class EntriesSearch extends React.Component {
clearSearch: PropTypes.func.isRequired,
searchTerm: PropTypes.string.isRequired,
collections: ImmutablePropTypes.seq,
collectionNames: PropTypes.array,
entries: ImmutablePropTypes.list,
page: PropTypes.number,
};
componentDidMount() {
const { searchTerm, searchEntries } = this.props;
searchEntries(searchTerm);
const { searchTerm, searchEntries, collectionNames } = this.props;
searchEntries(searchTerm, collectionNames);
}
componentDidUpdate(prevProps) {
if (prevProps.searchTerm === this.props.searchTerm) return;
const { searchTerm, collectionNames } = this.props;
// check if the search parameters are the same
if (prevProps.searchTerm === searchTerm && isEqual(prevProps.collectionNames, collectionNames))
return;
const { searchEntries } = prevProps;
searchEntries(this.props.searchTerm);
searchEntries(searchTerm, collectionNames);
}
componentWillUnmount() {
@ -44,10 +51,10 @@ class EntriesSearch extends React.Component {
};
handleCursorActions = action => {
const { page, searchTerm, searchEntries } = this.props;
const { page, searchTerm, searchEntries, collectionNames } = this.props;
if (action === 'append_next') {
const nextPage = page + 1;
searchEntries(searchTerm, nextPage);
searchEntries(searchTerm, collectionNames, nextPage);
}
};
@ -68,11 +75,11 @@ class EntriesSearch extends React.Component {
function mapStateToProps(state, ownProps) {
const { searchTerm } = ownProps;
const collections = ownProps.collections.toIndexedSeq();
const collectionNames = ownProps.collections.keySeq().toArray();
const isFetching = state.search.get('isFetching');
const page = state.search.get('page');
const entries = selectSearchedEntries(state);
return { isFetching, page, collections, entries, searchTerm };
const entries = selectSearchedEntries(state, collectionNames);
return { isFetching, page, collections, collectionNames, entries, searchTerm };
}
const mapDispatchToProps = {

View File

@ -54,10 +54,11 @@ export default class EntryListing extends React.Component {
renderCardsForMultipleCollections = () => {
const { collections, entries } = this.props;
const isSingleCollectionInList = collections.size === 1;
return entries.map((entry, idx) => {
const collectionName = entry.get('collection');
const collection = collections.find(coll => coll.get('name') === collectionName);
const collectionLabel = collection.get('label');
const collectionLabel = !isSingleCollectionInList && collection.get('label');
const inferedFields = this.inferFields(collection);
const entryCardProps = { collection, entry, inferedFields, collectionLabel };
return <EntryCard {...entryCardProps} key={idx} />;

View File

@ -5,8 +5,9 @@ import styled from '@emotion/styled';
import { css } from '@emotion/core';
import { translate } from 'react-polyglot';
import { NavLink } from 'react-router-dom';
import { Icon, components, colors, colorsRaw, lengths, zIndex } from 'netlify-cms-ui-default';
import { Icon, components, colors } from 'netlify-cms-ui-default';
import { searchCollections } from 'Actions/collections';
import CollectionSearch from './CollectionSearch';
const styles = {
sidebarNavLinkActive: css`
@ -22,7 +23,8 @@ const SidebarContainer = styled.aside`
padding: 8px 0 12px;
position: fixed;
max-height: calc(100vh - 112px);
overflow: auto;
display: flex;
flex-direction: column;
`;
const SidebarHeading = styled.h2`
@ -33,42 +35,10 @@ const SidebarHeading = styled.h2`
color: ${colors.textLead};
`;
const SearchContainer = styled.div`
display: flex;
align-items: center;
margin: 0 12px;
position: relative;
${Icon} {
position: absolute;
top: 0;
left: 6px;
z-index: ${zIndex.zIndex2};
height: 100%;
display: flex;
align-items: center;
pointer-events: none;
}
`;
const SearchInput = styled.input`
background-color: #eff0f4;
border-radius: ${lengths.borderRadius};
font-size: 14px;
padding: 10px 6px 10px 32px;
width: 100%;
position: relative;
z-index: ${zIndex.zIndex1};
&:focus {
outline: none;
box-shadow: inset 0 0 0 2px ${colorsRaw.blue};
}
`;
const SidebarNavList = styled.ul`
margin: 16px 0 0;
list-style: none;
overflow: auto;
`;
const SidebarNavLink = styled(NavLink)`
@ -78,6 +48,7 @@ const SidebarNavLink = styled(NavLink)`
align-items: center;
padding: 8px 12px;
border-left: 2px solid #fff;
z-index: -1;
${Icon} {
margin-right: 8px;
@ -96,6 +67,7 @@ const SidebarNavLink = styled(NavLink)`
class Sidebar extends React.Component {
static propTypes = {
collections: ImmutablePropTypes.orderedMap.isRequired,
collection: ImmutablePropTypes.map,
searchTerm: PropTypes.string,
t: PropTypes.func.isRequired,
};
@ -104,8 +76,6 @@ class Sidebar extends React.Component {
searchTerm: '',
};
state = { query: this.props.searchTerm };
renderLink = collection => {
const collectionName = collection.get('name');
return (
@ -119,21 +89,16 @@ class Sidebar extends React.Component {
};
render() {
const { collections, t } = this.props;
const { query } = this.state;
const { collections, collection, searchTerm, t } = this.props;
return (
<SidebarContainer>
<SidebarHeading>{t('collection.sidebar.collections')}</SidebarHeading>
<SearchContainer>
<Icon type="search" size="small" />
<SearchInput
onChange={e => this.setState({ query: e.target.value })}
onKeyDown={e => e.key === 'Enter' && searchCollections(query)}
placeholder={t('collection.sidebar.searchAll')}
value={query}
/>
</SearchContainer>
<CollectionSearch
searchTerm={searchTerm}
collections={collections}
collection={collection}
onSubmit={(query, collection) => searchCollections(query, collection)}
/>
<SidebarNavList>
{collections
.toList()

View File

@ -44,13 +44,14 @@ export const selectEntries = (state: State, collection: string) =>
export const selectPublishedSlugs = (state: State, collection: string) =>
fromEntries.selectPublishedSlugs(state.entries, collection);
export const selectSearchedEntries = (state: State) => {
export const selectSearchedEntries = (state: State, availableCollections: string[]) => {
const searchItems = state.search.get('entryIds');
// only return search results for actually available collections
return (
searchItems &&
searchItems.map(({ collection, slug }) =>
fromEntries.selectEntry(state.entries, collection, slug),
)
searchItems
.filter(({ collection }) => availableCollections.indexOf(collection) !== -1)
.map(({ collection, slug }) => fromEntries.selectEntry(state.entries, collection, slug))
);
};

View File

@ -12,10 +12,12 @@ let loadedEntries;
let response;
let page;
let searchTerm;
let searchCollections;
const defaultState = Map({
isFetching: false,
term: null,
collections: null,
page: 0,
entryIds: List([]),
queryHits: Map({}),
@ -31,6 +33,7 @@ const entries = (state = defaultState, action) => {
return state.withMutations(map => {
map.set('isFetching', true);
map.set('term', action.payload.searchTerm);
map.set('collections', List(action.payload.searchCollections));
map.set('page', action.payload.page);
});
}
@ -40,6 +43,7 @@ const entries = (state = defaultState, action) => {
loadedEntries = action.payload.entries;
page = action.payload.page;
searchTerm = action.payload.searchTerm;
searchCollections = action.payload.searchCollections;
return state.withMutations(map => {
const entryIds = List(
loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })),
@ -48,6 +52,7 @@ const entries = (state = defaultState, action) => {
map.set('fetchID', null);
map.set('page', page);
map.set('term', searchTerm);
map.set('collections', List(searchCollections));
map.set(
'entryIds',
!page || isNaN(page) || page === 0

View File

@ -222,6 +222,7 @@ export type Search = StaticallyTypedRecord<{
entryIds?: SearchItem[];
isFetching: boolean;
term: string | null;
collections: List<string> | null;
page: number;
}>;

View File

@ -34,11 +34,18 @@ const de = {
collection: {
sidebar: {
collections: 'Inhaltstypen',
allCollections: 'Allen Inhaltstypen',
searchAll: 'Alles durchsuchen',
searchIn: 'Suchen in',
},
collectionTop: {
sortBy: 'Sortieren nach',
viewAs: 'Anzeigen als',
newButton: 'Neue(r) %{collectionLabel}',
ascending: 'Aufsteigend',
descending: 'Absteigend',
searchResults: 'Suchergebnisse für "%{searchTerm}"',
searchResultsInCollection: 'Suchergebnisse für "%{searchTerm}" in %{collection}',
},
entries: {
loadingEntries: 'Beiträge laden',

View File

@ -34,7 +34,9 @@ const en = {
collection: {
sidebar: {
collections: 'Collections',
allCollections: 'All Collections',
searchAll: 'Search all',
searchIn: 'Search in',
},
collectionTop: {
sortBy: 'Sort by',
@ -42,6 +44,8 @@ const en = {
newButton: 'New %{collectionLabel}',
ascending: 'Ascending',
descending: 'Descending',
searchResults: 'Search Results for "%{searchTerm}"',
searchResultsInCollection: 'Search Results for "%{searchTerm}" in %{collection}',
},
entries: {
loadingEntries: 'Loading Entries...',