From 588622adb23df53f9a26914446b0982eddf8f15c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20K=C3=BCttner?= Date: Mon, 18 May 2020 09:52:06 +0200 Subject: [PATCH] feat: improve search to target single collections (#3760) --- cypress/integration/search_suggestion_spec.js | 78 ++++++ .../src/actions/__tests__/search.spec.js | 116 ++++++++- .../src/actions/collections.js | 8 +- .../netlify-cms-core/src/actions/search.ts | 42 +++- .../src/components/App/App.js | 11 + .../src/components/Collection/Collection.js | 41 ++- .../components/Collection/CollectionSearch.js | 238 ++++++++++++++++++ .../Collection/Entries/EntriesSearch.js | 25 +- .../Collection/Entries/EntryListing.js | 3 +- .../src/components/Collection/Sidebar.js | 63 ++--- .../netlify-cms-core/src/reducers/index.ts | 9 +- .../netlify-cms-core/src/reducers/search.js | 5 + packages/netlify-cms-core/src/types/redux.ts | 1 + packages/netlify-cms-locales/src/de/index.js | 7 + packages/netlify-cms-locales/src/en/index.js | 4 + 15 files changed, 569 insertions(+), 82 deletions(-) create mode 100644 cypress/integration/search_suggestion_spec.js create mode 100644 packages/netlify-cms-core/src/components/Collection/CollectionSearch.js diff --git a/cypress/integration/search_suggestion_spec.js b/cypress/integration/search_suggestion_spec.js new file mode 100644 index 00000000..cfeacceb --- /dev/null +++ b/cypress/integration/search_suggestion_spec.js @@ -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'); + }); +}); diff --git a/packages/netlify-cms-core/src/actions/__tests__/search.spec.js b/packages/netlify-cms-core/src/actions/__tests__/search.spec.js index 85938745..55f022f9 100644 --- a/packages/netlify-cms-core/src/actions/__tests__/search.spec.js +++ b/packages/netlify-cms-core/src/actions/__tests__/search.spec.js @@ -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', + ); + }); }); }); diff --git a/packages/netlify-cms-core/src/actions/collections.js b/packages/netlify-cms-core/src/actions/collections.js index 70cf1b3c..53cd4ab0 100644 --- a/packages/netlify-cms-core/src/actions/collections.js +++ b/packages/netlify-cms-core/src/actions/collections.js @@ -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) { diff --git a/packages/netlify-cms-core/src/actions/search.ts b/packages/netlify-cms-core/src/actions/search.ts index 718cd88e..4ecf7ff1 100644 --- a/packages/netlify-cms-core/src/actions/search.ts +++ b/packages/netlify-cms-core/src/actions/search.ts @@ -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, 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) && // 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)), ); }; diff --git a/packages/netlify-cms-core/src/components/App/App.js b/packages/netlify-cms-core/src/components/App/App.js index 1d8e9eb1..d8c79951 100644 --- a/packages/netlify-cms-core/src/components/App/App.js +++ b/packages/netlify-cms-core/src/components/App/App.js @@ -200,6 +200,12 @@ class App extends React.Component { + } + /> } /> + } + /> } diff --git a/packages/netlify-cms-core/src/components/Collection/Collection.js b/packages/netlify-cms-core/src/components/Collection/Collection.js index 6218c03f..1218093e 100644 --- a/packages/netlify-cms-core/src/components/Collection/Collection.js +++ b/packages/netlify-cms-core/src/components/Collection/Collection.js @@ -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 ; + const { searchTerm, collections, collection, isSingleSearchResult } = this.props; + return ( + 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 ( - + - {isSearchResults ? null : ( + {isSearchResults ? ( + + + {t(searchResultKey, { searchTerm, collection: collection.get('label') })} + + + ) : ( <> ` + 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 ( + this.toggleSuggestions(false)} + onFocus={() => this.toggleSuggestions(query !== '')} + > + + + this.handleQueryChange(e.target.value)} + onKeyDown={this.handleKeyDown} + onClick={() => this.toggleSuggestions(true)} + placeholder={t('collection.sidebar.searchAll')} + value={query} + /> + + {suggestionsVisible && ( + + + {t('collection.sidebar.searchIn')} + this.handleSuggestionClick(e, -1)} + onMouseDown={e => e.preventDefault()} + > + {t('collection.sidebar.allCollections')} + + + {collections.toIndexedSeq().map((collection, idx) => ( + this.handleSuggestionClick(e, idx)} + onMouseDown={e => e.preventDefault()} + > + {collection.get('label')} + + ))} + + + )} + + ); + } +} + +export default translate()(CollectionSearch); diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/EntriesSearch.js b/packages/netlify-cms-core/src/components/Collection/Entries/EntriesSearch.js index 35575604..57cf5363 100644 --- a/packages/netlify-cms-core/src/components/Collection/Entries/EntriesSearch.js +++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntriesSearch.js @@ -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 = { diff --git a/packages/netlify-cms-core/src/components/Collection/Entries/EntryListing.js b/packages/netlify-cms-core/src/components/Collection/Entries/EntryListing.js index 6197a993..7322315c 100644 --- a/packages/netlify-cms-core/src/components/Collection/Entries/EntryListing.js +++ b/packages/netlify-cms-core/src/components/Collection/Entries/EntryListing.js @@ -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 ; diff --git a/packages/netlify-cms-core/src/components/Collection/Sidebar.js b/packages/netlify-cms-core/src/components/Collection/Sidebar.js index db48511f..90f893ea 100644 --- a/packages/netlify-cms-core/src/components/Collection/Sidebar.js +++ b/packages/netlify-cms-core/src/components/Collection/Sidebar.js @@ -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 ( {t('collection.sidebar.collections')} - - - this.setState({ query: e.target.value })} - onKeyDown={e => e.key === 'Enter' && searchCollections(query)} - placeholder={t('collection.sidebar.searchAll')} - value={query} - /> - + searchCollections(query, collection)} + /> {collections .toList() diff --git a/packages/netlify-cms-core/src/reducers/index.ts b/packages/netlify-cms-core/src/reducers/index.ts index e2418ef7..246674b3 100644 --- a/packages/netlify-cms-core/src/reducers/index.ts +++ b/packages/netlify-cms-core/src/reducers/index.ts @@ -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)) ); }; diff --git a/packages/netlify-cms-core/src/reducers/search.js b/packages/netlify-cms-core/src/reducers/search.js index a6429f52..9a6b7cb7 100644 --- a/packages/netlify-cms-core/src/reducers/search.js +++ b/packages/netlify-cms-core/src/reducers/search.js @@ -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 diff --git a/packages/netlify-cms-core/src/types/redux.ts b/packages/netlify-cms-core/src/types/redux.ts index 3e786146..cce075ed 100644 --- a/packages/netlify-cms-core/src/types/redux.ts +++ b/packages/netlify-cms-core/src/types/redux.ts @@ -222,6 +222,7 @@ export type Search = StaticallyTypedRecord<{ entryIds?: SearchItem[]; isFetching: boolean; term: string | null; + collections: List | null; page: number; }>; diff --git a/packages/netlify-cms-locales/src/de/index.js b/packages/netlify-cms-locales/src/de/index.js index d79ec61e..1fe05ccf 100644 --- a/packages/netlify-cms-locales/src/de/index.js +++ b/packages/netlify-cms-locales/src/de/index.js @@ -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', diff --git a/packages/netlify-cms-locales/src/en/index.js b/packages/netlify-cms-locales/src/en/index.js index 42f02b1a..8b4f8513 100644 --- a/packages/netlify-cms-locales/src/en/index.js +++ b/packages/netlify-cms-locales/src/en/index.js @@ -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...',