diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index fdfb97d25eae..b8aac1e38baa 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -272,6 +272,7 @@ Form.js will automatically provide the following props to any input with the inp - value: The input value. - errorText: The translated error text that is returned by validate for that specific input. - onBlur: An onBlur handler that calls validate. +- onTouched: An onTouched handler that marks the input as touched. - onInputChange: An onChange handler that saves draft values and calls validate for that input (inputA). Passing an inputID as a second param allows inputA to manipulate the input value of the provided inputID (inputB). ## Dynamic Form Inputs diff --git a/src/components/CalendarPicker/calendarPickerPropTypes.js b/src/components/CalendarPicker/calendarPickerPropTypes.js index 03c142e4736d..7ccefc784d25 100644 --- a/src/components/CalendarPicker/calendarPickerPropTypes.js +++ b/src/components/CalendarPicker/calendarPickerPropTypes.js @@ -3,8 +3,8 @@ import moment from 'moment'; import CONST from '../../CONST'; const propTypes = { - /** An initial value of date */ - value: PropTypes.objectOf(Date), + /** An initial value of date string */ + value: PropTypes.string, /** A minimum date (oldest) allowed to select */ minDate: PropTypes.objectOf(Date), diff --git a/src/components/CalendarPicker/index.js b/src/components/CalendarPicker/index.js index 39762a10d1d9..cd3679f1b8c6 100644 --- a/src/components/CalendarPicker/index.js +++ b/src/components/CalendarPicker/index.js @@ -19,7 +19,9 @@ class CalendarPicker extends React.PureComponent { constructor(props) { super(props); - let currentDateView = props.value; + let currentSelection = moment(props.value, CONST.DATE.MOMENT_FORMAT_STRING); + let currentDateView = currentSelection.toDate(); + if (props.selectedYear) { currentDateView = moment(currentDateView).set('year', props.selectedYear).toDate(); } @@ -28,12 +30,17 @@ class CalendarPicker extends React.PureComponent { } if (props.maxDate < currentDateView) { currentDateView = props.maxDate; + currentSelection = moment(props.maxDate); } else if (props.minDate > currentDateView) { currentDateView = props.minDate; + currentSelection = moment(props.minDate); } this.state = { currentDateView, + selectedYear: currentSelection.get('year').toString(), + selectedMonth: this.getNumberStringWithLeadingZero(currentSelection.get('month') + 1), + selectedDay: this.getNumberStringWithLeadingZero(currentSelection.get('date')), }; this.moveToPrevMonth = this.moveToPrevMonth.bind(this); @@ -56,7 +63,19 @@ class CalendarPicker extends React.PureComponent { } // 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()})); + this.setState( + (prev) => { + const newMomentDate = moment(prev.currentDateView).set('year', this.props.selectedYear); + + return { + selectedYear: this.props.selectedYear, + currentDateView: this.clampDate(newMomentDate.toDate()), + }; + }, + () => { + this.props.onSelected(this.getSelectedDateString()); + }, + ); } /** @@ -67,7 +86,7 @@ class CalendarPicker extends React.PureComponent { onYearPickerPressed() { const minYear = moment(this.props.minDate).year(); const maxYear = moment(this.props.maxDate).year(); - const currentYear = this.state.currentDateView.getFullYear(); + const currentYear = parseInt(this.state.selectedYear, 10); Navigation.navigate(ROUTES.getYearSelectionRoute(minYear, maxYear, currentYear, Navigation.getActiveRoute())); this.props.onYearPickerOpen(this.state.currentDateView); } @@ -77,16 +96,102 @@ class CalendarPicker extends React.PureComponent { * @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); + this.setState( + (prev) => { + const momentDate = moment(prev.currentDateView).date(day); + + return { + selectedDay: this.getNumberStringWithLeadingZero(day), + currentDateView: this.clampDate(momentDate.toDate()), + }; + }, + () => { + this.props.onSelected(this.getSelectedDateString()); + }, + ); + } + + /** + * Gets the date string build from state values of selected year, month and day. + * @returns {string} - Date string in the 'YYYY-MM-DD' format. + */ + getSelectedDateString() { + // can't use moment.format() method here because it won't allow incorrect dates + return `${this.state.selectedYear}-${this.state.selectedMonth}-${this.state.selectedDay}`; } + /** + * Returns the string converted from the given number. If the number is lower than 10, + * it will add zero at the beginning of the string. + * @param {Number} number - The number to be converted. + * @returns {string} - Converted string prefixed by zero if necessary. + */ + getNumberStringWithLeadingZero(number) { + return `${number < 10 ? `0${number}` : number}`; + } + + /** + * Gives the new version of the state object, + * changing both selected month and year based on the given moment date. + * @param {moment.Moment} momentDate - Moment date object. + * @returns {{currentDateView: Date, selectedMonth: string, selectedYear: string}} - The new version of the state. + */ + getMonthState(momentDate) { + const clampedDate = this.clampDate(momentDate.toDate()); + const month = clampedDate.getMonth() + 1; + + return { + selectedMonth: this.getNumberStringWithLeadingZero(month), + selectedYear: clampedDate.getFullYear().toString(), // year might have changed too + currentDateView: clampedDate, + }; + } + + /** + * Handles the user pressing the previous month arrow of the calendar picker. + */ moveToPrevMonth() { - this.setState((prev) => ({currentDateView: moment(prev.currentDateView).subtract(1, 'M').toDate()})); + this.setState( + (prev) => { + const momentDate = moment(prev.currentDateView).subtract(1, 'M'); + + return this.getMonthState(momentDate); + }, + () => { + this.props.onSelected(this.getSelectedDateString()); + }, + ); } + /** + * Handles the user pressing the next month arrow of the calendar picker. + */ moveToNextMonth() { - this.setState((prev) => ({currentDateView: moment(prev.currentDateView).add(1, 'M').toDate()})); + this.setState( + (prev) => { + const momentDate = moment(prev.currentDateView).add(1, 'M'); + + return this.getMonthState(momentDate); + }, + () => { + this.props.onSelected(this.getSelectedDateString()); + }, + ); + } + + /** + * Checks whether the given date is in the min/max date range and returns the limit value if not. + * @param {Date} date - The date object to check. + * @returns {Date} - The date that is within the min/max date range. + */ + clampDate(date) { + if (this.props.maxDate < date) { + return this.props.maxDate; + } + if (this.props.minDate > date) { + return this.props.minDate; + } + return date; } render() { diff --git a/src/components/Form.js b/src/components/Form.js index 221ea2f23b98..5bcf97cf3547 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -306,6 +306,9 @@ class Form extends React.Component { child.props.onBlur(event); } }, + onTouched: () => { + this.setTouchedInput(inputID); + }, onInputChange: (value, key) => { const inputKey = key || inputID; this.setState( diff --git a/src/components/NewDatePicker/datePickerPropTypes.js b/src/components/NewDatePicker/datePickerPropTypes.js index 502d7d820afa..b08371030fc0 100644 --- a/src/components/NewDatePicker/datePickerPropTypes.js +++ b/src/components/NewDatePicker/datePickerPropTypes.js @@ -10,13 +10,13 @@ const propTypes = { * 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]), + value: 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]), + defaultValue: PropTypes.string, /** A minimum date of calendar to select */ minDate: PropTypes.objectOf(Date), diff --git a/src/components/NewDatePicker/index.js b/src/components/NewDatePicker/index.js index 72a3a2d35fa6..5ab35aefb563 100644 --- a/src/components/NewDatePicker/index.js +++ b/src/components/NewDatePicker/index.js @@ -24,13 +24,13 @@ class NewDatePicker extends React.Component { this.state = { selectedMonth: null, - selectedDate: moment(props.value || props.defaultValue || undefined).toDate(), + selectedDate: props.value || props.defaultValue || undefined, }; this.setDate = this.setDate.bind(this); this.setCurrentSelectedMonth = this.setCurrentSelectedMonth.bind(this); - // We're using uncontrolled input otherwise it wont be possible to + // We're using uncontrolled input otherwise it won't 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) : ''; @@ -47,11 +47,12 @@ class NewDatePicker extends React.Component { /** * Trigger the `onInputChange` handler when the user input has a complete date or is cleared - * @param {Date} selectedDate + * @param {string} selectedDate */ setDate(selectedDate) { this.setState({selectedDate}, () => { - this.props.onInputChange(moment(selectedDate).format(CONST.DATE.MOMENT_FORMAT_STRING)); + this.props.onTouched(); + this.props.onInputChange(selectedDate); }); } diff --git a/src/styles/styles.js b/src/styles/styles.js index 5d7f0952a627..d28f9e387970 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -3224,12 +3224,11 @@ const styles = { }, datePickerPopover: { - position: 'absolute', backgroundColor: themeColors.appBG, width: '100%', alignSelf: 'center', - top: 60, zIndex: 100, + marginTop: 8, }, loginHeroHeader: { diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js index 547320ed8d1d..206783447fa5 100644 --- a/tests/unit/CalendarPickerTest.js +++ b/tests/unit/CalendarPickerTest.js @@ -72,7 +72,7 @@ describe('CalendarPicker', () => { 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 value = '2023-01-01'; const {getByText} = render( { fireEvent.press(getByText('15')); - expect(onSelectedMock).toHaveBeenCalledWith(new Date('2023-01-15')); + expect(onSelectedMock).toHaveBeenCalledWith('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 value = '2022-01-01'; const minDate = new Date('2022-01-01'); const maxDate = new Date('2030-01-01'); const {getByText, getByTestId} = render( @@ -105,7 +105,7 @@ describe('CalendarPicker', () => { fireEvent.press(getByTestId('next-month-arrow')); fireEvent.press(getByText('15')); - expect(onSelectedMock).toHaveBeenCalledWith(new Date('2022-02-15')); + expect(onSelectedMock).toHaveBeenCalledWith('2022-02-15'); }); test('should block the back arrow when there is no available dates in the previous month', () => { @@ -123,7 +123,7 @@ describe('CalendarPicker', () => { 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 value = '2003-02-17'; const {getByTestId} = render( { fireEvent.press(getByText('1')); - expect(onSelectedMock).toHaveBeenCalledWith(new Date('2011-03-01')); + expect(onSelectedMock).toHaveBeenCalledWith('2011-03-01'); }); test('should open the calendar on a year from max date if it is earlier than current year', () => { @@ -170,12 +170,12 @@ describe('CalendarPicker', () => { }); test('should not allow to press earlier day than minDate', () => { - const date = new Date('2003-02-17'); + const value = '2003-02-17'; const minDate = new Date('2003-02-16'); const {getByLabelText} = render( , ); @@ -183,12 +183,12 @@ describe('CalendarPicker', () => { }); test('should not allow to press later day than max', () => { - const date = new Date('2003-02-17'); + const value = '2003-02-17'; const maxDate = new Date('2003-02-24'); const {getByLabelText} = render( , ); @@ -196,12 +196,12 @@ describe('CalendarPicker', () => { }); test('should allow to press min date', () => { - const date = new Date('2003-02-17'); + const value = '2003-02-17'; const minDate = new Date('2003-02-16'); const {getByLabelText} = render( , ); @@ -209,12 +209,12 @@ describe('CalendarPicker', () => { }); test('should not allow to press max date', () => { - const date = new Date('2003-02-17'); + const value = '2003-02-17'; const maxDate = new Date('2003-02-24'); const {getByLabelText} = render( , );