From 1ecb209712844d80dac118f6f5c656223cf6d6e1 Mon Sep 17 00:00:00 2001 From: Dmitry Samuylov Date: Tue, 28 Aug 2018 18:01:11 -0400 Subject: [PATCH] Feature/search after before on (#2028) * initial partial implementation of search date flags UI (on: before: after:) * Make the calendar show correctly * dismiss keyboard when showing the date suggestion since this type of input will primarily be entered through the calendar and later edited if needed * fixed code style errors * updates as per pull request comments * fixed eslint errors * changes as per pull request comments, removing unnecessary code * added handling for hiding the date filter feature if the server version doesn't support it * fixed eslint errors * removed unnecessary property defaults and server version checks, as per PR suggestions * this default is needed for the initial load of the component * changed the way we capture the flag from the search string, this handles scenarios where multiple flags might be present in the search string that are handled by the same suggestion component as is the case here with date based flags * need property declared to pass jslint rules * eslint style fix, moved regex out into constants * removed unused code and references * updated as per pull request feedback * updated to latest redux version * changed to new search posts api that supports passing in of time zone utc offset to support date based search flags * updated to the upcoming release version in preparation for merging to master * Properly memoize DateSuggestion mapStateToProps * Remove moment-timezone --- app/components/autocomplete/autocomplete.js | 18 ++- .../date_suggestion/date_suggestion.js | 103 ++++++++++++++++++ .../autocomplete/date_suggestion/index.js | 27 +++++ app/constants/autocomplete.js | 4 + app/screens/search/index.js | 9 +- app/screens/search/search.js | 70 +++++++++--- app/selectors/autocomplete.js | 18 +++ app/utils/timezone.js | 5 + assets/base/i18n/en.json | 6 + package-lock.json | 70 +++++++++++- package.json | 4 +- 11 files changed, 310 insertions(+), 24 deletions(-) create mode 100644 app/components/autocomplete/date_suggestion/date_suggestion.js create mode 100644 app/components/autocomplete/date_suggestion/index.js diff --git a/app/components/autocomplete/autocomplete.js b/app/components/autocomplete/autocomplete.js index 7daf48e53..24776a66e 100644 --- a/app/components/autocomplete/autocomplete.js +++ b/app/components/autocomplete/autocomplete.js @@ -16,6 +16,7 @@ import AtMention from './at_mention'; import ChannelMention from './channel_mention'; import EmojiSuggestion from './emoji_suggestion'; import SlashSuggestion from './slash_suggestion'; +import DateSuggestion from './date_suggestion'; export default class Autocomplete extends PureComponent { static propTypes = { @@ -26,11 +27,13 @@ export default class Autocomplete extends PureComponent { isSearch: PropTypes.bool, theme: PropTypes.object.isRequired, value: PropTypes.string, + enableDateSuggestion: PropTypes.bool.isRequired, }; static defaultProps = { isSearch: false, cursorPosition: 0, + enableDateSuggestion: false, }; state = { @@ -38,6 +41,7 @@ export default class Autocomplete extends PureComponent { channelMentionCount: 0, emojiCount: 0, commandCount: 0, + dateCount: 0, keyboardOffset: 0, }; @@ -57,6 +61,10 @@ export default class Autocomplete extends PureComponent { this.setState({commandCount}); }; + handleIsDateFilterChange = (dateCount) => { + this.setState({dateCount}); + }; + componentWillMount() { this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this.keyboardDidShow); this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this.keyboardDidHide); @@ -97,8 +105,8 @@ export default class Autocomplete extends PureComponent { } // We always need to render something, but we only draw the borders when we have results to show - const {atMentionCount, channelMentionCount, emojiCount, commandCount} = this.state; - if (atMentionCount + channelMentionCount + emojiCount + commandCount > 0) { + const {atMentionCount, channelMentionCount, emojiCount, commandCount, dateCount} = this.state; + if (atMentionCount + channelMentionCount + emojiCount + commandCount + dateCount > 0) { if (this.props.isSearch) { wrapperStyle.push(style.bordersSearch); } else { @@ -128,6 +136,12 @@ export default class Autocomplete extends PureComponent { onResultCountChange={this.handleCommandCountChange} {...this.props} /> + {(this.props.isSearch && this.props.enableDateSuggestion) && + + } ); diff --git a/app/components/autocomplete/date_suggestion/date_suggestion.js b/app/components/autocomplete/date_suggestion/date_suggestion.js new file mode 100644 index 000000000..acd8101fa --- /dev/null +++ b/app/components/autocomplete/date_suggestion/date_suggestion.js @@ -0,0 +1,103 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import {Keyboard, StyleSheet} from 'react-native'; +import PropTypes from 'prop-types'; +import {CalendarList} from 'react-native-calendars'; + +import {DATE_MENTION_SEARCH_REGEX, ALL_SEARCH_FLAGS_REGEX} from 'app/constants/autocomplete'; + +export default class DateSuggestion extends PureComponent { + static propTypes = { + cursorPosition: PropTypes.number.isRequired, + listHeight: PropTypes.number, + matchTerm: PropTypes.string, + onChangeText: PropTypes.func.isRequired, + onResultCountChange: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + value: PropTypes.string, + enableDateSuggestion: PropTypes.bool.isRequired, + }; + + static defaultProps = { + value: '', + }; + + constructor(props) { + super(props); + + this.state = { + mentionComplete: false, + sections: [], + }; + } + + componentWillReceiveProps(nextProps) { + const {matchTerm} = nextProps; + + if ((matchTerm !== this.props.matchTerm && matchTerm === null) || this.state.mentionComplete) { + // if the term changes but is null or the mention has been completed we render this component as null + this.setState({ + mentionComplete: false, + sections: [], + }); + + this.props.onResultCountChange(0); + } + } + + completeMention = (day) => { + const mention = day.dateString; + const {cursorPosition, onChangeText, value} = this.props; + const mentionPart = value.substring(0, cursorPosition); + const flags = mentionPart.match(ALL_SEARCH_FLAGS_REGEX); + const currentFlag = flags[flags.length - 1]; + let completedDraft = mentionPart.replace(DATE_MENTION_SEARCH_REGEX, `${currentFlag} ${mention} `); + + if (value.length > cursorPosition) { + completedDraft += value.substring(cursorPosition); + } + + onChangeText(completedDraft, true); + this.props.onResultCountChange(1); + this.setState({mentionComplete: true}); + }; + + render() { + const {mentionComplete} = this.state; + const {matchTerm, enableDateSuggestion} = this.props; + + if (matchTerm === null || mentionComplete || !enableDateSuggestion) { + // If we are not in an active state or the mention has been completed return null so nothing is rendered + // other components are not blocked. + return null; + } + + const currentDate = (new Date()).toDateString(); + + Keyboard.dismiss(); + + return ( + + ); + } +} + +const styles = StyleSheet.create({ + calList: { + height: 1700, + }, +}); diff --git a/app/components/autocomplete/date_suggestion/index.js b/app/components/autocomplete/date_suggestion/index.js new file mode 100644 index 000000000..1e3a28453 --- /dev/null +++ b/app/components/autocomplete/date_suggestion/index.js @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {makeGetMatchTermForDateMention} from 'app/selectors/autocomplete'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import DateSuggestion from './date_suggestion'; + +function makeMapStateToProps() { + const getMatchTermForDateMention = makeGetMatchTermForDateMention(); + + return (state, ownProps) => { + const {cursorPosition, value} = ownProps; + + const newValue = value.substring(0, cursorPosition); + const matchTerm = getMatchTermForDateMention(newValue); + + return { + matchTerm, + theme: getTheme(state), + }; + }; +} + +export default connect(makeMapStateToProps)(DateSuggestion); diff --git a/app/constants/autocomplete.js b/app/constants/autocomplete.js index 220a269cb..4f9c13f0c 100644 --- a/app/constants/autocomplete.js +++ b/app/constants/autocomplete.js @@ -8,3 +8,7 @@ export const AT_MENTION_SEARCH_REGEX = /\bfrom:\s*(\S*)$/i; export const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i; export const CHANNEL_MENTION_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i; + +export const DATE_MENTION_SEARCH_REGEX = /\b(?:on|before|after):\s*(\S*)$/i; + +export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g; \ No newline at end of file diff --git a/app/screens/search/index.js b/app/screens/search/index.js index 46e4727b9..fd0a060b1 100644 --- a/app/screens/search/index.js +++ b/app/screens/search/index.js @@ -5,10 +5,11 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {selectFocusedPostId, selectPost} from 'mattermost-redux/actions/posts'; -import {clearSearch, removeSearchTerms, searchPosts} from 'mattermost-redux/actions/search'; +import {clearSearch, removeSearchTerms, searchPostsWithParams} from 'mattermost-redux/actions/search'; import {getCurrentChannelId, filterPostIds} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {isMinimumServerVersion} from 'mattermost-redux/utils/helpers'; import {loadChannelsByTeamName, loadThreadIfNecessary} from 'app/actions/views/channel'; import {isLandscape} from 'app/selectors/device'; @@ -29,6 +30,9 @@ function makeMapStateToProps() { const {recent} = state.entities.search; const {searchPosts: searchRequest} = state.requests.search; + const serverVersion = state.entities.general.serverVersion; + const enableDateSuggestion = isMinimumServerVersion(serverVersion, 5, 3); + return { currentTeamId, currentChannelId, @@ -38,6 +42,7 @@ function makeMapStateToProps() { recent: recent[currentTeamId], searchingStatus: searchRequest.status, theme: getTheme(state), + enableDateSuggestion, }; }; } @@ -51,7 +56,7 @@ function mapDispatchToProps(dispatch) { loadThreadIfNecessary, removeSearchTerms, selectFocusedPostId, - searchPosts, + searchPostsWithParams, selectPost, }, dispatch), }; diff --git a/app/screens/search/search.js b/app/screens/search/search.js index c398e120a..58929854f 100644 --- a/app/screens/search/search.js +++ b/app/screens/search/search.js @@ -31,6 +31,7 @@ import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; import mattermostManaged from 'app/mattermost_managed'; import {preventDoubleTap} from 'app/utils/tap'; +import {getDeviceUtcOffset} from 'app/utils/timezone'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import ChannelDisplayName from './channel_display_name'; @@ -51,7 +52,7 @@ export default class Search extends PureComponent { loadChannelsByTeamName: PropTypes.func.isRequired, loadThreadIfNecessary: PropTypes.func.isRequired, removeSearchTerms: PropTypes.func.isRequired, - searchPosts: PropTypes.func.isRequired, + searchPostsWithParams: PropTypes.func.isRequired, selectFocusedPostId: PropTypes.func.isRequired, selectPost: PropTypes.func.isRequired, }).isRequired, @@ -63,6 +64,7 @@ export default class Search extends PureComponent { recent: PropTypes.array.isRequired, searchingStatus: PropTypes.string, theme: PropTypes.object.isRequired, + enableDateSuggestion: PropTypes.bool, }; static defaultProps = { @@ -466,7 +468,9 @@ export default class Search extends PureComponent { }, }); - actions.searchPosts(currentTeamId, terms.trim(), isOrSearch, true); + // timezone offset in seconds + const timeZoneOffset = getDeviceUtcOffset() * 60; + actions.searchPostsWithParams(currentTeamId, {terms: terms.trim(), is_or_search: isOrSearch, time_zone_offset: timeZoneOffset}, true); }; handleSearchButtonPress = preventDoubleTap((text) => { @@ -514,22 +518,53 @@ export default class Search extends PureComponent { value, } = this.state; const style = getStyleFromTheme(theme); + + const sectionsData = [{ + value: 'from:', + modifier: `from:${intl.formatMessage({id: 'mobile.search.from_modifier_title', defaultMessage: 'username'})}`, + description: intl.formatMessage({ + id: 'mobile.search.from_modifier_description', + defaultMessage: 'to find posts from specific users', + }), + }, { + value: 'in:', + modifier: `in:${intl.formatMessage({id: 'mobile.search.in_modifier_title', defaultMessage: 'channel-name'})}`, + description: intl.formatMessage({ + id: 'mobile.search.in_modifier_description', + defaultMessage: 'to find posts in specific channels', + }), + }]; + + // if search by date filters supported + if (this.props.enableDateSuggestion) { + sectionsData.push({ + value: 'on:', + modifier: 'on: YYYY-MM-DD', + description: intl.formatMessage({ + id: 'mobile.search.on_modifier_description', + defaultMessage: 'to find posts on a specific date', + }), + }); + sectionsData.push({ + value: 'after:', + modifier: 'after: YYYY-MM-DD', + description: intl.formatMessage({ + id: 'mobile.search.after_modifier_description', + defaultMessage: 'to find posts after a specific date', + }), + }); + sectionsData.push({ + value: 'before:', + modifier: 'before: YYYY-MM-DD', + description: intl.formatMessage({ + id: 'mobile.search.before_modifier_description', + defaultMessage: 'to find posts before a specific date', + }), + }); + } + const sections = [{ - data: [{ - value: 'from:', - modifier: `from:${intl.formatMessage({id: 'mobile.search.from_modifier_title', defaultMessage: 'username'})}`, - description: intl.formatMessage({ - id: 'mobile.search.from_modifier_description', - defaultMessage: 'to find posts from specific users', - }), - }, { - value: 'in:', - modifier: `in:${intl.formatMessage({id: 'mobile.search.in_modifier_title', defaultMessage: 'channel-name'})}`, - description: intl.formatMessage({ - id: 'mobile.search.in_modifier_description', - defaultMessage: 'to find posts in specific channels', - }), - }], + data: sectionsData, key: 'modifiers', title: '', renderItem: this.renderModifiers, @@ -651,6 +686,7 @@ export default class Search extends PureComponent { onChangeText={this.handleTextChanged} isSearch={true} value={value} + enableDateSuggestion={this.props.enableDateSuggestion} /> diff --git a/app/selectors/autocomplete.js b/app/selectors/autocomplete.js index 11e9d20df..8bc92d477 100644 --- a/app/selectors/autocomplete.js +++ b/app/selectors/autocomplete.js @@ -224,3 +224,21 @@ export const filterPrivateChannels = createSelector( return channels.map((c) => c.id); } ); + +export const makeGetMatchTermForDateMention = () => { + let lastMatchTerm = null; + let lastValue; + return (value) => { + if (value !== lastValue) { + const regex = Autocomplete.DATE_MENTION_SEARCH_REGEX; + const match = value.match(regex); + lastValue = value; + if (match) { + lastMatchTerm = match[1]; + } else { + lastMatchTerm = null; + } + } + return lastMatchTerm; + }; +}; diff --git a/app/utils/timezone.js b/app/utils/timezone.js index df8da9520..8498efecf 100644 --- a/app/utils/timezone.js +++ b/app/utils/timezone.js @@ -9,6 +9,11 @@ export function getDeviceTimezone() { return DeviceInfo.getTimezone(); } +export function getDeviceUtcOffset() { + const reverseOffsetInMinutes = new Date().getTimezoneOffset(); + return -reverseOffsetInMinutes; +} + export function isTimezoneEnabled(state) { const {config} = state.entities.general; const serverVersion = state.entities.general.serverVersion; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 9312a9d73..9dd8a4ee3 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -2598,6 +2598,12 @@ "mobile.search.from_modifier_title": "username", "mobile.search.in_modifier_description": "to find posts in specific channels", "mobile.search.in_modifier_title": "channel-name", + "mobile.search.on_modifier_description": "to find posts on a specific date", + "mobile.search.on_modifier_title": "YYYY-MM-DD", + "mobile.search.after_modifier_description": "to find posts after a specific date", + "mobile.search.after_modifier_title": "YYYY-MM-DD", + "mobile.search.before_modifier_description": "to find posts before a specific date", + "mobile.search.before_modifier_title": "YYYY-MM-DD", "mobile.search.jump": "Jump to recent messages", "mobile.search.no_results": "No Results Found", "mobile.search.recent_title": "Recent Searches", diff --git a/package-lock.json b/package-lock.json index bf43cb80b..fa5f64bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2750,6 +2750,46 @@ "integrity": "sha512-NOLEgsT6UiDTjnWG5Hd2Mg25LRyz/oe8ql3wbjzgSFeRzRROhPmtlsvIrei4B46UjERF0td9SZ1ZXPLOdcrBHg==", "dev": true }, + "@types/prop-types": { + "version": "15.5.5", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.5.tgz", + "integrity": "sha512-mOrlCEdwX3seT3n0AXNt4KNPAZZxcsABUHwBgFXOt+nvFUXkxCAO6UBJHPrDxWEa2KDMil86355fjo8jbZ+K0Q==", + "requires": { + "@types/react": "*" + } + }, + "@types/react": { + "version": "16.4.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.4.11.tgz", + "integrity": "sha512-1DQnmwO8u8N3ucvRX2ZLDEjQ2VctkAvL/rpbm2ev4uaZA0z4ysU+I0tk+K8ZLblC6p7MCgFyF+cQlSNIPUHzeQ==", + "requires": { + "@types/prop-types": "*", + "csstype": "^2.2.0" + } + }, + "@types/react-native": { + "version": "0.56.10", + "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.56.10.tgz", + "integrity": "sha512-xbut9LMHg773sJX+0PJuGoG7+sB7VL24djp/41rrwht9Kg+3TPcqTdP4k3V6Gqi+Jpv/YbmL0Wlxdlu+vVxORA==", + "requires": { + "@types/react": "*" + } + }, + "@types/react-native-calendars": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/react-native-calendars/-/react-native-calendars-1.20.2.tgz", + "integrity": "sha512-mb7H4yRug06STg40SnqGM9c6kvAXcPuImvQjKfr8v8DVrgFpTS0RO2Ykyk+B1s3bpsK3eop8ExLLKmMM4bz5fA==", + "requires": { + "@types/react": "*", + "@types/react-native": "*", + "@types/xdate": "*" + } + }, + "@types/xdate": { + "version": "0.8.29", + "resolved": "https://registry.npmjs.org/@types/xdate/-/xdate-0.8.29.tgz", + "integrity": "sha512-vhjumeX678c1aA5TDdxeLdWjqazQ4qDvgK57pRr5l3Y0H5+ZZYckNtLkJiW4tJ3YVZq43ZFZFVResfAM4UMvTw==" + }, "abab": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", @@ -5413,6 +5453,11 @@ "cssom": "0.3.x" } }, + "csstype": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.5.6.tgz", + "integrity": "sha512-tKPyhy0FmfYD2KQYXD5GzkvAYLYj96cMLXr648CKGd3wBe0QqoPipImjGiLze9c8leJK8J3n7ap90tpk3E6HGQ==" + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -9993,6 +10038,11 @@ "resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz", "integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4=" }, + "lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=" + }, "lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -10128,8 +10178,8 @@ } }, "mattermost-redux": { - "version": "github:mattermost/mattermost-redux#11902b0d530e5b3303e7fffe74ef8102d25a1893", - "from": "github:mattermost/mattermost-redux#11902b0d530e5b3303e7fffe74ef8102d25a1893", + "version": "github:mattermost/mattermost-redux#45ae4849c2810b56cb5a420fb7d49d2621145969", + "from": "github:mattermost/mattermost-redux#45ae4849c2810b56cb5a420fb7d49d2621145969", "requires": { "deep-equal": "1.0.1", "eslint-plugin-header": "1.2.0", @@ -14654,6 +14704,17 @@ "prop-types": "^15.5.10" } }, + "react-native-calendars": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/react-native-calendars/-/react-native-calendars-1.20.0.tgz", + "integrity": "sha512-VlRoDcnEAWYE1JBPBh/Bie6baLQCmtuOGhw7V5yk09Y4j7Hy8BtuZIHh2+LU/TFYso+wEHJAFdj6D0QFttDOlg==", + "requires": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "prop-types": "^15.5.10", + "xdate": "^0.8.0" + } + }, "react-native-circular-progress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/react-native-circular-progress/-/react-native-circular-progress-0.2.0.tgz", @@ -18284,6 +18345,11 @@ } } }, + "xdate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/xdate/-/xdate-0.8.2.tgz", + "integrity": "sha1-17AzwASF0CaVuvAET06s2j/JYaM=" + }, "xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index d68688d6e..f7b8ff064 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "license": "Apache 2.0", "private": true, "dependencies": { + "@types/react-native-calendars": "1.20.2", "analytics-react-native": "1.2.0", "commonmark": "github:mattermost/commonmark.js#e5c34706cafa2924370772ff47937b833648a6de", "commonmark-react-renderer": "mattermost/commonmark-react-renderer#86fa63f898802953842526c2030f3b63c5d1ae7a", @@ -15,7 +16,7 @@ "intl": "1.2.5", "jail-monkey": "1.0.0", "jsc-android": "216113.0.3", - "mattermost-redux": "github:mattermost/mattermost-redux#11902b0d530e5b3303e7fffe74ef8102d25a1893", + "mattermost-redux": "github:mattermost/mattermost-redux#45ae4849c2810b56cb5a420fb7d49d2621145969", "mime-db": "1.33.0", "prop-types": "15.6.1", "react": "16.3.2", @@ -24,6 +25,7 @@ "react-native-animatable": "1.2.4", "react-native-bottom-sheet": "1.0.3", "react-native-button": "2.3.0", + "react-native-calendars": "1.20.0", "react-native-circular-progress": "0.2.0", "react-native-cookies": "3.2.0", "react-native-device-info": "enahum/react-native-device-info.git#a7bb3cff1086780b2c791a3e43e5b826fdf3ab11",