diff --git a/assets/images/calendar.svg b/assets/images/calendar.svg new file mode 100644 index 000000000000..18885029a7c8 --- /dev/null +++ b/assets/images/calendar.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/package.json b/package.json index b43d2f834751..f1d6a0c5d246 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "ios-build": "fastlane ios build", "android-build": "fastlane android build", "android-build-e2e": "bundle exec fastlane android build_e2e", - "test": "jest", + "test": "TZ=utc jest", "lint": "eslint . --max-warnings=0", "lint-watch": "npx eslint-watch --watch --changed", "shellcheck": "./scripts/shellCheck.sh", diff --git a/src/CONST.js b/src/CONST.js index 80f8ed3ccd59..fb4a95819909 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -8,6 +8,7 @@ const USE_EXPENSIFY_URL = 'https://use.expensify.com'; const PLATFORM_OS_MACOS = 'Mac OS'; const ANDROID_PACKAGE_NAME = 'com.expensify.chat'; const USA_COUNTRY_NAME = 'United States'; +const CURRENT_YEAR = new Date().getFullYear(); const CONST = { ANDROID_PACKAGE_NAME, @@ -46,6 +47,23 @@ const CONST = { RESERVED_FIRST_NAMES: ['Expensify', 'Concierge'], }, + CALENDAR_PICKER: { + // Numbers were arbitrarily picked. + MIN_YEAR: CURRENT_YEAR - 100, + MAX_YEAR: CURRENT_YEAR + 100, + }, + + DATE_BIRTH: { + MIN_AGE: 5, + MAX_AGE: 150, + }, + + // This is used to enable a rotation/transform style to any component. + DIRECTION: { + LEFT: 'left', + RIGHT: 'right', + }, + // Sizes needed for report empty state background image handling EMPTY_STATE_BACKGROUND: { SMALL_SCREEN: { diff --git a/src/ROUTES.js b/src/ROUTES.js index 0b70b92c1d6f..3bc2c32db971 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -56,6 +56,8 @@ export default { REPORT, REPORT_WITH_ID: 'r/:reportID', getReportRoute: reportID => `r/${reportID}`, + SELECT_YEAR: 'select-year', + getYearSelectionRoute: (minYear, maxYear, currYear, backTo) => `select-year?min=${minYear}&max=${maxYear}&year=${currYear}&backTo=${backTo}`, /** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */ CONCIERGE: 'concierge', diff --git a/src/components/CalendarPicker/ArrowIcon.js b/src/components/CalendarPicker/ArrowIcon.js new file mode 100644 index 000000000000..239e55d2e904 --- /dev/null +++ b/src/components/CalendarPicker/ArrowIcon.js @@ -0,0 +1,38 @@ +import React from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import styles from '../../styles/styles'; +import * as Expensicons from '../Icon/Expensicons'; +import * as StyleUtils from '../../styles/StyleUtils'; +import Icon from '../Icon'; +import CONST from '../../CONST'; + +const propTypes = { + /** Specifies if the arrow icon should be disabled or not. */ + disabled: PropTypes.bool, + + /** Specifies direction of icon */ + direction: PropTypes.oneOf([CONST.DIRECTION.LEFT, CONST.DIRECTION.RIGHT]), +}; + +const defaultProps = { + disabled: false, + direction: CONST.DIRECTION.RIGHT, +}; + +const ArrowIcon = props => ( + + + +); + +ArrowIcon.displayName = 'ArrowIcon'; +ArrowIcon.propTypes = propTypes; +ArrowIcon.defaultProps = defaultProps; + +export default ArrowIcon; diff --git a/src/components/CalendarPicker/calendarPickerPropTypes.js b/src/components/CalendarPicker/calendarPickerPropTypes.js new file mode 100644 index 000000000000..9efb999ebdb8 --- /dev/null +++ b/src/components/CalendarPicker/calendarPickerPropTypes.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import moment from 'moment'; +import CONST from '../../CONST'; + +const propTypes = { + /** An initial value of date */ + value: PropTypes.objectOf(Date), + + /** A minimum date (oldest) allowed to select */ + minDate: PropTypes.objectOf(Date), + + /** A maximum date (earliest) allowed to select */ + maxDate: PropTypes.objectOf(Date), + + /** Default year to be set in the calendar picker. Used with navigation to set the correct year after going back to the view with calendar */ + selectedYear: PropTypes.string, + + /** A function that is called when the date changed inside the calendar component */ + onChanged: PropTypes.func, + + /** A function called when the date is selected */ + onSelected: PropTypes.func, +}; + +const defaultProps = { + value: new Date(), + minDate: moment().year(CONST.CALENDAR_PICKER.MIN_YEAR).toDate(), + maxDate: moment().year(CONST.CALENDAR_PICKER.MAX_YEAR).toDate(), + selectedYear: null, + onChanged: () => {}, + onSelected: () => {}, +}; + +export {propTypes, defaultProps}; diff --git a/src/components/CalendarPicker/generateMonthMatrix.js b/src/components/CalendarPicker/generateMonthMatrix.js new file mode 100644 index 000000000000..c32316a1c881 --- /dev/null +++ b/src/components/CalendarPicker/generateMonthMatrix.js @@ -0,0 +1,60 @@ +import moment from 'moment'; + +/** + * Generates a matrix representation of a month's calendar given the year and month. + * + * @param {Number} year - The year for which to generate the month matrix. + * @param {Number} month - The month (0-indexed) for which to generate the month matrix. + * @returns {Array>} - A 2D array of the month's calendar days, with null values representing days outside the current month. + */ +export default function generateMonthMatrix(year, month) { + if (typeof year !== 'number') { + throw new TypeError('Year must be a number'); + } + if (year < 0) { + throw new Error('Year cannot be less than 0'); + } + if (typeof month !== 'number') { + throw new TypeError('Month must be a number'); + } + if (month < 0) { + throw new Error('Month cannot be less than 0'); + } + if (month > 11) { + throw new Error('Month cannot be greater than 11'); + } + + // Get the number of days in the month and the first day of the month + const daysInMonth = moment([year, month]).daysInMonth(); + const firstDay = moment([year, month, 1]).locale('en'); + + // Create a matrix to hold the calendar days + const matrix = []; + let currentWeek = []; + + // Add null values for days before the first day of the month + for (let i = 0; i < firstDay.weekday(); i++) { + currentWeek.push(null); + } + + // Add calendar days to the matrix + for (let i = 1; i <= daysInMonth; i++) { + const day = moment([year, month, i]).locale('en'); + currentWeek.push(day.date()); + + // Start a new row when the current week is full + if (day.weekday() === 6) { + matrix.push(currentWeek); + currentWeek = []; + } + } + + // Add null values for days after the last day of the month + if (currentWeek.length > 0) { + for (let i = currentWeek.length; i < 7; i++) { + currentWeek.push(null); + } + matrix.push(currentWeek); + } + return matrix; +} diff --git a/src/components/CalendarPicker/index.js b/src/components/CalendarPicker/index.js new file mode 100644 index 000000000000..a0f3e174ffa3 --- /dev/null +++ b/src/components/CalendarPicker/index.js @@ -0,0 +1,171 @@ +import _ from 'underscore'; +import React from 'react'; +import {View, TouchableOpacity, Pressable} from 'react-native'; +import moment from 'moment'; +import Text from '../Text'; +import ArrowIcon from './ArrowIcon'; +import styles from '../../styles/styles'; +import {propTypes as calendarPickerPropType, defaultProps as defaultCalendarPickerPropType} from './calendarPickerPropTypes'; +import generateMonthMatrix from './generateMonthMatrix'; +import withLocalize from '../withLocalize'; +import Navigation from '../../libs/Navigation/Navigation'; +import ROUTES from '../../ROUTES'; +import CONST from '../../CONST'; +import getButtonState from '../../libs/getButtonState'; +import * as StyleUtils from '../../styles/StyleUtils'; + +class CalendarPicker extends React.PureComponent { + constructor(props) { + super(props); + + this.monthNames = moment.localeData(props.preferredLocale).months(); + this.daysOfWeek = moment.localeData(props.preferredLocale).weekdays(); + + let currentDateView = props.value; + if (props.selectedYear) { + currentDateView = moment(currentDateView).set('year', props.selectedYear).toDate(); + } + if (props.maxDate < currentDateView) { + currentDateView = props.maxDate; + } else if (props.minDate > currentDateView) { + currentDateView = props.minDate; + } + + this.state = { + currentDateView, + }; + + this.moveToPrevMonth = this.moveToPrevMonth.bind(this); + this.moveToNextMonth = this.moveToNextMonth.bind(this); + this.onYearPickerPressed = this.onYearPickerPressed.bind(this); + this.onDayPressed = this.onDayPressed.bind(this); + } + + componentDidMount() { + if (this.props.minDate <= this.props.maxDate) { + return; + } + throw new Error('Minimum date cannot be greater than the maximum date.'); + } + + componentDidUpdate(prevProps) { + // Check if selectedYear has changed + if (this.props.selectedYear === prevProps.selectedYear) { + return; + } + + // If the selectedYear prop has changed, update the currentDateView state with the new year value + this.setState(prev => ({currentDateView: moment(prev.currentDateView).set('year', this.props.selectedYear).toDate()})); + } + + /** + * Handles the user pressing the year picker button. + * Opens the year selection screen with the minimum and maximum year range + * based on the props, the current year based on the state, and the active route. + */ + onYearPickerPressed() { + const minYear = moment(this.props.minDate).year(); + const maxYear = moment(this.props.maxDate).year(); + const currentYear = this.state.currentDateView.getFullYear(); + Navigation.navigate(ROUTES.getYearSelectionRoute(minYear, maxYear, currentYear, Navigation.getActiveRoute())); + } + + /** + * Calls the onSelected function with the selected date. + * @param {Number} day - The day of the month that was selected. + */ + onDayPressed(day) { + const selectedDate = new Date(this.state.currentDateView.getFullYear(), this.state.currentDateView.getMonth(), day); + this.props.onSelected(selectedDate); + } + + moveToPrevMonth() { + this.setState(prev => ({currentDateView: moment(prev.currentDateView).subtract(1, 'M').toDate()})); + } + + moveToNextMonth() { + this.setState(prev => ({currentDateView: moment(prev.currentDateView).add(1, 'M').toDate()})); + } + + render() { + const currentMonthView = this.state.currentDateView.getMonth(); + const currentYearView = this.state.currentDateView.getFullYear(); + const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); + const hasAvailableDatesNextMonth = moment(this.props.maxDate).endOf('month').startOf('day') > moment(this.state.currentDateView).add(1, 'M'); + const hasAvailableDatesPrevMonth = moment(this.props.minDate).startOf('day') < moment(this.state.currentDateView).subtract(1, 'M').endOf('month'); + + return ( + + + + {currentYearView} + + + + + {this.monthNames[currentMonthView]} + + + + + + + + + + + {_.map(this.daysOfWeek, (dayOfWeek => ( + + {dayOfWeek[0]} + + )))} + + {_.map(calendarDaysMatrix, week => ( + + {_.map(week, (day, index) => { + const currentDate = moment([currentYearView, currentMonthView, day]); + const isBeforeMinDate = currentDate < moment(this.props.minDate).startOf('day'); + const isAfterMaxDate = currentDate > moment(this.props.maxDate).startOf('day'); + const isDisabled = !day || isBeforeMinDate || isAfterMaxDate; + const isSelected = moment(this.props.value).isSame(moment([currentYearView, currentMonthView, day]), 'day'); + + return ( + this.onDayPressed(day)} + style={styles.calendarDayRoot} + accessibilityLabel={day ? day.toString() : undefined} + > + {({hovered, pressed}) => ( + + {day} + + )} + + ); + })} + + ))} + + ); + } +} + +CalendarPicker.propTypes = calendarPickerPropType; +CalendarPicker.defaultProps = defaultCalendarPickerPropType; + +export default withLocalize(CalendarPicker); diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index f0cdd578f8db..af53ebe3b501 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -19,12 +19,7 @@ class DatePicker extends React.Component { this.setDate = this.setDate.bind(this); this.showDatepicker = this.showDatepicker.bind(this); - /* We're using uncontrolled input otherwise it wont be possible to - * raise change events with a date value - each change will produce a date - * and make us reset the text input */ - this.defaultValue = props.defaultValue - ? moment(props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) - : ''; + this.defaultValue = props.defaultValue ? moment(props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) : ''; } componentDidMount() { diff --git a/src/components/Form.js b/src/components/Form.js index 5a45d599661b..136f0097d7ba 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -255,7 +255,15 @@ class Form extends React.Component { .value() || ''; return React.cloneElement(child, { - ref: node => this.inputRefs[inputID] = node, + ref: (node) => { + this.inputRefs[inputID] = node; + + // Call the original ref, if any + const {ref} = child; + if (_.isFunction(ref)) { + ref(node); + } + }, value: this.state.inputValues[inputID], errorText: this.state.errors[inputID] || fieldErrorMessage, onBlur: () => { diff --git a/src/components/Icon/Expensicons.js b/src/components/Icon/Expensicons.js index 36a01a83f279..f6b3938642c0 100644 --- a/src/components/Icon/Expensicons.js +++ b/src/components/Icon/Expensicons.js @@ -13,6 +13,7 @@ import Bolt from '../../../assets/images/bolt.svg'; import Briefcase from '../../../assets/images/briefcase.svg'; import Bug from '../../../assets/images/bug.svg'; import Building from '../../../assets/images/building.svg'; +import Calendar from '../../../assets/images/calendar.svg'; import Camera from '../../../assets/images/camera.svg'; import Cash from '../../../assets/images/cash.svg'; import ChatBubble from '../../../assets/images/chatbubble.svg'; @@ -125,6 +126,7 @@ export { Briefcase, Bug, Building, + Calendar, Camera, Cash, ChatBubble, diff --git a/src/components/NewDatePicker/datePickerPropTypes.js b/src/components/NewDatePicker/datePickerPropTypes.js new file mode 100644 index 000000000000..0c3906e3c6ff --- /dev/null +++ b/src/components/NewDatePicker/datePickerPropTypes.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import moment from 'moment'; +import { + propTypes as baseTextInputPropTypes, + defaultProps as defaultBaseTextInputPropTypes, +} from '../TextInput/baseTextInputPropTypes'; +import CONST from '../../CONST'; + +const propTypes = { + ...baseTextInputPropTypes, + + /** + * The datepicker supports any value that `moment` can parse. + * `onInputChange` would always be called with a Date (or null) + */ + value: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]), + + /** + * The datepicker supports any defaultValue that `moment` can parse. + * `onInputChange` would always be called with a Date (or null) + */ + defaultValue: PropTypes.oneOfType([PropTypes.instanceOf(Date), PropTypes.string]), + + /** A minimum date of calendar to select */ + minDate: PropTypes.objectOf(Date), + + /** A maximum date of calendar to select */ + maxDate: PropTypes.objectOf(Date), + + /** Default year to be set in the calendar picker */ + selectedYear: PropTypes.string, + + /** A function called when picked is closed */ + onHidePicker: PropTypes.func, +}; + +const defaultProps = { + ...defaultBaseTextInputPropTypes, + minDate: moment().year(CONST.CALENDAR_PICKER.MIN_YEAR).toDate(), + maxDate: moment().year(CONST.CALENDAR_PICKER.MAX_YEAR).toDate(), + value: undefined, + onHidePicker: () => {}, +}; + +export {propTypes, defaultProps}; diff --git a/src/components/NewDatePicker/index.js b/src/components/NewDatePicker/index.js new file mode 100644 index 000000000000..c5df338f4206 --- /dev/null +++ b/src/components/NewDatePicker/index.js @@ -0,0 +1,168 @@ +import React from 'react'; +import {View, Animated} from 'react-native'; +import moment from 'moment'; +import _ from 'underscore'; +import TextInput from '../TextInput'; +import CalendarPicker from '../CalendarPicker'; +import CONST from '../../CONST'; +import styles from '../../styles/styles'; +import * as Expensicons from '../Icon/Expensicons'; +import {propTypes as datePickerPropTypes, defaultProps as defaultDatePickerProps} from './datePickerPropTypes'; +import KeyboardShortcut from '../../libs/KeyboardShortcut'; + +const propTypes = { + ...datePickerPropTypes, +}; + +const datePickerDefaultProps = { + ...defaultDatePickerProps, +}; + +class NewDatePicker extends React.Component { + constructor(props) { + super(props); + + this.state = { + isPickerVisible: false, + selectedDate: moment(props.value || props.defaultValue || undefined).toDate(), + }; + + this.setDate = this.setDate.bind(this); + this.showPicker = this.showPicker.bind(this); + this.hidePicker = this.hidePicker.bind(this); + + this.opacity = new Animated.Value(0); + + // We're using uncontrolled input otherwise it wont be possible to + // raise change events with a date value - each change will produce a date + // and make us reset the text input + this.defaultValue = props.defaultValue + ? moment(props.defaultValue).format(CONST.DATE.MOMENT_FORMAT_STRING) + : ''; + } + + componentDidMount() { + const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE; + this.unsubscribeEscapeKey = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { + if (!this.state.isPickerVisible) { + return; + } + this.hidePicker(); + this.textInputRef.blur(); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, () => !this.state.isPickerVisible); + } + + componentWillUnmount() { + if (!this.unsubscribeEscapeKey) { + return; + } + this.unsubscribeEscapeKey(); + } + + /** + * Trigger the `onInputChange` handler when the user input has a complete date or is cleared + * @param {Date} selectedDate + */ + setDate(selectedDate) { + this.setState({selectedDate}, () => { + this.props.onInputChange(moment(selectedDate).format(CONST.DATE.MOMENT_FORMAT_STRING)); + this.hidePicker(); + this.textInputRef.blur(); + }); + } + + /** + * Function to animate showing the picker. + */ + showPicker() { + this.setState({isPickerVisible: true}, () => { + Animated.timing(this.opacity, { + toValue: 1, + duration: 100, + useNativeDriver: true, + }).start(); + }); + } + + /** + * Function to animate and hide the picker. + */ + hidePicker() { + Animated.timing(this.opacity, { + toValue: 0, + duration: 100, + useNativeDriver: true, + }).start((animationResult) => { + if (!animationResult.finished) { + return; + } + this.setState({isPickerVisible: false}, this.props.onHidePicker); + }); + } + + render() { + return ( + this.wrapperRef = ref} + onBlur={(event) => { + if (this.wrapperRef && event.relatedTarget && this.wrapperRef.contains(event.relatedTarget)) { + return; + } + this.hidePicker(); + }} + style={styles.datePickerRoot} + > + + { + this.textInputRef = el; + if (!_.isFunction(this.props.innerRef)) { + return; + } + this.props.innerRef(el); + }} + icon={Expensicons.Calendar} + onPress={this.showPicker} + label={this.props.label} + value={this.props.value || ''} + defaultValue={this.defaultValue} + placeholder={this.props.placeholder || CONST.DATE.MOMENT_FORMAT_STRING} + errorText={this.props.errorText} + containerStyles={this.props.containerStyles} + textInputContainerStyles={this.state.isPickerVisible ? [styles.borderColorFocus] : []} + disabled={this.props.disabled} + editable={false} + /> + + { + this.state.isPickerVisible && ( + { + // To prevent focus stealing + e.preventDefault(); + }} + style={[styles.datePickerPopover, styles.border, {opacity: this.opacity}]} + > + + + ) + } + + ); + } +} + +NewDatePicker.propTypes = propTypes; +NewDatePicker.defaultProps = datePickerDefaultProps; + +export default React.forwardRef((props, ref) => ( + /* eslint-disable-next-line react/jsx-props-no-spreading */ + +)); diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 075749f847e2..66b613a1ce33 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -275,6 +275,8 @@ class BaseOptionsSelector extends Component { label={this.props.textInputLabel} onChangeText={this.props.onChangeText} placeholder={this.props.placeholderText} + maxLength={this.props.maxLength} + keyboardType={this.props.keyboardType} onBlur={(e) => { if (!this.props.shouldFocusOnSelectRow) { return; diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index 8527afd16a03..851b95f05c6e 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -30,9 +30,15 @@ const propTypes = { /** Callback fired when text changes */ onChangeText: PropTypes.func.isRequired, + /** Limits the maximum number of characters that can be entered in input field */ + maxLength: PropTypes.number, + /** Label to display for the text input */ textInputLabel: PropTypes.string, + /** Optional keyboard type for the input */ + keyboardType: PropTypes.string, + /** Optional placeholder text for the selector */ placeholderText: PropTypes.string, @@ -98,6 +104,7 @@ const defaultProps = { onSelectRow: () => {}, textInputLabel: '', placeholderText: '', + keyboardType: 'default', selectedOptions: [], headerMessage: '', canSelectMultipleOptions: false, @@ -117,6 +124,7 @@ const defaultProps = { isDisabled: false, shouldHaveOptionSeparator: false, initiallyFocusedOptionKey: undefined, + maxLength: undefined, }; export {propTypes, defaultProps}; diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index 74f7700ec7a6..d85aea8a71a0 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -318,7 +318,7 @@ class BaseTextInput extends Component { /> {this.props.secureTextEntry && ( e.preventDefault()} > @@ -328,6 +328,14 @@ class BaseTextInput extends Component { /> )} + {!this.props.secureTextEntry && this.props.icon && ( + + + + )} diff --git a/src/components/TextInput/baseTextInputPropTypes.js b/src/components/TextInput/baseTextInputPropTypes.js index 5c52d1e17b8f..3fe09cfc87e4 100644 --- a/src/components/TextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/baseTextInputPropTypes.js @@ -19,6 +19,9 @@ const propTypes = { /** Error text to display */ errorText: PropTypes.string, + /** Icon to display in right side of text input */ + icon: PropTypes.func, + /** Customize the TextInput container */ textInputContainerStyles: PropTypes.arrayOf(PropTypes.object), @@ -105,6 +108,7 @@ const defaultProps = { onInputChange: () => {}, shouldDelayFocus: false, submitOnEnter: false, + icon: null, }; export {propTypes, defaultProps}; diff --git a/src/languages/en.js b/src/languages/en.js index a47b9410a000..45a410ce1126 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -56,6 +56,8 @@ export default { here: 'here', date: 'Date', dob: 'Date of birth', + currentYear: 'Current year', + currentMonth: 'Current month', ssnLast4: 'Last 4 digits of SSN', ssnFull9: 'Full 9 digits of SSN', addressLine: ({lineNumber}) => `Address line ${lineNumber}`, @@ -635,6 +637,10 @@ export default { newChatPage: { createGroup: 'Create group', }, + yearPickerPage: { + year: 'Year', + selectYear: 'Please select a year', + }, notFound: { chatYouLookingForCannotBeFound: 'The chat you are looking for cannot be found.', getMeOutOfHere: 'Get me out of here', diff --git a/src/languages/es.js b/src/languages/es.js index 7c80857ec75e..23aeac8511e7 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -55,6 +55,8 @@ export default { here: 'aquí', date: 'Fecha', dob: 'Fecha de Nacimiento', + currentYear: 'Año actual', + currentMonth: 'Mes actual', ssnLast4: 'Últimos 4 dígitos de su SSN', ssnFull9: 'Los 9 dígitos del SSN', addressLine: ({lineNumber}) => `Dirección línea ${lineNumber}`, @@ -634,6 +636,10 @@ export default { newChatPage: { createGroup: 'Crear grupo', }, + yearPickerPage: { + year: 'Año', + selectYear: 'Por favor seleccione un año', + }, notFound: { chatYouLookingForCannotBeFound: 'El chat que estás buscando no se pudo encontrar.', getMeOutOfHere: 'Sácame de aquí', diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index ec8b31b5880c..7cedd3060db4 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -315,6 +315,12 @@ class AuthScreens extends React.Component { component={ModalStackNavigators.WalletStatementStackNavigator} listeners={modalScreenListeners} /> + { + const YearPickerPage = require('../../../pages/YearPickerPage').default; + return YearPickerPage; + }, + name: 'YearPicker_Root', +}]); + export { IOUBillStackNavigator, IOURequestModalStackNavigator, @@ -528,4 +536,5 @@ export { AddPersonalBankAccountModalStackNavigator, ReimbursementAccountModalStackNavigator, WalletStatementStackNavigator, + YearPickerStackNavigator, }; diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 3af0f479ae17..080b8d148cff 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -262,6 +262,11 @@ export default { WalletStatement_Root: ROUTES.WALLET_STATEMENT_WITH_DATE, }, }, + Select_Year: { + screens: { + YearPicker_Root: ROUTES.SELECT_YEAR, + }, + }, [SCREENS.NOT_FOUND]: '*', }, }, diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index f2e35941edc4..f620f0186706 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -221,7 +221,7 @@ function getAgeRequirementError(date, minimumAge, maximumAge) { if (!testDate.isValid()) { return Localize.translateLocal('common.error.dateInvalid'); } - if (testDate.isBetween(longAgoDate, recentDate)) { + if (testDate.isBetween(longAgoDate, recentDate, undefined, [])) { return ''; } if (testDate.isSameOrAfter(recentDate)) { diff --git a/src/pages/YearPickerPage.js b/src/pages/YearPickerPage.js new file mode 100644 index 000000000000..35ea89382355 --- /dev/null +++ b/src/pages/YearPickerPage.js @@ -0,0 +1,110 @@ +import _ from 'underscore'; +import React from 'react'; +import {withCurrentUserPersonalDetailsPropTypes, withCurrentUserPersonalDetailsDefaultProps} from '../components/withCurrentUserPersonalDetails'; +import ScreenWrapper from '../components/ScreenWrapper'; +import HeaderWithCloseButton from '../components/HeaderWithCloseButton'; +import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; +import ROUTES from '../ROUTES'; +import styles from '../styles/styles'; +import Navigation from '../libs/Navigation/Navigation'; +import OptionsSelector from '../components/OptionsSelector'; +import themeColors from '../styles/themes/default'; +import * as Expensicons from '../components/Icon/Expensicons'; +import CONST from '../CONST'; + +const greenCheckmark = {src: Expensicons.Checkmark, color: themeColors.success}; + +const propTypes = { + ...withLocalizePropTypes, + ...withCurrentUserPersonalDetailsPropTypes, +}; + +const defaultProps = { + ...withCurrentUserPersonalDetailsDefaultProps, +}; + +class YearPickerPage extends React.Component { + constructor(props) { + super(props); + + const {params} = props.route; + const minYear = Number(params.min); + const maxYear = Number(params.max); + const currentYear = Number(params.year); + + this.currentYear = currentYear; + this.yearList = _.map(Array.from({length: (maxYear - minYear) + 1}, (v, i) => i + minYear), value => ({ + text: value.toString(), + value, + keyForList: value.toString(), + + // Include the green checkmark icon to indicate the currently selected value + customIcon: value === currentYear ? greenCheckmark : undefined, + + // This property will make the currently selected value have bold text + boldStyle: value === currentYear, + })); + + this.updateYearOfBirth = this.updateSelectedYear.bind(this); + this.filterYearList = this.filterYearList.bind(this); + + this.state = { + inputText: '', + yearOptions: this.yearList, + }; + } + + /** + * Function called on selection of the year, to take user back to the previous screen + * + * @param {String} selectedYear + */ + updateSelectedYear(selectedYear) { + // We have to navigate using concatenation here as it is not possible to pass a function as a route param + Navigation.navigate(`${this.props.route.params.backTo}?year=${selectedYear}`); + } + + /** + * Function filtering the list of the items when using search input + * + * @param {String} text + */ + filterYearList(text) { + this.setState({ + inputText: text, + yearOptions: _.filter(this.yearList, year => year.text.includes(text.trim())), + }); + } + + render() { + return ( + + Navigation.navigate(`${this.props.route.params.backTo}?year=${this.currentYear}` || ROUTES.HOME)} + onCloseButtonPress={() => Navigation.dismissModal(true)} + /> + this.updateSelectedYear(option.value)} + initiallyFocusedOptionKey={this.currentYear.toString()} + hideSectionHeaders + optionHoveredStyle={styles.hoveredComponentBG} + shouldHaveOptionSeparator + contentContainerStyles={[styles.ph5]} + /> + + ); + } +} + +YearPickerPage.propTypes = propTypes; +YearPickerPage.defaultProps = defaultProps; + +export default withLocalize(YearPickerPage); diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js index 260152188ff5..68ac9250aa98 100644 --- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js +++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js @@ -1,7 +1,8 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import moment from 'moment'; +import _ from 'underscore'; import ScreenWrapper from '../../../../components/ScreenWrapper'; import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton'; import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize'; @@ -13,7 +14,8 @@ import styles from '../../../../styles/styles'; import Navigation from '../../../../libs/Navigation/Navigation'; import * as PersonalDetails from '../../../../libs/actions/PersonalDetails'; import compose from '../../../../libs/compose'; -import DatePicker from '../../../../components/DatePicker'; +import NewDatePicker from '../../../../components/NewDatePicker'; +import CONST from '../../../../CONST'; const propTypes = { /* Onyx Props */ @@ -38,6 +40,39 @@ class DateOfBirthPage extends Component { this.validate = this.validate.bind(this); this.updateDateOfBirth = this.updateDateOfBirth.bind(this); + this.clearSelectedYear = this.clearSelectedYear.bind(this); + this.getYearFromRouteParams = this.getYearFromRouteParams.bind(this); + this.minDate = moment().subtract(CONST.DATE_BIRTH.MAX_AGE, 'Y').toDate(); + this.maxDate = moment().subtract(CONST.DATE_BIRTH.MIN_AGE, 'Y').toDate(); + + this.state = { + selectedYear: '', + }; + } + + componentDidMount() { + this.props.navigation.addListener('focus', this.getYearFromRouteParams); + } + + componentWillUnmount() { + this.props.navigation.removeListener('focus', this.getYearFromRouteParams); + } + + /** + * Function to be called to read year from params - necessary to read passed year from the Year picker which is a separate screen + * It allows to display selected year in the calendar picker without overwriting this value in Onyx + */ + getYearFromRouteParams() { + const {params} = this.props.route; + if (params && params.year) { + this.setState({selectedYear: params.year}); + if (this.datePicker) { + this.datePicker.focus(); + if (_.isFunction(this.datePicker.click)) { + this.datePicker.click(); + } + } + } } /** @@ -51,6 +86,13 @@ class DateOfBirthPage extends Component { ); } + /** + * A function to clear selected year + */ + clearSelectedYear() { + this.setState({selectedYear: ''}); + } + /** * @param {Object} values * @param {String} values.dob - date of birth @@ -58,8 +100,8 @@ class DateOfBirthPage extends Component { */ validate(values) { const errors = {}; - const minimumAge = 5; - const maximumAge = 150; + const minimumAge = CONST.DATE_BIRTH.MIN_AGE; + const maximumAge = CONST.DATE_BIRTH.MAX_AGE; if (!values.dob || !ValidationUtils.isValidDate(values.dob)) { errors.dob = this.props.translate('common.error.fieldRequired'); @@ -91,15 +133,16 @@ class DateOfBirthPage extends Component { submitButtonText={this.props.translate('common.save')} enabledWhenOffline > - - - + this.datePicker = ref} + inputID="dob" + label={this.props.translate('common.date')} + defaultValue={privateDetails.dob || ''} + minDate={this.minDate} + maxDate={this.maxDate} + selectedYear={this.state.selectedYear} + onHidePicker={this.clearSelectedYear} + /> ); diff --git a/src/styles/StyleUtils.js b/src/styles/StyleUtils.js index e6a808e180e9..6de8f72e1b60 100644 --- a/src/styles/StyleUtils.js +++ b/src/styles/StyleUtils.js @@ -847,6 +847,20 @@ function getEmojiReactionCounterTextStyle(hasUserReacted, sizeScale = 1) { return sizeStyles; } +/** + * Returns a style object with a rotation transformation applied based on the provided direction prop. + * + * @param {string} direction - The direction of the rotation (CONST.DIRECTION.LEFT or CONST.DIRECTION.RIGHT). + * @returns {Object} + */ +function getDirectionStyle(direction) { + if (direction === CONST.DIRECTION.LEFT) { + return {transform: [{rotate: '180deg'}]}; + } + + return {}; +} + export { getAvatarSize, getAvatarStyle, @@ -893,4 +907,5 @@ export { getEmojiReactionBubbleStyle, getEmojiReactionTextStyle, getEmojiReactionCounterTextStyle, + getDirectionStyle, }; diff --git a/src/styles/styles.js b/src/styles/styles.js index 092619783051..de490186cc48 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -726,6 +726,38 @@ const styles = { height: variables.inputHeight, }, + calendarHeader: { + height: 50, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 15, + paddingRight: 5, + }, + + calendarDayRoot: { + flex: 1, + height: 45, + justifyContent: 'center', + alignItems: 'center', + }, + + calendarDayContainer: { + width: 30, + height: 30, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 15, + }, + + calendarDayContainerSelected: { + backgroundColor: themeColors.buttonDefaultBG, + }, + + calendarButtonDisabled: { + opacity: 0.5, + }, + textInputContainer: { flex: 1, justifyContent: 'center', @@ -794,7 +826,7 @@ const styles = { textInputDesktop: addOutlineWidth({}, 0), - secureInputShowPasswordButton: { + textInputIconContainer: { paddingHorizontal: 11, justifyContent: 'center', margin: 1, @@ -2986,7 +3018,6 @@ const styles = { fontSize: variables.fontSizeXXLarge, letterSpacing: 4, }, - footer: { backgroundColor: themeColors.midtone, }, @@ -3023,6 +3054,24 @@ const styles = { width: '100%', }, + listPickerSeparator: { + height: 1, + backgroundColor: themeColors.buttonDefaultBG, + }, + + datePickerRoot: { + position: 'relative', + zIndex: 99, + }, + + datePickerPopover: { + position: 'absolute', + backgroundColor: themeColors.appBG, + width: '100%', + alignSelf: 'center', + top: 60, + zIndex: 100, + }, }; export default styles; diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js new file mode 100644 index 000000000000..3667cba0a7e6 --- /dev/null +++ b/tests/unit/CalendarPickerTest.js @@ -0,0 +1,153 @@ +import {render, fireEvent, within} from '@testing-library/react-native'; +import moment from 'moment'; +import CalendarPicker from '../../src/components/CalendarPicker'; + +moment.locale('en'); +const monthNames = moment.localeData().months(); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({navigate: jest.fn()}), + createNavigationContainerRef: jest.fn(), +})); + +// eslint-disable-next-line arrow-body-style +const MockedCalendarPicker = (props) => { + // eslint-disable-next-line react/jsx-props-no-spreading + return ''} preferredLocale="en" />; +}; + +describe('CalendarPicker', () => { + test('renders calendar component', () => { + render(); + }); + + test('displays the current month and year', () => { + const currentDate = new Date(); + const maxDate = moment(currentDate).add(1, 'Y').toDate(); + const minDate = moment(currentDate).subtract(1, 'Y').toDate(); + const {getByText} = render(); + + expect(getByText(monthNames[currentDate.getMonth()])).toBeTruthy(); + expect(getByText(currentDate.getFullYear().toString())).toBeTruthy(); + }); + + test('clicking next month arrow updates the displayed month', () => { + const minDate = new Date('2022-01-01'); + const maxDate = new Date('2030-01-01'); + const {getByTestId, getByText} = render(); + + fireEvent.press(getByTestId('next-month-arrow')); + + const nextMonth = (new Date()).getMonth() + 1; + expect(getByText(monthNames[nextMonth])).toBeTruthy(); + }); + + test('clicking previous month arrow updates the displayed month', () => { + const {getByTestId, getByText} = render(); + + fireEvent.press(getByTestId('prev-month-arrow')); + + const prevMonth = (new Date()).getMonth() - 1; + expect(getByText(monthNames[prevMonth])).toBeTruthy(); + }); + + test('clicking a day updates the selected date', () => { + const onSelectedMock = jest.fn(); + const minDate = new Date('2022-01-01'); + const maxDate = new Date('2030-01-01'); + const value = new Date('2023-01-01'); + const {getByText} = render(); + + fireEvent.press(getByText('15')); + + expect(onSelectedMock).toHaveBeenCalledWith(new Date('2023-01-15')); + expect(onSelectedMock).toHaveBeenCalledTimes(1); + }); + + test('clicking previous month arrow and selecting day updates the selected date', () => { + const onSelectedMock = jest.fn(); + const value = new Date('2022-01-01'); + const minDate = new Date('2022-01-01'); + const maxDate = new Date('2030-01-01'); + const {getByText, getByTestId} = render(); + + fireEvent.press(getByTestId('next-month-arrow')); + fireEvent.press(getByText('15')); + + expect(onSelectedMock).toHaveBeenCalledWith(new Date('2022-02-15')); + }); + + test('should block the back arrow when there is no available dates in the previous month', () => { + const minDate = new Date('2003-02-01'); + const value = new Date('2003-02-17'); + const {getByTestId} = render(); + + expect(getByTestId('prev-month-arrow')).toBeDisabled(); + }); + + test('should block the next arrow when there is no available dates in the next month', () => { + const maxDate = new Date('2003-02-24'); + const value = new Date('2003-02-17'); + const {getByTestId} = render(); + + expect(getByTestId('next-month-arrow')).toBeDisabled(); + }); + + test('should open the calendar on a month from max date if it is earlier than current month', () => { + const onSelectedMock = jest.fn(); + const maxDate = new Date('2011-03-01'); + const {getByText} = render(); + + fireEvent.press(getByText('1')); + + expect(onSelectedMock).toHaveBeenCalledWith(new Date('2011-03-01')); + }); + + test('should open the calendar on a year from max date if it is earlier than current year', () => { + const maxDate = new Date('2011-03-01'); + const {getByTestId} = render(); + + expect(within(getByTestId('currentYearText')).getByText('2011')).toBeTruthy(); + }); + + test('should open the calendar on a month from min date if it is later than current month', () => { + const minDate = new Date('2035-02-16'); + const maxDate = new Date('2040-02-16'); + const {getByTestId} = render(); + + expect(within(getByTestId('currentYearText')).getByText(minDate.getFullYear().toString())).toBeTruthy(); + }); + + test('should not allow to press earlier day than minDate', () => { + const date = new Date('2003-02-17'); + const minDate = new Date('2003-02-16'); + const {getByLabelText} = render(); + + expect(getByLabelText('15')).toBeDisabled(); + }); + + test('should not allow to press later day than max', () => { + const date = new Date('2003-02-17'); + const maxDate = new Date('2003-02-24'); + const {getByLabelText} = render(); + + expect(getByLabelText('25')).toBeDisabled(); + }); + + test('should allow to press min date', () => { + const date = new Date('2003-02-17'); + const minDate = new Date('2003-02-16'); + const {getByLabelText} = render(); + + expect(getByLabelText('16')).not.toBeDisabled(); + }); + + test('should not allow to press max date', () => { + const date = new Date('2003-02-17'); + const maxDate = new Date('2003-02-24'); + const {getByLabelText} = render(); + + expect(getByLabelText('24')).not.toBeDisabled(); + }); +}); + diff --git a/tests/unit/generateMonthMatrixTest.js b/tests/unit/generateMonthMatrixTest.js new file mode 100644 index 000000000000..b36ccc29f547 --- /dev/null +++ b/tests/unit/generateMonthMatrixTest.js @@ -0,0 +1,95 @@ +import generateMonthMatrix from '../../src/components/CalendarPicker/generateMonthMatrix'; + +describe('generateMonthMatrix', () => { + it('returns the correct matrix for January 2022', () => { + const expected = [ + [null, null, null, null, null, null, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, 25, 26, 27, 28, 29], + [30, 31, null, null, null, null, null], + ]; + expect(generateMonthMatrix(2022, 0)).toEqual(expected); + }); + + it('returns the correct matrix for February 2022', () => { + const expected = [ + [null, null, 1, 2, 3, 4, 5], + [6, 7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18, 19], + [20, 21, 22, 23, 24, 25, 26], + [27, 28, null, null, null, null, null], + ]; + expect(generateMonthMatrix(2022, 1)).toEqual(expected); + }); + + it('returns the correct matrix for leap year February 2020', () => { + const expected = [ + [null, null, null, null, null, null, 1], + [2, 3, 4, 5, 6, 7, 8], + [9, 10, 11, 12, 13, 14, 15], + [16, 17, 18, 19, 20, 21, 22], + [23, 24, 25, 26, 27, 28, 29], + ]; + expect(generateMonthMatrix(2020, 1)).toEqual(expected); + }); + + it('returns the correct matrix for March 2022', () => { + const expected = [ + [null, null, 1, 2, 3, 4, 5], + [6, 7, 8, 9, 10, 11, 12], + [13, 14, 15, 16, 17, 18, 19], + [20, 21, 22, 23, 24, 25, 26], + [27, 28, 29, 30, 31, null, null], + ]; + expect(generateMonthMatrix(2022, 2)).toEqual(expected); + }); + + it('returns the correct matrix for April 2022', () => { + const expected = [ + [null, null, null, null, null, 1, 2], + [3, 4, 5, 6, 7, 8, 9], + [10, 11, 12, 13, 14, 15, 16], + [17, 18, 19, 20, 21, 22, 23], + [24, 25, 26, 27, 28, 29, 30], + ]; + expect(generateMonthMatrix(2022, 3)).toEqual(expected); + }); + + it('returns the correct matrix for December 2022', () => { + const expected = [ + [null, null, null, null, 1, 2, 3], + [4, 5, 6, 7, 8, 9, 10], + [11, 12, 13, 14, 15, 16, 17], + [18, 19, 20, 21, 22, 23, 24], + [25, 26, 27, 28, 29, 30, 31], + ]; + expect(generateMonthMatrix(2022, 11)).toEqual(expected); + }); + + it('throws an error if month is less than 0', () => { + expect(() => generateMonthMatrix(2022, -1)).toThrow(); + }); + + it('throws an error if month is greater than 11', () => { + expect(() => generateMonthMatrix(2022, 12)).toThrow(); + }); + + it('throws an error if year is negative', () => { + expect(() => generateMonthMatrix(-1, 0)).toThrow(); + }); + + it('throws an error if year or month is not a number', () => { + expect(() => generateMonthMatrix()).toThrow(); + expect(() => generateMonthMatrix(2022, 'invalid')).toThrow(); + expect(() => generateMonthMatrix('2022', '0')).toThrow(); + expect(() => generateMonthMatrix(null, undefined)).toThrow(); + }); + + it('returns a matrix with 6 rows and 7 columns for January 2022', () => { + const matrix = generateMonthMatrix(2022, 0); + expect(matrix.length).toBe(6); + expect(matrix[0].length).toBe(7); + }); +});