From ac762f2277402c06f209ca3c8e0416d16916e991 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Tue, 24 Nov 2015 12:30:12 -0500 Subject: Replaced SearchAutocomplete with new suggestion components --- web/react/components/search_autocomplete.jsx | 341 ----------------------- web/react/components/search_bar.jsx | 45 +-- web/react/components/search_channel_provider.jsx | 71 +++++ web/react/components/search_user_provider.jsx | 64 +++++ web/react/components/suggestion_box.jsx | 170 +++++++++++ web/react/components/suggestion_list.jsx | 157 +++++++++++ web/react/stores/suggestion_store.jsx | 246 ++++++++++++++++ web/react/utils/constants.jsx | 9 +- web/sass-files/sass/partials/_popover.scss | 2 + 9 files changed, 726 insertions(+), 379 deletions(-) delete mode 100644 web/react/components/search_autocomplete.jsx create mode 100644 web/react/components/search_channel_provider.jsx create mode 100644 web/react/components/search_user_provider.jsx create mode 100644 web/react/components/suggestion_box.jsx create mode 100644 web/react/components/suggestion_list.jsx create mode 100644 web/react/stores/suggestion_store.jsx (limited to 'web') 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 ( -
- {channel.name} -
- ); - } - - renderUserSuggestion(user) { - let className = 'search-autocomplete__item'; - if (user.username === this.getSelection()) { - className += ' selected'; - } - - return ( -
- channel.type === Constants.OPEN_CHANNEL); - if (publicChannels.length > 0) { - suggestions.push( -
- {'Public ' + Utils.getChannelTerm(Constants.OPEN_CHANNEL) + 's'} -
- ); - 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( -
- {'Private ' + Utils.getChannelTerm(Constants.PRIVATE_CHANNEL) + 's'} -
- ); - suggestions = suggestions.concat(privateChannels.map(this.renderChannelSuggestion)); - } - } else if (this.state.mode === 'users') { - suggestions = this.state.suggestions.map(this.renderUserSuggestion); - } - - return ( - - {suggestions} - - ); - } -} - -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..19ff8386f 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,13 +19,11 @@ 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; @@ -77,18 +77,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 +121,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 +154,17 @@ export default class SearchBar extends React.Component { autoComplete='off' > - {isSearching} - + {item.name} +
+ ); + } +} + +SearchChannelSuggestion.propTypes = { + item: React.PropTypes.object.isRequired, + isSelection: React.PropTypes.bool, + onClick: React.PropTypes.func +}; + +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); + } + } +} + +export default new SearchChannelProvider(); \ No newline at end of file diff --git a/web/react/components/search_user_provider.jsx b/web/react/components/search_user_provider.jsx new file mode 100644 index 000000000..6440e77c2 --- /dev/null +++ b/web/react/components/search_user_provider.jsx @@ -0,0 +1,64 @@ +// 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 ( +
+ 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 ( +
+ + +
+ ); + } +} + +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 ( +
+ {text} +
+ ); + } + + 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( + + ); + } + + return ( + + {items} + + ); + } +} + +SuggestionList.propTypes = { + suggestionId: React.PropTypes.string.isRequired +}; \ No newline at end of file diff --git a/web/react/stores/suggestion_store.jsx b/web/react/stores/suggestion_store.jsx new file mode 100644 index 000000000..016929501 --- /dev/null +++ b/web/react/stores/suggestion_store.jsx @@ -0,0 +1,246 @@ +// 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 EventEmitter from 'events'; + +const ActionTypes = Constants.ActionTypes; + +const COMPLETE_WORD_EVENT = 'complete_word'; +const PRETEXT_CHANGED_EVENT = 'pretext_changed'; +const SUGGESTIONS_CHANGED_EVENT = 'suggestions_changed'; + +class SuggestionStore extends EventEmitter { + constructor() { + super(); + + this.addSuggestionsChangedListener = this.addSuggestionsChangedListener.bind(this); + this.removeSuggestionsChangedListener = this.removeSuggestionsChangedListener.bind(this); + this.emitSuggestionsChanged = this.emitSuggestionsChanged.bind(this); + + this.addPretextChangedListener = this.addPretextChangedListener.bind(this); + this.removePretextChangedListener = this.removePretextChangedListener.bind(this); + this.emitPretextChanged = this.emitPretextChanged.bind(this); + + this.addCompleteWordListener = this.addCompleteWordListener.bind(this); + this.removeCompleteWordListener = this.removeCompleteWordListener.bind(this); + this.emitCompleteWord = this.emitCompleteWord.bind(this); + + this.handleEventPayload = this.handleEventPayload.bind(this); + this.dispatchToken = AppDispatcher.register(this.handleEventPayload); + + // this.suggestions stores the state of all SuggestionBoxes by mapping their unique identifier to an + // object with the following fields: + // pretext: the text before the cursor + // matchedPretext: the text before the cursor that will be replaced if an autocomplete term is selected + // terms: a list of strings which the previously typed text may be replaced by + // items: a list of objects backing the terms which may be used in rendering + // components: a list of react components that can be used to render their corresponding item + // selection: the term currently selected by the keyboard + this.suggestions = new Map(); + } + + addSuggestionsChangedListener(id, callback) { + this.on(SUGGESTIONS_CHANGED_EVENT + id, callback); + } + removeSuggestionsChangedListener(id, callback) { + this.removeListener(SUGGESTIONS_CHANGED_EVENT + id, callback); + } + emitSuggestionsChanged(id) { + this.emit(SUGGESTIONS_CHANGED_EVENT + id); + } + + addPretextChangedListener(id, callback) { + this.on(PRETEXT_CHANGED_EVENT + id, callback); + } + removePretextChangedListener(id, callback) { + this.removeListener(PRETEXT_CHANGED_EVENT + id, callback); + } + emitPretextChanged(id, pretext) { + this.emit(PRETEXT_CHANGED_EVENT + id, pretext); + } + + addCompleteWordListener(id, callback) { + this.on(COMPLETE_WORD_EVENT + id, callback); + } + removeCompleteWordListener(id, callback) { + this.removeListener(COMPLETE_WORD_EVENT + id, callback); + } + emitCompleteWord(id, term) { + this.emit(COMPLETE_WORD_EVENT + id, term); + } + + registerSuggestionBox(id) { + this.suggestions.set(id, { + pretext: '', + matchedPretext: '', + terms: [], + items: [], + components: [], + selection: '' + }); + } + + unregisterSuggestionBox(id) { + this.suggestions.delete(id); + } + + clearSuggestions(id) { + const suggestion = this.suggestions.get(id); + + suggestion.matchedPretext = ''; + suggestion.terms = []; + suggestion.items = []; + suggestion.components = []; + suggestion.selection = ''; + } + + hasSuggestions(id) { + return this.suggestions.get(id).terms.length > 0; + } + + setPretext(id, pretext) { + const suggestion = this.suggestions.get(id); + + suggestion.pretext = pretext; + } + + setMatchedPretext(id, matchedPretext) { + const suggestion = this.suggestions.get(id); + + suggestion.matchedPretext = matchedPretext; + } + + addSuggestion(id, term, item, component) { + const suggestion = this.suggestions.get(id); + + suggestion.terms.push(term); + suggestion.items.push(item); + suggestion.components.push(component); + } + + addSuggestions(id, terms, items, component) { + const suggestion = this.suggestions.get(id); + + suggestion.terms.push(...terms); + suggestion.items.push(...items); + + for (let i = 0; i < terms.length; i++) { + suggestion.components.push(component); + } + } + + // make sure that if suggestions exist, then one of them is selected. return true if the selection changes. + ensureSelectionExists(id) { + const suggestion = this.suggestions.get(id); + + if (suggestion.terms.length > 0) { + // if the current selection is no longer in the map, select the first term in the list + if (!suggestion.selection || suggestion.terms.indexOf(suggestion.selection) === -1) { + suggestion.selection = suggestion.terms[0]; + + return true; + } + } else if (suggestion.selection) { + suggestion.selection = ''; + + return true; + } + + return false; + } + + getPretext(id) { + return this.suggestions.get(id).pretext; + } + + getMatchedPretext(id) { + return this.suggestions.get(id).matchedPretext; + } + + getItems(id) { + return this.suggestions.get(id).items; + } + + getTerms(id) { + return this.suggestions.get(id).terms; + } + + getComponents(id) { + return this.suggestions.get(id).components; + } + + getSelection(id) { + return this.suggestions.get(id).selection; + } + + selectNext(id) { + this.setSelectionByDelta(id, 1); + } + + selectPrevious(id) { + this.setSelectionByDelta(id, -1); + } + + setSelectionByDelta(id, delta) { + const suggestion = this.suggestions.get(id); + + let selectionIndex = suggestion.terms.indexOf(suggestion.selection); + + if (selectionIndex === -1) { + // this should never happen since selection should always be in terms + throw new Error('selection is not in terms'); + } + + selectionIndex += delta; + + if (selectionIndex < 0) { + selectionIndex = 0; + } else if (selectionIndex > suggestion.terms.length - 1) { + selectionIndex = suggestion.terms.length - 1; + } + + suggestion.selection = suggestion.terms[selectionIndex]; + } + + handleEventPayload(payload) { + const {type, id, ...other} = payload.action; // eslint-disable-line no-redeclare + + switch (type) { + case ActionTypes.SUGGESTION_PRETEXT_CHANGED: + this.clearSuggestions(id); + + this.setPretext(id, other.pretext); + this.emitPretextChanged(id, other.pretext); + + this.ensureSelectionExists(id); + this.emitSuggestionsChanged(id); + break; + case ActionTypes.SUGGESTION_RECEIVED_SUGGESTIONS: + this.setMatchedPretext(id, other.matchedPretext); + this.addSuggestions(id, other.terms, other.items, other.componentType); + + this.ensureSelectionExists(id); + this.emitSuggestionsChanged(id); + break; + case ActionTypes.SUGGESTION_SELECT_NEXT: + this.selectNext(id); + this.emitSuggestionsChanged(id); + break; + case ActionTypes.SUGGESTION_SELECT_PREVIOUS: + this.selectPrevious(id); + this.emitSuggestionsChanged(id); + break; + case ActionTypes.SUGGESTION_COMPLETE_WORD: + this.emitCompleteWord(id, other.term || this.getSelection(id), this.getMatchedPretext(id)); + + this.setPretext(id, ''); + this.clearSuggestions(id); + this.emitSuggestionsChanged(id); + break; + } + } +} + +export default new SuggestionStore(); \ No newline at end of file diff --git a/web/react/utils/constants.jsx b/web/react/utils/constants.jsx index 99bd2453c..7d9532867 100644 --- a/web/react/utils/constants.jsx +++ b/web/react/utils/constants.jsx @@ -24,6 +24,7 @@ export default { RECIEVED_POST: null, RECIEVED_EDIT_POST: null, RECIEVED_SEARCH: null, + RECIEVED_SEARCH_TERM: null, RECIEVED_POST_SELECTED: null, RECIEVED_MENTION_DATA: null, RECIEVED_ADD_MENTION: null, @@ -50,7 +51,13 @@ export default { TOGGLE_INVITE_MEMBER_MODAL: null, TOGGLE_DELETE_POST_MODAL: null, TOGGLE_GET_TEAM_INVITE_LINK_MODAL: null, - TOGGLE_REGISTER_APP_MODAL: null + TOGGLE_REGISTER_APP_MODAL: null, + + SUGGESTION_PRETEXT_CHANGED: null, + SUGGESTION_RECEIVED_SUGGESTIONS: null, + SUGGESTION_COMPLETE_WORD: null, + SUGGESTION_SELECT_NEXT: null, + SUGGESTION_SELECT_PREVIOUS: null }), PayloadSources: keyMirror({ diff --git a/web/sass-files/sass/partials/_popover.scss b/web/sass-files/sass/partials/_popover.scss index 7d98935d5..a48bfb22d 100644 --- a/web/sass-files/sass/partials/_popover.scss +++ b/web/sass-files/sass/partials/_popover.scss @@ -94,6 +94,8 @@ } .popover-content { + max-height: 500px; + overflow: auto; padding: 3px 13px; } -- cgit v1.2.3-1-g7c22 From bedb1a751f45e39c5747f2c6280dae958748e2f0 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Thu, 26 Nov 2015 15:44:50 -0500 Subject: Added -1 as an ignored magic number --- web/react/.eslintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'web') diff --git a/web/react/.eslintrc b/web/react/.eslintrc index 935bb638a..baaf7eaa5 100644 --- a/web/react/.eslintrc +++ b/web/react/.eslintrc @@ -47,7 +47,7 @@ "no-irregular-whitespace": 2, "no-unexpected-multiline": 2, "no-unreachable": 2, - "no-magic-numbers": [1, { "enforceConst": true, "detectObjects": true } ], + "no-magic-numbers": [1, { "ignore": [-1, 0, 1, 2], "enforceConst": true, "detectObjects": true } ], "valid-typeof": 2, "block-scoped-var": 2, -- cgit v1.2.3-1-g7c22 From 956d460b108f278b6cfbcb728241b89b992b2a55 Mon Sep 17 00:00:00 2001 From: hmhealey Date: Mon, 30 Nov 2015 14:12:09 -0500 Subject: Changed SuggestionProviders to be regular objects and not singletons --- web/react/components/search_bar.jsx | 4 +++- web/react/components/search_channel_provider.jsx | 4 +--- web/react/components/search_user_provider.jsx | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) (limited to 'web') diff --git a/web/react/components/search_bar.jsx b/web/react/components/search_bar.jsx index 19ff8386f..0ea5c451a 100644 --- a/web/react/components/search_bar.jsx +++ b/web/react/components/search_bar.jsx @@ -28,6 +28,8 @@ export default class SearchBar extends React.Component { const state = this.getSearchTermStateFromStores(); state.focused = false; this.state = state; + + this.suggestionProviders = [new SearchChannelProvider(), new SearchUserProvider()]; } getSearchTermStateFromStores() { var term = SearchStore.getSearchTerm() || ''; @@ -162,7 +164,7 @@ export default class SearchBar extends React.Component { onFocus={this.handleUserFocus} onBlur={this.handleUserBlur} onUserInput={this.handleUserInput} - providers={[SearchChannelProvider, SearchUserProvider]} + providers={this.suggestionProviders} /> {isSearching}