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);
+ });
+});