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(() => { beforeEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();
}); });
it('should search entries using integration', async () => { it('should search entries in all collections using integration', async () => {
const store = mockStore({ const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }), collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}), search: fromJS({}),
@ -39,6 +39,7 @@ describe('search', () => {
type: 'SEARCH_ENTRIES_REQUEST', type: 'SEARCH_ENTRIES_REQUEST',
payload: { payload: {
searchTerm: 'find me', searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
page: 0, page: 0,
}, },
}); });
@ -46,6 +47,7 @@ describe('search', () => {
type: 'SEARCH_ENTRIES_SUCCESS', type: 'SEARCH_ENTRIES_SUCCESS',
payload: { payload: {
searchTerm: 'find me', searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
entries: response.entries, entries: response.entries,
page: response.pagination, page: response.pagination,
}, },
@ -55,7 +57,45 @@ describe('search', () => {
expect(integration.search).toHaveBeenCalledWith(['posts', 'pages'], 'find me', 0); 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({ const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }), collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }),
search: fromJS({}), search: fromJS({}),
@ -74,6 +114,7 @@ describe('search', () => {
type: 'SEARCH_ENTRIES_REQUEST', type: 'SEARCH_ENTRIES_REQUEST',
payload: { payload: {
searchTerm: 'find me', searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
page: 0, page: 0,
}, },
}); });
@ -81,6 +122,7 @@ describe('search', () => {
type: 'SEARCH_ENTRIES_SUCCESS', type: 'SEARCH_ENTRIES_SUCCESS',
payload: { payload: {
searchTerm: 'find me', searchTerm: 'find me',
searchCollections: ['posts', 'pages'],
entries: response.entries, entries: response.entries,
page: response.pagination, 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({ const store = mockStore({
collections: fromJS({ posts: { name: 'posts' }, pages: { name: 'pages' } }), 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')); await store.dispatch(searchEntries('find me'));
@ -104,5 +183,34 @@ describe('search', () => {
const actions = store.getActions(); const actions = store.getActions();
expect(actions).toHaveLength(0); 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 history from 'Routing/history';
import { getCollectionUrl, getNewEntryUrl } from 'Lib/urlHelper'; import { getCollectionUrl, getNewEntryUrl } from 'Lib/urlHelper';
export function searchCollections(query) { export function searchCollections(query, collection) {
history.push(`/search/${query}`); if (collection) {
history.push(`/collections/${collection}/search/${query}`);
} else {
history.push(`/search/${query}`);
}
} }
export function showCollection(collectionName) { export function showCollection(collectionName) {

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { translate } from 'react-polyglot'; 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 { getNewEntryUrl } from 'Lib/urlHelper';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import CollectionTop from './CollectionTop'; import CollectionTop from './CollectionTop';
@ -24,11 +24,21 @@ const CollectionMain = styled.main`
padding-left: 280px; padding-left: 280px;
`; `;
const SearchResultContainer = styled.div`
${components.cardTop};
margin-bottom: 22px;
`;
const SearchResultHeading = styled.h1`
${components.cardTopHeading};
`;
class Collection extends React.Component { class Collection extends React.Component {
static propTypes = { static propTypes = {
searchTerm: PropTypes.string, searchTerm: PropTypes.string,
collectionName: PropTypes.string, collectionName: PropTypes.string,
isSearchResults: PropTypes.bool, isSearchResults: PropTypes.bool,
isSingleSearchResult: PropTypes.bool,
collection: ImmutablePropTypes.map.isRequired, collection: ImmutablePropTypes.map.isRequired,
collections: ImmutablePropTypes.orderedMap.isRequired, collections: ImmutablePropTypes.orderedMap.isRequired,
sortableFields: PropTypes.array, sortableFields: PropTypes.array,
@ -46,8 +56,13 @@ class Collection extends React.Component {
}; };
renderEntriesSearch = () => { renderEntriesSearch = () => {
const { searchTerm, collections } = this.props; const { searchTerm, collections, collection, isSingleSearchResult } = this.props;
return <EntriesSearch collections={collections} searchTerm={searchTerm} />; return (
<EntriesSearch
collections={isSingleSearchResult ? collections.filter(c => c === collection) : collections}
searchTerm={searchTerm}
/>
);
}; };
handleChangeViewStyle = viewStyle => { handleChangeViewStyle = viewStyle => {
@ -62,17 +77,33 @@ class Collection extends React.Component {
collections, collections,
collectionName, collectionName,
isSearchResults, isSearchResults,
isSingleSearchResult,
searchTerm, searchTerm,
sortableFields, sortableFields,
onSortClick, onSortClick,
sort, sort,
t,
} = this.props; } = this.props;
const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : ''; const newEntryUrl = collection.get('create') ? getNewEntryUrl(collectionName) : '';
const searchResultKey =
'collection.collectionTop.searchResults' + (isSingleSearchResult ? 'InCollection' : '');
return ( return (
<CollectionContainer> <CollectionContainer>
<Sidebar collections={collections} searchTerm={searchTerm} /> <Sidebar
collections={collections}
collection={(!isSearchResults || isSingleSearchResult) && collection}
searchTerm={searchTerm}
/>
<CollectionMain> <CollectionMain>
{isSearchResults ? null : ( {isSearchResults ? (
<SearchResultContainer>
<SearchResultHeading>
{t(searchResultKey, { searchTerm, collection: collection.get('label') })}
</SearchResultHeading>
</SearchResultContainer>
) : (
<> <>
<CollectionTop collection={collection} newEntryUrl={newEntryUrl} /> <CollectionTop collection={collection} newEntryUrl={newEntryUrl} />
<CollectionControls <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 PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { Cursor } from 'netlify-cms-lib-util'; import { Cursor } from 'netlify-cms-lib-util';
import { selectSearchedEntries } from 'Reducers'; import { selectSearchedEntries } from 'Reducers';
import { import {
@ -17,19 +18,25 @@ class EntriesSearch extends React.Component {
clearSearch: PropTypes.func.isRequired, clearSearch: PropTypes.func.isRequired,
searchTerm: PropTypes.string.isRequired, searchTerm: PropTypes.string.isRequired,
collections: ImmutablePropTypes.seq, collections: ImmutablePropTypes.seq,
collectionNames: PropTypes.array,
entries: ImmutablePropTypes.list, entries: ImmutablePropTypes.list,
page: PropTypes.number, page: PropTypes.number,
}; };
componentDidMount() { componentDidMount() {
const { searchTerm, searchEntries } = this.props; const { searchTerm, searchEntries, collectionNames } = this.props;
searchEntries(searchTerm); searchEntries(searchTerm, collectionNames);
} }
componentDidUpdate(prevProps) { 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; const { searchEntries } = prevProps;
searchEntries(this.props.searchTerm); searchEntries(searchTerm, collectionNames);
} }
componentWillUnmount() { componentWillUnmount() {
@ -44,10 +51,10 @@ class EntriesSearch extends React.Component {
}; };
handleCursorActions = action => { handleCursorActions = action => {
const { page, searchTerm, searchEntries } = this.props; const { page, searchTerm, searchEntries, collectionNames } = this.props;
if (action === 'append_next') { if (action === 'append_next') {
const nextPage = page + 1; const nextPage = page + 1;
searchEntries(searchTerm, nextPage); searchEntries(searchTerm, collectionNames, nextPage);
} }
}; };
@ -68,11 +75,11 @@ class EntriesSearch extends React.Component {
function mapStateToProps(state, ownProps) { function mapStateToProps(state, ownProps) {
const { searchTerm } = ownProps; const { searchTerm } = ownProps;
const collections = ownProps.collections.toIndexedSeq(); const collections = ownProps.collections.toIndexedSeq();
const collectionNames = ownProps.collections.keySeq().toArray();
const isFetching = state.search.get('isFetching'); const isFetching = state.search.get('isFetching');
const page = state.search.get('page'); const page = state.search.get('page');
const entries = selectSearchedEntries(state); const entries = selectSearchedEntries(state, collectionNames);
return { isFetching, page, collections, collectionNames, entries, searchTerm };
return { isFetching, page, collections, entries, searchTerm };
} }
const mapDispatchToProps = { const mapDispatchToProps = {

View File

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

View File

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

View File

@ -44,13 +44,14 @@ export const selectEntries = (state: State, collection: string) =>
export const selectPublishedSlugs = (state: State, collection: string) => export const selectPublishedSlugs = (state: State, collection: string) =>
fromEntries.selectPublishedSlugs(state.entries, collection); fromEntries.selectPublishedSlugs(state.entries, collection);
export const selectSearchedEntries = (state: State) => { export const selectSearchedEntries = (state: State, availableCollections: string[]) => {
const searchItems = state.search.get('entryIds'); const searchItems = state.search.get('entryIds');
// only return search results for actually available collections
return ( return (
searchItems && searchItems &&
searchItems.map(({ collection, slug }) => searchItems
fromEntries.selectEntry(state.entries, collection, slug), .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 response;
let page; let page;
let searchTerm; let searchTerm;
let searchCollections;
const defaultState = Map({ const defaultState = Map({
isFetching: false, isFetching: false,
term: null, term: null,
collections: null,
page: 0, page: 0,
entryIds: List([]), entryIds: List([]),
queryHits: Map({}), queryHits: Map({}),
@ -31,6 +33,7 @@ const entries = (state = defaultState, action) => {
return state.withMutations(map => { return state.withMutations(map => {
map.set('isFetching', true); map.set('isFetching', true);
map.set('term', action.payload.searchTerm); map.set('term', action.payload.searchTerm);
map.set('collections', List(action.payload.searchCollections));
map.set('page', action.payload.page); map.set('page', action.payload.page);
}); });
} }
@ -40,6 +43,7 @@ const entries = (state = defaultState, action) => {
loadedEntries = action.payload.entries; loadedEntries = action.payload.entries;
page = action.payload.page; page = action.payload.page;
searchTerm = action.payload.searchTerm; searchTerm = action.payload.searchTerm;
searchCollections = action.payload.searchCollections;
return state.withMutations(map => { return state.withMutations(map => {
const entryIds = List( const entryIds = List(
loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })), loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })),
@ -48,6 +52,7 @@ const entries = (state = defaultState, action) => {
map.set('fetchID', null); map.set('fetchID', null);
map.set('page', page); map.set('page', page);
map.set('term', searchTerm); map.set('term', searchTerm);
map.set('collections', List(searchCollections));
map.set( map.set(
'entryIds', 'entryIds',
!page || isNaN(page) || page === 0 !page || isNaN(page) || page === 0

View File

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

View File

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

View File

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