Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: New Date Picker Design #15343

Merged
merged 94 commits into from
Mar 20, 2023
Merged
Show file tree
Hide file tree
Changes from 93 commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
23c1c2f
new datepicker design
TMisiukiewicz Mar 3, 2023
07f7803
WIP: month/year push to page
ArekChr Mar 3, 2023
55da3eb
feat: translate header and sub header
ArekChr Mar 3, 2023
d78625c
fix: pass string date format to input chage
ArekChr Mar 3, 2023
eaffefb
hide datepicker when using push-to-page pattern
TMisiukiewicz Mar 3, 2023
974dfa2
fix: set year and month through onyx draft value
ArekChr Mar 3, 2023
1406300
push-to-page with year picker on mobile
TMisiukiewicz Mar 3, 2023
95d58f4
web support for push-to-page year picker
TMisiukiewicz Mar 3, 2023
57dad19
set min and max year
TMisiukiewicz Mar 3, 2023
bba8154
prevent setting year when not applied
TMisiukiewicz Mar 3, 2023
84354f1
datepicker push-to-page improvements
TMisiukiewicz Mar 3, 2023
687828c
remove unused listpicker
TMisiukiewicz Mar 3, 2023
883070e
reusable page for year selection
TMisiukiewicz Mar 3, 2023
26ce073
use own implementation of popover in calendar
TMisiukiewicz Mar 3, 2023
d99dbd9
use custom popover for datepicker
TMisiukiewicz Mar 3, 2023
87c49be
code refactor
TMisiukiewicz Mar 3, 2023
cbac7b4
separate datepicker into new file
TMisiukiewicz Mar 3, 2023
fee5aad
add search to year picker page
TMisiukiewicz Mar 3, 2023
90e3732
code cleanup
TMisiukiewicz Mar 3, 2023
b319be0
code updates
TMisiukiewicz Mar 3, 2023
6563d39
code review updates
TMisiukiewicz Mar 3, 2023
1d5e894
code adjustments to pr checklist
TMisiukiewicz Mar 3, 2023
1f70100
remove unused import
TMisiukiewicz Mar 3, 2023
49b8343
docs: comments expaining month martix funciton
ArekChr Mar 3, 2023
d0312f0
refactor: cr, comments
ArekChr Mar 6, 2023
cc89e58
refactor: cr, rename prop types, comments
ArekChr Mar 6, 2023
0eed6c5
fix: cr - code improvements
ArekChr Mar 6, 2023
cffa5eb
refactor: cr, default props for min/max year
ArekChr Mar 7, 2023
d47f559
refactor: default min/max date i dateOfBirthPage
ArekChr Mar 7, 2023
b7c0b8b
fix: click ouside to close calendar on web
ArekChr Mar 7, 2023
d98d5bc
revert tests removal
TMisiukiewicz Mar 7, 2023
2823d78
cr: remove unneccesary disabled condition, translate accessibility la…
ArekChr Mar 7, 2023
71a8894
fix: tests
ArekChr Mar 7, 2023
97300ed
refactor: default values for calendar
ArekChr Mar 7, 2023
e17531c
refactor: code review updates
TMisiukiewicz Mar 7, 2023
6299383
refactor: updateLocalDate
ArekChr Mar 7, 2023
9cd301c
refactor: code review updates
TMisiukiewicz Mar 7, 2023
71824b9
refactor: cr
ArekChr Mar 7, 2023
f087628
refactor: cr updates
TMisiukiewicz Mar 7, 2023
91fc8d2
refactor: cr
ArekChr Mar 7, 2023
59e0f72
refactor: remove min max date from constructor in native
ArekChr Mar 7, 2023
beaa24c
refactor: year list array
ArekChr Mar 7, 2023
cc4300f
refactor: cr updates
TMisiukiewicz Mar 8, 2023
dd735b1
fix: update getAgeRequirementError
TMisiukiewicz Mar 8, 2023
ce75a1b
refactor: code review updates
TMisiukiewicz Mar 8, 2023
498876f
change readParams to getYearFromRouteParams
TMisiukiewicz Mar 8, 2023
989494c
update naming convention
TMisiukiewicz Mar 8, 2023
facdfcd
bring back click listener
TMisiukiewicz Mar 8, 2023
e1fc14a
update file import name
TMisiukiewicz Mar 8, 2023
5ce634a
fix: cr changes
ArekChr Mar 8, 2023
dfd755f
fix: rename datePicker case sensitive
ArekChr Mar 8, 2023
fed133a
fix: cr comments
ArekChr Mar 8, 2023
88bd92a
fix: cr comments
ArekChr Mar 8, 2023
0c4b043
fix: cr comments
ArekChr Mar 8, 2023
2a81ce1
fix: cr calendar year
ArekChr Mar 9, 2023
d8cbec4
fix: cr, remove unused HOC
ArekChr Mar 9, 2023
4e1ffd1
fix: cr, comments
ArekChr Mar 9, 2023
e184109
refactor: remove unused imports
ArekChr Mar 9, 2023
e14c37b
refactor: managing focused text input state, fix position of calendar
ArekChr Mar 10, 2023
8b58c54
fix: rename case sensitive file
ArekChr Mar 10, 2023
551bf6c
fix: rename case sensitive file
ArekChr Mar 10, 2023
92a8687
fix: remove document mousedown event listener
ArekChr Mar 10, 2023
0f3164a
feat: autofocus
ArekChr Mar 13, 2023
dfc8714
fix: remove componentDidMount, show picker on focus
ArekChr Mar 13, 2023
2968bd1
fix: remove auto focus from text input
ArekChr Mar 13, 2023
46fd131
refactor: cr
ArekChr Mar 13, 2023
d125aee
refactor: cr
ArekChr Mar 13, 2023
673cc7c
refactor: remove index.native.js
ArekChr Mar 13, 2023
ff94600
fix: focus calendar buttons by pressing tab
ArekChr Mar 13, 2023
bff8fb1
refactor: remove redundant condition
ArekChr Mar 13, 2023
c0fd2a1
fix: cr
ArekChr Mar 13, 2023
5e78efe
fix: cr
ArekChr Mar 13, 2023
edf6cca
refactor: rename tmp
ArekChr Mar 13, 2023
853def8
refactor: rename datepickerPropTypes
ArekChr Mar 13, 2023
2abbce4
fix: revert datepicker ios
ArekChr Mar 13, 2023
f676d87
fix: numpad keyboard in options selector
ArekChr Mar 14, 2023
9307c38
fix: selecton list keyboard type default
ArekChr Mar 14, 2023
2a60148
feat: add calendar icon dob input
ArekChr Mar 14, 2023
3270817
fix: clone icon to oryginal dir
ArekChr Mar 14, 2023
be94db1
fix: cr
ArekChr Mar 14, 2023
2645f7e
fix: cr
ArekChr Mar 14, 2023
f3e4d19
refactor: cr
ArekChr Mar 15, 2023
7ea4962
fix: cr
ArekChr Mar 16, 2023
91f5ae8
fix: cr
ArekChr Mar 16, 2023
bb755bf
fix: cr
ArekChr Mar 16, 2023
91684e1
fix: cr
ArekChr Mar 16, 2023
48f0b14
fix: cr
ArekChr Mar 16, 2023
406de1b
fix: close picker on esc condition
ArekChr Mar 16, 2023
ff4043a
fix: cr
ArekChr Mar 16, 2023
eaf012b
refactor: add pressed and hover styles to calendar day container
ArekChr Mar 16, 2023
9016573
fix: keyboard shortcuts issues
ArekChr Mar 17, 2023
880d91b
refactor: calendar days styles
ArekChr Mar 17, 2023
9afdd53
fix: cr
ArekChr Mar 17, 2023
76e1d0e
fix: linter
ArekChr Mar 17, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions assets/images/calendar.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
ArekChr marked this conversation as resolved.
Show resolved Hide resolved
MAX_AGE: 150,
},

