diff options
author | Christopher Speller <crspeller@gmail.com> | 2015-12-03 08:13:32 -0500 |
---|---|---|
committer | Christopher Speller <crspeller@gmail.com> | 2015-12-03 08:13:32 -0500 |
commit | 6aa316a75a67f191f7a6db7b6fae642369e51f81 (patch) | |
tree | e53adb7997a325a4fb48ef5b3b31a4523e39ae7d /web/react/components | |
parent | 0a38ec5796391d0daf7b7874859108a54c20ec5f (diff) | |
parent | 956d460b108f278b6cfbcb728241b89b992b2a55 (diff) | |
download | chat-6aa316a75a67f191f7a6db7b6fae642369e51f81.tar.gz chat-6aa316a75a67f191f7a6db7b6fae642369e51f81.tar.bz2 chat-6aa316a75a67f191f7a6db7b6fae642369e51f81.zip |
Merge pull request #1540 from hmhealey/plt1297
PLT-1297 Replaced SearchAutocomplete with new suggestion components
Diffstat (limited to 'web/react/components')
-rw-r--r-- | web/react/components/search_autocomplete.jsx | 341 | ||||
-rw-r--r-- | web/react/components/search_bar.jsx | 47 | ||||
-rw-r--r-- | web/react/components/search_channel_provider.jsx | 69 | ||||
-rw-r--r-- | web/react/components/search_user_provider.jsx | 62 | ||||
-rw-r--r-- | web/react/components/suggestion_box.jsx | 170 | ||||
-rw-r--r-- | web/react/components/suggestion_list.jsx | 157 |
6 files changed, 468 insertions, 378 deletions
diff --git a/web/react/components/search_autocomplete.jsx b/web/react/components/search_autocomplete.jsx deleted file mode 100644 index 4c0aa0166..000000000 --- a/web/react/components/search_autocomplete.jsx +++ /dev/null @@ -1,341 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import ChannelStore from '../stores/channel_store.jsx'; -import Constants from '../utils/constants.jsx'; -const KeyCodes = Constants.KeyCodes; -const Popover = ReactBootstrap.Popover; -import UserStore from '../stores/user_store.jsx'; -import * as Utils from '../utils/utils.jsx'; - -const patterns = new Map([ - ['channels', /\b(?:in|channel):\s*(\S*)$/i], - ['users', /\bfrom:\s*(\S*)$/i] -]); - -export default class SearchAutocomplete extends React.Component { - constructor(props) { - super(props); - - this.handleClick = this.handleClick.bind(this); - this.handleDocumentClick = this.handleDocumentClick.bind(this); - this.handleInputChange = this.handleInputChange.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - - this.completeWord = this.completeWord.bind(this); - this.getSelection = this.getSelection.bind(this); - this.scrollToItem = this.scrollToItem.bind(this); - this.updateSuggestions = this.updateSuggestions.bind(this); - - this.renderChannelSuggestion = this.renderChannelSuggestion.bind(this); - this.renderUserSuggestion = this.renderUserSuggestion.bind(this); - - this.state = { - show: false, - mode: '', - filter: '', - selection: 0, - suggestions: new Map() - }; - } - - componentDidMount() { - $(document).on('click', this.handleDocumentClick); - } - - componentDidUpdate(prevProps, prevState) { - const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content'); - - if (this.state.show && this.state.suggestions.length > 0) { - if (!prevState.show) { - content.perfectScrollbar(); - content.css('max-height', $(window).height() - 200); - } - - // keep the keyboard selection visible when scrolling - this.scrollToItem(this.getSelection()); - } - } - - componentWillUnmount() { - $(document).off('click', this.handleDocumentClick); - } - - handleClick(value) { - this.completeWord(value); - } - - handleDocumentClick(e) { - const container = $(ReactDOM.findDOMNode(this.refs.searchPopover)); - - if (!(container.is(e.target) || container.has(e.target).length > 0)) { - this.setState({ - show: false - }); - } - } - - handleInputChange(textbox, text) { - const caret = Utils.getCaretPosition(textbox); - const preText = text.substring(0, caret); - - let mode = ''; - let filter = ''; - for (const [modeForPattern, pattern] of patterns) { - const result = pattern.exec(preText); - - if (result) { - mode = modeForPattern; - filter = result[1]; - break; - } - } - - if (mode !== this.state.mode || filter !== this.state.filter) { - this.updateSuggestions(mode, filter); - } - - this.setState({ - mode, - filter, - show: mode || filter - }); - } - - handleKeyDown(e) { - if (!this.state.show || this.state.suggestions.length === 0) { - return; - } - - if (e.which === KeyCodes.UP || e.which === KeyCodes.DOWN) { - e.preventDefault(); - - let selection = this.state.selection; - - if (e.which === KeyCodes.UP) { - selection -= 1; - } else { - selection += 1; - } - - if (selection >= 0 && selection < this.state.suggestions.length) { - this.setState({ - selection - }); - } - } else if (e.which === KeyCodes.ENTER || e.which === KeyCodes.SPACE) { - e.preventDefault(); - - this.completeWord(this.getSelection()); - } - } - - completeWord(value) { - // add a space so that anything else typed doesn't interfere with the search flag - this.props.completeWord(this.state.filter, value + ' '); - - this.setState({ - show: false, - mode: '', - filter: '', - selection: 0 - }); - } - - getSelection() { - if (this.state.suggestions.length > 0) { - if (this.state.mode === 'channels') { - return this.state.suggestions[this.state.selection].name; - } else if (this.state.mode === 'users') { - return this.state.suggestions[this.state.selection].username; - } - } - - return ''; - } - - scrollToItem(itemName) { - const content = $(ReactDOM.findDOMNode(this.refs.searchPopover)).find('.popover-content'); - const visibleContentHeight = content[0].clientHeight; - const actualContentHeight = content[0].scrollHeight; - - if (this.state.suggestions.length > 0 && visibleContentHeight < actualContentHeight) { - const contentTop = content.scrollTop(); - const contentTopPadding = parseInt(content.css('padding-top'), 10); - const contentBottomPadding = parseInt(content.css('padding-top'), 10); - - const item = $(this.refs[itemName]); - const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10); - const itemBottom = item[0].offsetTop + item.height() + parseInt(item.css('margin-bottom'), 10); - - if (itemTop - contentTopPadding < contentTop) { - // the item is off the top of the visible space - content.scrollTop(itemTop - contentTopPadding); - } else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) { - // the item has gone off the bottom of the visible space - content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding); - } - } - } - - updateSuggestions(mode, filter) { - let suggestions = []; - - if (mode === 'channels') { - let channels = ChannelStore.getAll(); - - if (filter) { - channels = channels.filter((channel) => channel.name.startsWith(filter) && channel.type !== 'D'); - } else { - // don't show direct channels - channels = channels.filter((channel) => channel.type !== 'D'); - } - - channels.sort((a, b) => { - // put public channels first and then sort alphabebetically - if (a.type === b.type) { - return a.name.localeCompare(b.name); - } else if (a.type === Constants.OPEN_CHANNEL) { - return -1; - } - - return 1; - }); - - suggestions = channels; - } else if (mode === 'users') { - let users = UserStore.getActiveOnlyProfileList(); - - if (filter) { - users = users.filter((user) => user.username.startsWith(filter)); - } - - users.sort((a, b) => a.username.localeCompare(b.username)); - - suggestions = users; - } - - let selection = this.state.selection; - - // keep the same user/channel selected if it's still visible as a suggestion - if (selection > 0 && this.state.suggestions.length > 0) { - // we can't just use indexOf to find if the selection is still in the list since they are different javascript objects - const currentSelectionId = this.state.suggestions[selection].id; - let found = false; - - for (let i = 0; i < suggestions.length; i++) { - if (suggestions[i].id === currentSelectionId) { - selection = i; - found = true; - - break; - } - } - - if (!found) { - selection = 0; - } - } else { - selection = 0; - } - - this.setState({ - suggestions, - selection - }); - } - - renderChannelSuggestion(channel) { - let className = 'search-autocomplete__item'; - if (channel.name === this.getSelection()) { - className += ' selected'; - } - - return ( - <div - key={channel.name} - ref={channel.name} - onClick={this.handleClick.bind(this, channel.name)} - className={className} - > - {channel.name} - </div> - ); - } - - renderUserSuggestion(user) { - let className = 'search-autocomplete__item'; - if (user.username === this.getSelection()) { - className += ' selected'; - } - - return ( - <div - key={user.username} - ref={user.username} - onClick={this.handleClick.bind(this, user.username)} - className={className} - > - <img - className='profile-img rounded' - src={'/api/v1/users/' + user.id + '/image?time=' + user.update_at} - /> - {user.username} - </div> - ); - } - - render() { - if (!this.state.show || this.state.suggestions.length === 0) { - return null; - } - - let suggestions = []; - - if (this.state.mode === 'channels') { - const publicChannels = this.state.suggestions.filter((channel) => channel.type === Constants.OPEN_CHANNEL); - if (publicChannels.length > 0) { - suggestions.push( - <div - key='public-channel-divider' - className='search-autocomplete__divider' - > - <span>{'Public ' + Utils.getChannelTerm(Constants.OPEN_CHANNEL) + 's'}</span> - </div> - ); - suggestions = suggestions.concat(publicChannels.map(this.renderChannelSuggestion)); - } - - const privateChannels = this.state.suggestions.filter((channel) => channel.type === Constants.PRIVATE_CHANNEL); - if (privateChannels.length > 0) { - suggestions.push( - <div - key='private-channel-divider' - className='search-autocomplete__divider' - > - <span>{'Private ' + Utils.getChannelTerm(Constants.PRIVATE_CHANNEL) + 's'}</span> - </div> - ); - suggestions = suggestions.concat(privateChannels.map(this.renderChannelSuggestion)); - } - } else if (this.state.mode === 'users') { - suggestions = this.state.suggestions.map(this.renderUserSuggestion); - } - - return ( - <Popover - ref='searchPopover' - onShow={this.componentDidMount} - id='search-autocomplete__popover' - className='search-help-popover autocomplete visible' - placement='bottom' - > - {suggestions} - </Popover> - ); - } -} - -SearchAutocomplete.propTypes = { - completeWord: React.PropTypes.func.isRequired -}; diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index 32f0f93bf..0ea5c451a 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -5,11 +5,13 @@ import * as client from '../utils/client.jsx'; import * as AsyncClient from '../utils/async_client.jsx'; import SearchStore from '../stores/search_store.jsx'; import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import SuggestionBox from '../components/suggestion_box.jsx'; +import SearchChannelProvider from '../components/search_channel_provider.jsx'; +import SearchUserProvider from '../components/search_user_provider.jsx'; import * as utils from '../utils/utils.jsx'; import Constants from '../utils/constants.jsx'; var ActionTypes = Constants.ActionTypes; var Popover = ReactBootstrap.Popover; -import SearchAutocomplete from './search_autocomplete.jsx'; export default class SearchBar extends React.Component { constructor() { @@ -17,17 +19,17 @@ export default class SearchBar extends React.Component { this.mounted = false; this.onListenerChange = this.onListenerChange.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); this.handleUserInput = this.handleUserInput.bind(this); this.handleUserFocus = this.handleUserFocus.bind(this); this.handleUserBlur = this.handleUserBlur.bind(this); this.performSearch = this.performSearch.bind(this); this.handleSubmit = this.handleSubmit.bind(this); - this.completeWord = this.completeWord.bind(this); const state = this.getSearchTermStateFromStores(); state.focused = false; this.state = state; + + this.suggestionProviders = [new SearchChannelProvider(), new SearchUserProvider()]; } getSearchTermStateFromStores() { var term = SearchStore.getSearchTerm() || ''; @@ -77,18 +79,11 @@ export default class SearchBar extends React.Component { results: null }); } - handleKeyDown(e) { - if (this.refs.autocomplete) { - this.refs.autocomplete.handleKeyDown(e); - } - } - handleUserInput(e) { - var term = e.target.value; + handleUserInput(text) { + var term = text; SearchStore.storeSearchTerm(term); SearchStore.emitSearchTermChange(false); this.setState({searchTerm: term}); - - this.refs.autocomplete.handleInputChange(e.target, term); } handleUserBlur() { this.setState({focused: false}); @@ -128,23 +123,6 @@ export default class SearchBar extends React.Component { this.performSearch(this.state.searchTerm.trim()); } - completeWord(partialWord, word) { - const textbox = ReactDOM.findDOMNode(this.refs.search); - let text = textbox.value; - - const caret = utils.getCaretPosition(textbox); - const preText = text.substring(0, caret - partialWord.length); - const postText = text.substring(caret); - text = preText + word + postText; - - textbox.value = text; - utils.setCaretPosition(textbox, preText.length + word.length); - - SearchStore.storeSearchTerm(text); - SearchStore.emitSearchTermChange(false); - this.setState({searchTerm: text}); - } - render() { var isSearching = null; if (this.state.isSearching) { @@ -178,22 +156,17 @@ export default class SearchBar extends React.Component { autoComplete='off' > <span className='glyphicon glyphicon-search sidebar__search-icon' /> - <input - type='text' + <SuggestionBox ref='search' className='form-control search-bar' placeholder='Search' value={this.state.searchTerm} onFocus={this.handleUserFocus} onBlur={this.handleUserBlur} - onChange={this.handleUserInput} - onKeyDown={this.handleKeyDown} + onUserInput={this.handleUserInput} + providers={this.suggestionProviders} /> {isSearching} - <SearchAutocomplete - ref='autocomplete' - completeWord={this.completeWord} - /> <Popover id='searchbar-help-popup' placement='bottom' diff --git a/web/react/components/search_channel_provider.jsx b/web/react/components/search_channel_provider.jsx new file mode 100644 index 000000000..6b2fa2d62 --- /dev/null +++ b/web/react/components/search_channel_provider.jsx @@ -0,0 +1,69 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ChannelStore from '../stores/channel_store.jsx'; +import Constants from '../utils/constants.jsx'; +import SuggestionStore from '../stores/suggestion_store.jsx'; + +class SearchChannelSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'search-autocomplete__item'; + if (isSelection) { + className += ' selected'; + } + + return ( + <div + onClick={onClick} + className={className} + > + {item.name} + </div> + ); + } +} + +SearchChannelSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class SearchChannelProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/\b(?:in|channel):\s*(\S*)$/i).exec(pretext); + if (captured) { + const channelPrefix = captured[1]; + + const channels = ChannelStore.getAll(); + const publicChannels = []; + const privateChannels = []; + + for (const id of Object.keys(channels)) { + const channel = channels[id]; + + // don't show direct channels + if (channel.type !== Constants.DM_CHANNEL && channel.name.startsWith(channelPrefix)) { + if (channel.type === Constants.OPEN_CHANNEL) { + publicChannels.push(channel); + } else { + privateChannels.push(channel); + } + } + } + + publicChannels.sort((a, b) => a.name.localeCompare(b.name)); + const publicChannelNames = publicChannels.map((channel) => channel.name); + + privateChannels.sort((a, b) => a.name.localeCompare(b.name)); + const privateChannelNames = privateChannels.map((channel) => channel.name); + + SuggestionStore.setMatchedPretext(suggestionId, channelPrefix); + + SuggestionStore.addSuggestions(suggestionId, publicChannelNames, publicChannels, SearchChannelSuggestion); + SuggestionStore.addSuggestions(suggestionId, privateChannelNames, privateChannels, SearchChannelSuggestion); + } + } +} diff --git a/web/react/components/search_user_provider.jsx b/web/react/components/search_user_provider.jsx new file mode 100644 index 000000000..7c1711d36 --- /dev/null +++ b/web/react/components/search_user_provider.jsx @@ -0,0 +1,62 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SuggestionStore from '../stores/suggestion_store.jsx'; +import UserStore from '../stores/user_store.jsx'; + +class SearchUserSuggestion extends React.Component { + render() { + const {item, isSelection, onClick} = this.props; + + let className = 'search-autocomplete__item'; + if (isSelection) { + className += ' selected'; + } + + return ( + <div + className={className} + onClick={onClick} + > + <img + className='profile-img rounded' + src={'/api/v1/users/' + item.id + '/image?time=' + item.update_at} + /> + {item.username} + </div> + ); + } +} + +SearchUserSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +export default class SearchUserProvider { + handlePretextChanged(suggestionId, pretext) { + const captured = (/\bfrom:\s*(\S*)$/i).exec(pretext); + if (captured) { + const usernamePrefix = captured[1]; + + const users = UserStore.getProfiles(); + let filtered = []; + + for (const id of Object.keys(users)) { + const user = users[id]; + + if (user.username.startsWith(usernamePrefix)) { + filtered.push(user); + } + } + + filtered = filtered.sort((a, b) => a.username.localeCompare(b.username)); + + const usernames = filtered.map((user) => user.username); + + SuggestionStore.setMatchedPretext(suggestionId, usernamePrefix); + SuggestionStore.addSuggestions(suggestionId, usernames, filtered, SearchUserSuggestion); + } + } +} diff --git a/web/react/components/suggestion_box.jsx b/web/react/components/suggestion_box.jsx new file mode 100644 index 000000000..a72e17430 --- /dev/null +++ b/web/react/components/suggestion_box.jsx @@ -0,0 +1,170 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from '../utils/constants.jsx'; +import SuggestionList from './suggestion_list.jsx'; +import SuggestionStore from '../stores/suggestion_store.jsx'; +import * as Utils from '../utils/utils.jsx'; + +const ActionTypes = Constants.ActionTypes; +const KeyCodes = Constants.KeyCodes; + +export default class SuggestionBox extends React.Component { + constructor(props) { + super(props); + + this.handleDocumentClick = this.handleDocumentClick.bind(this); + this.handleFocus = this.handleFocus.bind(this); + + this.handleChange = this.handleChange.bind(this); + this.handleCompleteWord = this.handleCompleteWord.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handlePretextChanged = this.handlePretextChanged.bind(this); + + this.suggestionId = Utils.generateId(); + + this.state = { + focused: false + }; + } + + componentDidMount() { + SuggestionStore.registerSuggestionBox(this.suggestionId); + $(document).on('click', this.handleDocumentClick); + + SuggestionStore.addCompleteWordListener(this.suggestionId, this.handleCompleteWord); + SuggestionStore.addPretextChangedListener(this.suggestionId, this.handlePretextChanged); + } + + componentWillUnmount() { + SuggestionStore.removeCompleteWordListener(this.suggestionId, this.handleCompleteWord); + SuggestionStore.removePretextChangedListener(this.suggestionId, this.handlePretextChanged); + + SuggestionStore.unregisterSuggestionBox(this.suggestionId); + $(document).off('click', this.handleDocumentClick); + } + + handleDocumentClick(e) { + if (!this.state.focused) { + return; + } + + const container = $(ReactDOM.findDOMNode(this)); + if (!(container.is(e.target) || container.has(e.target).length > 0)) { + // we can't just use blur for this because it fires and hides the children before + // their click handlers can be called + this.setState({ + focused: false + }); + } + } + + handleFocus() { + this.setState({ + focused: true + }); + + if (this.props.onFocus) { + this.props.onFocus(); + } + } + + handleChange(e) { + const textbox = ReactDOM.findDOMNode(this.refs.textbox); + const caret = Utils.getCaretPosition(textbox); + const pretext = textbox.value.substring(0, caret); + + AppDispatcher.handleViewAction({ + type: ActionTypes.SUGGESTION_PRETEXT_CHANGED, + id: this.suggestionId, + pretext + }); + + if (this.props.onUserInput) { + this.props.onUserInput(textbox.value); + } + + if (this.props.onChange) { + this.props.onChange(e); + } + } + + handleCompleteWord(term) { + const textbox = ReactDOM.findDOMNode(this.refs.textbox); + const caret = Utils.getCaretPosition(textbox); + + const text = this.props.value; + const prefix = text.substring(0, caret - SuggestionStore.getMatchedPretext(this.suggestionId).length); + const suffix = text.substring(caret); + + if (this.props.onUserInput) { + this.props.onUserInput(prefix + term + ' ' + suffix); + } + + // set the caret position after the next rendering + window.requestAnimationFrame(() => { + Utils.setCaretPosition(textbox, prefix.length + term.length + 1); + }); + } + + handleKeyDown(e) { + if (e.which === KeyCodes.UP) { + AppDispatcher.handleViewAction({ + type: ActionTypes.SUGGESTION_SELECT_PREVIOUS, + id: this.suggestionId + }); + e.preventDefault(); + } else if (e.which === KeyCodes.DOWN) { + AppDispatcher.handleViewAction({ + type: ActionTypes.SUGGESTION_SELECT_NEXT, + id: this.suggestionId + }); + e.preventDefault(); + } else if ((e.which === KeyCodes.SPACE || e.which === KeyCodes.ENTER) && SuggestionStore.hasSuggestions(this.suggestionId)) { + AppDispatcher.handleViewAction({ + type: ActionTypes.SUGGESTION_COMPLETE_WORD, + id: this.suggestionId + }); + e.preventDefault(); + } else if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } + + handlePretextChanged(pretext) { + for (const provider of this.props.providers) { + provider.handlePretextChanged(this.suggestionId, pretext); + } + } + + render() { + const newProps = Object.assign({}, this.props, { + onFocus: this.handleFocus, + onChange: this.handleChange, + onKeyDown: this.handleKeyDown + }); + + return ( + <div> + <input + ref='textbox' + type='text' + {...newProps} + /> + <SuggestionList suggestionId={this.suggestionId} /> + </div> + ); + } +} + +SuggestionBox.propTypes = { + value: React.PropTypes.string.isRequired, + onUserInput: React.PropTypes.func, + providers: React.PropTypes.arrayOf(React.PropTypes.object), + + // explicitly name any input event handlers we override and need to manually call + onChange: React.PropTypes.func, + onKeyDown: React.PropTypes.func, + onFocus: React.PropTypes.func +}; diff --git a/web/react/components/suggestion_list.jsx b/web/react/components/suggestion_list.jsx new file mode 100644 index 000000000..04d8f3e60 --- /dev/null +++ b/web/react/components/suggestion_list.jsx @@ -0,0 +1,157 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import AppDispatcher from '../dispatcher/app_dispatcher.jsx'; +import Constants from '../utils/constants.jsx'; +import SuggestionStore from '../stores/suggestion_store.jsx'; +import * as Utils from '../utils/utils.jsx'; + +export default class SuggestionList extends React.Component { + constructor(props) { + super(props); + + this.handleItemClick = this.handleItemClick.bind(this); + this.handleSuggestionsChanged = this.handleSuggestionsChanged.bind(this); + + this.scrollToItem = this.scrollToItem.bind(this); + + this.state = { + items: [], + terms: [], + components: [], + selection: '' + }; + } + + componentDidMount() { + SuggestionStore.addSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged); + } + + componentDidUpdate(prevProps, prevState) { + if (this.state.items.length > 0 && prevState.items.length === 0) { + const content = $(ReactDOM.findDOMNode(this.refs.popover)).find('.popover-content'); + content.perfectScrollbar(); + } + } + + componentWillUnmount() { + SuggestionStore.removeSuggestionsChangedListener(this.props.suggestionId, this.handleSuggestionsChanged); + } + + handleItemClick(term, e) { + AppDispatcher.handleViewAction({ + type: Constants.ActionTypes.SUGGESTION_COMPLETE_WORD, + id: this.props.suggestionId, + term + }); + + e.preventDefault(); + } + + handleSuggestionsChanged() { + const selection = SuggestionStore.getSelection(this.props.suggestionId); + + this.setState({ + items: SuggestionStore.getItems(this.props.suggestionId), + terms: SuggestionStore.getTerms(this.props.suggestionId), + components: SuggestionStore.getComponents(this.props.suggestionId), + selection + }); + + if (selection) { + window.requestAnimationFrame(() => this.scrollToItem(this.state.selection)); + } + } + + scrollToItem(term) { + const content = $(ReactDOM.findDOMNode(this.refs.popover)).find('.popover-content'); + const visibleContentHeight = content[0].clientHeight; + const actualContentHeight = content[0].scrollHeight; + + if (visibleContentHeight < actualContentHeight) { + const contentTop = content.scrollTop(); + const contentTopPadding = parseInt(content.css('padding-top'), 10); + const contentBottomPadding = parseInt(content.css('padding-top'), 10); + + const item = $(ReactDOM.findDOMNode(this.refs[term])); + const itemTop = item[0].offsetTop - parseInt(item.css('margin-top'), 10); + const itemBottom = item[0].offsetTop + item.height() + parseInt(item.css('margin-bottom'), 10); + + if (itemTop - contentTopPadding < contentTop) { + // the item is off the top of the visible space + content.scrollTop(itemTop - contentTopPadding); + } else if (itemBottom + contentTopPadding + contentBottomPadding > contentTop + visibleContentHeight) { + // the item has gone off the bottom of the visible space + content.scrollTop(itemBottom - visibleContentHeight + contentTopPadding + contentBottomPadding); + } + } + } + + renderChannelDivider(type) { + let text; + if (type === Constants.OPEN_CHANNEL) { + text = 'Public ' + Utils.getChannelTerm(type) + 's'; + } else { + text = 'Private ' + Utils.getChannelTerm(type) + 's'; + } + + return ( + <div + key={type + '-divider'} + className='search-autocomplete__divider' + > + <span>{text}</span> + </div> + ); + } + + render() { + if (this.state.items.length === 0) { + return null; + } + + const items = []; + for (let i = 0; i < this.state.items.length; i++) { + const item = this.state.items[i]; + const term = this.state.terms[i]; + const isSelection = term === this.state.selection; + + // ReactComponent names need to be upper case when used in JSX + const Component = this.state.components[i]; + + // temporary hack to add dividers between public and private channels in the search suggestion list + if (i === 0 || item.type !== this.state.items[i - 1].type) { + if (item.type === Constants.OPEN_CHANNEL) { + items.push(this.renderChannelDivider(Constants.OPEN_CHANNEL)); + } else if (item.type === Constants.PRIVATE_CHANNEL) { + items.push(this.renderChannelDivider(Constants.PRIVATE_CHANNEL)); + } + } + + items.push( + <Component + key={term} + ref={term} + item={item} + isSelection={isSelection} + onClick={this.handleItemClick.bind(this, term)} + /> + ); + } + + return ( + <ReactBootstrap.Popover + ref='popover' + id='search-autocomplete__popover' + className='search-help-popover autocomplete visible' + placement='bottom' + > + {items} + </ReactBootstrap.Popover> + ); + } +} + +SuggestionList.propTypes = { + suggestionId: React.PropTypes.string.isRequired +};
\ No newline at end of file |