From f0bdfadab7e5293b3f7855b6707a1fa95ea76ca6 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 13 Nov 2016 13:04:18 +0100 Subject: [PATCH] Search component --- .../javascripts/components/actions/search.jsx | 51 +++++++ .../features/compose/components/search.jsx | 126 ++++++++++++++++++ .../compose/containers/search_container.jsx | 35 +++++ .../components/features/compose/index.jsx | 3 +- .../components/reducers/accounts.jsx | 2 + .../javascripts/components/reducers/index.jsx | 4 +- .../components/reducers/search.jsx | 60 +++++++++ app/assets/stylesheets/components.scss | 14 +- 8 files changed, 291 insertions(+), 4 deletions(-) create mode 100644 app/assets/javascripts/components/actions/search.jsx create mode 100644 app/assets/javascripts/components/features/compose/components/search.jsx create mode 100644 app/assets/javascripts/components/features/compose/containers/search_container.jsx create mode 100644 app/assets/javascripts/components/reducers/search.jsx diff --git a/app/assets/javascripts/components/actions/search.jsx b/app/assets/javascripts/components/actions/search.jsx new file mode 100644 index 0000000000..ceb0e4a0c1 --- /dev/null +++ b/app/assets/javascripts/components/actions/search.jsx @@ -0,0 +1,51 @@ +import api from '../api' + +export const SEARCH_CHANGE = 'SEARCH_CHANGE'; +export const SEARCH_SUGGESTIONS_CLEAR = 'SEARCH_SUGGESTIONS_CLEAR'; +export const SEARCH_SUGGESTIONS_READY = 'SEARCH_SUGGESTIONS_READY'; +export const SEARCH_RESET = 'SEARCH_RESET'; + +export function changeSearch(value) { + return { + type: SEARCH_CHANGE, + value + }; +}; + +export function clearSearchSuggestions() { + return { + type: SEARCH_SUGGESTIONS_CLEAR + }; +}; + +export function readySearchSuggestions(value, accounts) { + return { + type: SEARCH_SUGGESTIONS_READY, + value, + accounts + }; +}; + +export function fetchSearchSuggestions(value) { + return (dispatch, getState) => { + if (getState().getIn(['search', 'loaded_value']) === value) { + return; + } + + api(getState).get('/api/v1/accounts/search', { + params: { + q: value, + resolve: true, + limit: 4 + } + }).then(response => { + dispatch(readySearchSuggestions(value, response.data)); + }); + }; +}; + +export function resetSearch() { + return { + type: SEARCH_RESET + }; +}; diff --git a/app/assets/javascripts/components/features/compose/components/search.jsx b/app/assets/javascripts/components/features/compose/components/search.jsx new file mode 100644 index 0000000000..e81771e6a2 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/search.jsx @@ -0,0 +1,126 @@ +import PureRenderMixin from 'react-addons-pure-render-mixin'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Autosuggest from 'react-autosuggest'; +import AutosuggestAccountContainer from '../containers/autosuggest_account_container'; + +const getSuggestionValue = suggestion => suggestion.value; + +const renderSuggestion = suggestion => { + if (suggestion.type === 'account') { + return ; + } else { + return #{suggestion.id} + } +}; + +const renderSectionTitle = section => ( + {section.title} +); + +const getSectionSuggestions = section => section.items; + +const outerStyle = { + padding: '10px', + lineHeight: '20px', + position: 'relative' +}; + +const inputStyle = { + boxSizing: 'border-box', + display: 'block', + width: '100%', + border: 'none', + padding: '10px', + paddingRight: '30px', + fontFamily: 'Roboto', + background: '#282c37', + color: '#9baec8', + fontSize: '14px', + margin: '0' +}; + +const iconStyle = { + position: 'absolute', + top: '18px', + right: '20px', + color: '#9baec8', + fontSize: '18px', + pointerEvents: 'none' +}; + +const Search = React.createClass({ + + contextTypes: { + router: React.PropTypes.object + }, + + propTypes: { + suggestions: React.PropTypes.array.isRequired, + value: React.PropTypes.string.isRequired, + onChange: React.PropTypes.func.isRequired, + onClear: React.PropTypes.func.isRequired, + onFetch: React.PropTypes.func.isRequired, + onReset: React.PropTypes.func.isRequired + }, + + mixins: [PureRenderMixin], + + onChange (_, { newValue }) { + if (typeof newValue !== 'string') { + return; + } + + this.props.onChange(newValue); + }, + + onSuggestionsClearRequested () { + this.props.onClear(); + }, + + onSuggestionsFetchRequested ({ value }) { + value = value.replace('#', ''); + this.props.onFetch(value.trim()); + }, + + onSuggestionSelected (_, { suggestion }) { + if (suggestion.type === 'account') { + this.context.router.push(`/accounts/${suggestion.id}`); + } else { + this.context.router.push(`/statuses/tag/${suggestion.id}`); + } + }, + + render () { + const inputProps = { + placeholder: 'Search', + value: this.props.value, + onChange: this.onChange, + style: inputStyle + }; + + return ( +
+ + +
+
+ ); + }, + +}); + +export default Search; diff --git a/app/assets/javascripts/components/features/compose/containers/search_container.jsx b/app/assets/javascripts/components/features/compose/containers/search_container.jsx new file mode 100644 index 0000000000..17a68f2fc8 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/search_container.jsx @@ -0,0 +1,35 @@ +import { connect } from 'react-redux'; +import { + changeSearch, + clearSearchSuggestions, + fetchSearchSuggestions, + resetSearch +} from '../../../actions/search'; +import Search from '../components/search'; + +const mapStateToProps = state => ({ + suggestions: state.getIn(['search', 'suggestions']), + value: state.getIn(['search', 'value']) +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeSearch(value)); + }, + + onClear () { + dispatch(clearSearchSuggestions()); + }, + + onFetch (value) { + dispatch(fetchSearchSuggestions(value)); + }, + + onReset () { + dispatch(resetSearch()); + } + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Search); diff --git a/app/assets/javascripts/components/features/compose/index.jsx b/app/assets/javascripts/components/features/compose/index.jsx index d76afc4375..260f67034c 100644 --- a/app/assets/javascripts/components/features/compose/index.jsx +++ b/app/assets/javascripts/components/features/compose/index.jsx @@ -5,6 +5,7 @@ import UploadFormContainer from '../ui/containers/upload_form_container'; import NavigationContainer from '../ui/containers/navigation_container'; import PureRenderMixin from 'react-addons-pure-render-mixin'; import SuggestionsContainer from './containers/suggestions_container'; +import SearchContainer from './containers/search_container'; import { fetchSuggestions } from '../../actions/suggestions'; import { connect } from 'react-redux'; @@ -24,13 +25,13 @@ const Compose = React.createClass({ return (
+
-
); } diff --git a/app/assets/javascripts/components/reducers/accounts.jsx b/app/assets/javascripts/components/reducers/accounts.jsx index 471e1b0aa1..b20b3d0c57 100644 --- a/app/assets/javascripts/components/reducers/accounts.jsx +++ b/app/assets/javascripts/components/reducers/accounts.jsx @@ -26,6 +26,7 @@ import { STATUS_FETCH_SUCCESS, CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; +import { SEARCH_SUGGESTIONS_READY } from '../actions/search'; import Immutable from 'immutable'; const normalizeAccount = (state, account) => state.set(account.id, Immutable.fromJS(account)); @@ -70,6 +71,7 @@ export default function accounts(state = initialState, action) { case REBLOGS_FETCH_SUCCESS: case FAVOURITES_FETCH_SUCCESS: case COMPOSE_SUGGESTIONS_READY: + case SEARCH_SUGGESTIONS_READY: return normalizeAccounts(state, action.accounts); case TIMELINE_REFRESH_SUCCESS: case TIMELINE_EXPAND_SUCCESS: diff --git a/app/assets/javascripts/components/reducers/index.jsx b/app/assets/javascripts/components/reducers/index.jsx index ccc9e8e8e6..e2203cc1ac 100644 --- a/app/assets/javascripts/components/reducers/index.jsx +++ b/app/assets/javascripts/components/reducers/index.jsx @@ -10,6 +10,7 @@ import user_lists from './user_lists'; import accounts from './accounts'; import statuses from './statuses'; import relationships from './relationships'; +import search from './search'; export default combineReducers({ timelines, @@ -22,5 +23,6 @@ export default combineReducers({ user_lists, accounts, statuses, - relationships + relationships, + search }); diff --git a/app/assets/javascripts/components/reducers/search.jsx b/app/assets/javascripts/components/reducers/search.jsx new file mode 100644 index 0000000000..f3ee17f60a --- /dev/null +++ b/app/assets/javascripts/components/reducers/search.jsx @@ -0,0 +1,60 @@ +import { + SEARCH_CHANGE, + SEARCH_SUGGESTIONS_READY, + SEARCH_RESET +} from '../actions/search'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + value: '', + loaded_value: '', + suggestions: [] +}); + +const normalizeSuggestions = (state, value, accounts) => { + let newSuggestions = [ + { + title: 'Account', + items: accounts.map(item => ({ + type: 'account', + id: item.id, + value: item.acct + })) + } + ]; + + if (value.indexOf('@') === -1) { + newSuggestions.push({ + title: 'Hashtag', + items: [ + { + type: 'hashtag', + id: value, + value: `#${value}` + } + ] + }); + } + + return state.withMutations(map => { + map.set('suggestions', newSuggestions); + map.set('loaded_value', value); + }); +}; + +export default function search(state = initialState, action) { + switch(action.type) { + case SEARCH_CHANGE: + return state.set('value', action.value); + case SEARCH_SUGGESTIONS_READY: + return normalizeSuggestions(state, action.value, action.accounts); + case SEARCH_RESET: + return state.withMutations(map => { + map.set('suggestions', []); + map.set('value', ''); + map.set('loaded_value', ''); + }); + default: + return state; + } +}; diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 89397a96d6..2cd58bb2bc 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -325,12 +325,22 @@ top: 100%; width: 100%; z-index: 99; + box-shadow: 0 0 15px rgba(0, 0, 0, 0.4); } -.react-autosuggest__suggestions-list { +.react-autosuggest__section-title { background: #9baec8; + padding: 4px 10px; + font-weight: 500; + cursor: default; + color: #282c37; + text-transform: uppercase; + font-size: 11px; +} + +.react-autosuggest__suggestions-list { + background: #d9e1e8; color: #282c37; - box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); font-size: 14px; }