// This is used to enable a rotation/transform style to any component.
DIRECTION: {
ArekChr marked this conversation as resolved.
Show resolved Hide resolved
LEFT: 'left',
RIGHT: 'right',
},

// Sizes needed for report empty state background image handling
EMPTY_STATE_BACKGROUND: {
SMALL_SCREEN: {
Expand Down
2 changes: 2 additions & 0 deletions src/ROUTES.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also super NAB - i feel like backTo could be slightly clearer, so maybe we can explain in a comment or change to originatingRoute or something like that? If y'all agree it's not immediately clear


/** 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',
Expand Down
38 changes: 38 additions & 0 deletions src/components/CalendarPicker/ArrowIcon.js
Original file line number Diff line number Diff line change
@@ -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 => (
<View style={[
styles.p1,
StyleUtils.getDirectionStyle(props.direction),
props.disabled ? styles.calendarButtonDisabled : {},
]}
>
ArekChr marked this conversation as resolved.
Show resolved Hide resolved
<Icon src={Expensicons.ArrowRight} />
</View>
);

ArrowIcon.displayName = 'ArrowIcon';
ArrowIcon.propTypes = propTypes;
ArrowIcon.defaultProps = defaultProps;

export default ArrowIcon;
34 changes: 34 additions & 0 deletions src/components/CalendarPicker/calendarPickerPropTypes.js
Original file line number Diff line number Diff line change
@@ -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};
60 changes: 60 additions & 0 deletions src/components/CalendarPicker/generateMonthMatrix.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import moment from 'moment';

/**
* Generates a matrix representation of a month's calendar given the year and month.
ArekChr marked this conversation as resolved.
Show resolved Hide resolved
*
* @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<Array<Number|null>>} - 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;
}
171 changes: 171 additions & 0 deletions src/components/CalendarPicker/index.js
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a huge amount of comment history in this PR, so apologies if I missed something, but imo this CalendarPicker component should not have been separated from the NewDatePicker. Doing so actually makes things a bit more complicated.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. It would have been good to get some wider input before introducing large changes like this.

Copy link
Contributor

@mountiny mountiny Jun 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was separated to two components as initially we had a logic which was hiding and opening the date picker once user focused into the date input. So it was clearer from developer perspective that way I would say.

Down the road we have decided to remove this logic and the calendar is permanently shown now so it should be easier to keep it all in one component now

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made an issue to track the improvements here if you want to follow along

There was a bunch of follow up PRs to this component since this PR got merged so I am sure we could use some clean up and refactoring as well.

constructor(props) {
super(props);

this.monthNames = moment.localeData(props.preferredLocale).months();
this.daysOfWeek = moment.localeData(props.preferredLocale).weekdays();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caused a regression.

Text wasn't changing when switched to a different locale through another device/tab


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');
Comment on lines +94 to +95
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comparison was not accurate. More details about the root cause: #28622 (comment)


return (
<View>
<View style={[styles.calendarHeader, styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, styles.ph4, styles.pr1]}>
<TouchableOpacity
onPress={this.onYearPickerPressed}
style={[styles.alignItemsCenter, styles.flexRow, styles.flex1, styles.justifyContentStart]}
>
<Text style={styles.sidebarLinkTextBold} testID="currentYearText" accessibilityLabel={this.props.translate('common.currentYear')}>{currentYearView}</Text>
ArekChr marked this conversation as resolved.
Show resolved Hide resolved
<ArrowIcon />
</TouchableOpacity>
<View style={[styles.alignItemsCenter, styles.flexRow, styles.flex1, styles.justifyContentEnd]}>
<Text
style={styles.sidebarLinkTextBold}
testID="currentMonthText"
accessibilityLabel={this.props.translate('common.currentMonth')}
>
{this.monthNames[currentMonthView]}
</Text>
<TouchableOpacity testID="prev-month-arrow" disabled={!hasAvailableDatesPrevMonth} onPress={this.moveToPrevMonth}>
<ArrowIcon disabled={!hasAvailableDatesPrevMonth} direction={CONST.DIRECTION.LEFT} />
</TouchableOpacity>
<TouchableOpacity testID="next-month-arrow" disabled={!hasAvailableDatesNextMonth} onPress={this.moveToNextMonth}>
<ArrowIcon disabled={!hasAvailableDatesNextMonth} />
</TouchableOpacity>
</View>
</View>
<View style={styles.flexRow}>
{_.map(this.daysOfWeek, (dayOfWeek => (
<View key={dayOfWeek} style={[styles.calendarDayRoot, styles.flex1, styles.justifyContentCenter, styles.alignItemsCenter]}>
<Text style={styles.sidebarLinkTextBold}>{dayOfWeek[0]}</Text>
</View>
)))}
</View>
{_.map(calendarDaysMatrix, week => (
<View key={`week-${week}`} style={styles.flexRow}>
{_.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 (
<Pressable
key={`${index}_day-${day}`}
disabled={isDisabled}
onPress={() => this.onDayPressed(day)}
style={styles.calendarDayRoot}
accessibilityLabel={day ? day.toString() : undefined}
>
{({hovered, pressed}) => (
<View
style={[
styles.calendarDayContainer,
isSelected ? styles.calendarDayContainerSelected : {},
StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed)),
]}
>
<Text style={isDisabled ? styles.calendarButtonDisabled : styles.dayText}>{day}</Text>
</View>
)}
</Pressable>
);
})}
</View>
))}
</View>
);
}
}

CalendarPicker.propTypes = calendarPickerPropType;
CalendarPicker.defaultProps = defaultCalendarPickerPropType;

export default withLocalize(CalendarPicker);
7 changes: 1 addition & 6 deletions src/components/DatePicker/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading