feat: improve search to target single collections (#3760)
This commit is contained in:
parent
72596bbbec
commit
588622adb2
78
cypress/integration/search_suggestion_spec.js
Normal file
78
cypress/integration/search_suggestion_spec.js
Normal 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');
|
||||
});
|
||||
});
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,9 +1,13 @@
|
||||
import history from 'Routing/history';
|
||||
import { getCollectionUrl, getNewEntryUrl } from 'Lib/urlHelper';
|
||||
|
||||
export function searchCollections(query) {
|
||||
export function searchCollections(query, collection) {
|
||||
if (collection) {
|
||||
history.push(`/collections/${collection}/search/${query}`);
|
||||
} else {
|
||||
history.push(`/search/${query}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function showCollection(collectionName) {
|
||||
history.push(getCollectionUrl(collectionName));
|
||||
|
@ -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)),
|
||||
);
|
||||
};
|
||||
|
@ -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 />}
|
||||
|
@ -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
|
||||
|
@ -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);
|
@ -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 = {
|
||||
|
@ -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} />;
|
||||
|
@ -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}
|
||||
<CollectionSearch
|
||||
searchTerm={searchTerm}
|
||||
collections={collections}
|
||||
collection={collection}
|
||||
onSubmit={(query, collection) => searchCollections(query, collection)}
|
||||
/>
|
||||
</SearchContainer>
|
||||
<SidebarNavList>
|
||||
{collections
|
||||
.toList()
|
||||
|
@ -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))
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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
|
||||
|
@ -222,6 +222,7 @@ export type Search = StaticallyTypedRecord<{
|
||||
entryIds?: SearchItem[];
|
||||
isFetching: boolean;
|
||||
term: string | null;
|
||||
collections: List<string> | null;
|
||||
page: number;
|
||||
}>;
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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...',
|
||||
|
Loading…
x
Reference in New Issue
Block a user