Skip to content

Commit

Permalink
Refactor DatePicker component to react hooks and function component (#…
Browse files Browse the repository at this point in the history
…36835)

* Refactor DatePicker component to use react hooks and change it from class
component to function component.

It was already done as a part of different PR #22897. After code review
it looks like the original author is not able to find time to reply or
address review comments. I'm doing takeover from here and will take care
of PR until it gets merged.

* add changelog entry

* add `onMonthPreviewed` in README.md

* Remove `getMomentDate` test case

Since in real word scenario, in UI we never deal with `getMomentDate` directly
so in the test case we cannot write some event or simulate any behaviour that will
give access to `getMomentDate` method. If we take help of `currentDate` props then test cases
are already in the place.

* Fix `onChangeMoment` test cases with help of `onDateChange` props

* Move `getMomentDate` to a separate `utils.js` file

- moved the `getMomentDate` tests under a new file, eg `test/utils.js` file
- changed the `getMomentDate` unit tests to avoid rendering `DatePicker` and using enzyme

* Remove extra spaces from the changelog file

Co-authored-by: Marco Ciampini <[email protected]>

* Descriptive documentation on `onMonthPreviewed ` props

Co-authored-by: Marco Ciampini <[email protected]>

Co-authored-by: Marco Ciampini <[email protected]>
  • Loading branch information
amustaque97 and ciampo authored Dec 15, 2021
1 parent 2b35b60 commit 92923c6
Show file tree
Hide file tree
Showing 6 changed files with 128 additions and 113 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
### Enhancements

- Wrapped `Modal` in a `forwardRef` call ([#36831](https://github.com/WordPress/gutenberg/pull/36831)).
- Refactor `DateTime` class component to functional component ([#36835](https://github.com/WordPress/gutenberg/pull/36835))
- Unify styles for `ColorIndicator` with how they appear in Global Styles ([#37028](https://github.com/WordPress/gutenberg/pull/37028))
- Add support for rendering the `ColorPalette` in a `Dropdown` when opened in the sidebar ([#37067](https://github.com/WordPress/gutenberg/pull/37067))
- Show an incremental sequence of numbers (1/2/3/4/5) as a label of the font size, when we have at most five font sizes, where at least one the them contains a complex css value(clamp, var, etc..). We do this because complex css values cannot be calculated properly and the incremental sequence of numbers as labels can help the user better mentally map the different available font sizes. ([#37038](https://github.com/WordPress/gutenberg/pull/37038))
Expand Down
7 changes: 7 additions & 0 deletions packages/components/src/date-time/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,10 @@ A callback function which receives a Date object representing a day as an argume

- Type: `Function`
- Required: No

### onMonthPreviewed

A callback invoked when selecting the previous/next month in the date picker. The callback receives the new month date in the ISO format as an argument.

- Type: `Function`
- Required: No
151 changes: 67 additions & 84 deletions packages/components/src/date-time/date.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@ import DayPickerSingleDateController from 'react-dates/lib/components/DayPickerS
/**
* WordPress dependencies
*/
import { Component, createRef, useEffect, useRef } from '@wordpress/element';
import { useEffect, useRef } from '@wordpress/element';
import { isRTL, _n, sprintf } from '@wordpress/i18n';

/**
* Internal dependencies
*/
import { getMomentDate } from './utils';

/**
* Module Constants
*/
Expand Down Expand Up @@ -70,43 +75,40 @@ function DatePickerDay( { day, events = [] } ) {
);
}

class DatePicker extends Component {
constructor() {
super( ...arguments );

this.onChangeMoment = this.onChangeMoment.bind( this );
this.nodeRef = createRef();
this.onMonthPreviewedHandler = this.onMonthPreviewedHandler.bind(
this
);
}

onMonthPreviewedHandler( newMonthDate ) {
this.props.onMonthPreviewed?.( newMonthDate.toISOString() );
this.keepFocusInside();
}
function DatePicker( {
currentDate,
onChange,
events,
isInvalidDate,
onMonthPreviewed,
} ) {
const nodeRef = useRef();
const onMonthPreviewedHandler = ( newMonthDate ) => {
onMonthPreviewed?.( newMonthDate.toISOString() );
keepFocusInside();
};

/*
* Todo: We should remove this function ASAP.
* It is kept because focus is lost when we click on the previous and next month buttons.
* This focus loss closes the date picker popover.
* Ideally we should add an upstream commit on react-dates to fix this issue.
*/
keepFocusInside() {
if ( ! this.nodeRef.current ) {
const keepFocusInside = () => {
if ( ! nodeRef.current ) {
return;
}

const { ownerDocument } = this.nodeRef.current;
const { ownerDocument } = nodeRef.current;
const { activeElement } = ownerDocument;

// If focus was lost.
if (
! activeElement ||
! this.nodeRef.current.contains( ownerDocument.activeElement )
! nodeRef.current.contains( ownerDocument.activeElement )
) {
// Retrieve the focus region div.
const focusRegion = this.nodeRef.current.querySelector(
const focusRegion = nodeRef.current.querySelector(
'.DayPicker_focusRegion'
);
if ( ! focusRegion ) {
Expand All @@ -115,11 +117,9 @@ class DatePicker extends Component {
// Keep the focus on focus region.
focusRegion.focus();
}
}

onChangeMoment( newDate ) {
const { currentDate, onChange } = this.props;
};

const onChangeMoment = ( newDate ) => {
// If currentDate is null, use now as momentTime to designate hours, minutes, seconds.
const momentDate = currentDate ? moment( currentDate ) : moment();
const momentTime = {
Expand All @@ -131,71 +131,54 @@ class DatePicker extends Component {
onChange( newDate.set( momentTime ).format( TIMEZONELESS_FORMAT ) );

// Keep focus on the date picker.
this.keepFocusInside();
}

/**
* Create a Moment object from a date string. With no currentDate supplied, default to a Moment
* object representing now. If a null value is passed, return a null value.
*
* @param {?string} currentDate Date representing the currently selected date or null to signify no selection.
* @return {?moment.Moment} Moment object for selected date or null.
*/
getMomentDate( currentDate ) {
if ( null === currentDate ) {
return null;
}
return currentDate ? moment( currentDate ) : moment();
}
keepFocusInside();
};

getEventsPerDay( day ) {
if ( ! this.props.events?.length ) {
const getEventsPerDay = ( day ) => {
if ( ! events?.length ) {
return [];
}

return this.props.events.filter( ( eventDay ) =>
return events.filter( ( eventDay ) =>
day.isSame( eventDay.date, 'day' )
);
}

render() {
const { currentDate, isInvalidDate } = this.props;
const momentDate = this.getMomentDate( currentDate );

return (
<div className="components-datetime__date" ref={ this.nodeRef }>
<DayPickerSingleDateController
date={ momentDate }
daySize={ 30 }
focused
hideKeyboardShortcutsPanel
// This is a hack to force the calendar to update on month or year change
// https://github.com/airbnb/react-dates/issues/240#issuecomment-361776665
key={ `datepicker-controller-${
momentDate ? momentDate.format( 'MM-YYYY' ) : 'null'
}` }
noBorder
numberOfMonths={ 1 }
onDateChange={ this.onChangeMoment }
transitionDuration={ 0 }
weekDayFormat="ddd"
dayAriaLabelFormat={ ARIAL_LABEL_TIME_FORMAT }
isRTL={ isRTL() }
isOutsideRange={ ( date ) => {
return isInvalidDate && isInvalidDate( date.toDate() );
} }
onPrevMonthClick={ this.onMonthPreviewedHandler }
onNextMonthClick={ this.onMonthPreviewedHandler }
renderDayContents={ ( day ) => (
<DatePickerDay
day={ day }
events={ this.getEventsPerDay( day ) }
/>
) }
/>
</div>
);
}
};

const momentDate = getMomentDate( currentDate );

return (
<div className="components-datetime__date" ref={ nodeRef }>
<DayPickerSingleDateController
date={ momentDate }
daySize={ 30 }
focused
hideKeyboardShortcutsPanel
// This is a hack to force the calendar to update on month or year change
// https://github.com/airbnb/react-dates/issues/240#issuecomment-361776665
key={ `datepicker-controller-${
momentDate ? momentDate.format( 'MM-YYYY' ) : 'null'
}` }
noBorder
numberOfMonths={ 1 }
onDateChange={ onChangeMoment }
transitionDuration={ 0 }
weekDayFormat="ddd"
dayAriaLabelFormat={ ARIAL_LABEL_TIME_FORMAT }
isRTL={ isRTL() }
isOutsideRange={ ( date ) => {
return isInvalidDate && isInvalidDate( date.toDate() );
} }
onPrevMonthClick={ onMonthPreviewedHandler }
onNextMonthClick={ onMonthPreviewedHandler }
renderDayContents={ ( day ) => (
<DatePickerDay
day={ day }
events={ getEventsPerDay( day ) }
/>
) }
/>
</div>
);
}

export default DatePicker;
32 changes: 3 additions & 29 deletions packages/components/src/date-time/test/date.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,32 +31,6 @@ describe( 'DatePicker', () => {
expect( moment.isMoment( date ) ).toBe( true );
} );

describe( 'getMomentDate', () => {
it( 'should return a Moment object representing a given date string', () => {
const currentDate = '1986-10-18T23:00:00';
const wrapper = shallow( <DatePicker /> );
const momentDate = wrapper.instance().getMomentDate( currentDate );

expect( moment.isMoment( momentDate ) ).toBe( true );
expect( momentDate.isSame( moment( currentDate ) ) ).toBe( true );
} );

it( 'should return null when given a null agrument', () => {
const currentDate = null;
const wrapper = shallow( <DatePicker /> );
const momentDate = wrapper.instance().getMomentDate( currentDate );

expect( momentDate ).toBeNull();
} );

it( 'should return a Moment object representing now when given an undefined argument', () => {
const wrapper = shallow( <DatePicker /> );
const momentDate = wrapper.instance().getMomentDate();

expect( moment.isMoment( momentDate ) ).toBe( true );
} );
} );

describe( 'onChangeMoment', () => {
it( 'should call onChange with a formated date of the input', () => {
const onChangeSpy = jest.fn();
Expand All @@ -69,7 +43,7 @@ describe( 'DatePicker', () => {
);
const newDate = moment();

wrapper.instance().onChangeMoment( newDate );
wrapper.childAt( 0 ).props().onDateChange( newDate );

expect( onChangeSpy ).toHaveBeenCalledWith(
newDate.format( TIMEZONELESS_FORMAT )
Expand All @@ -87,7 +61,7 @@ describe( 'DatePicker', () => {
minutes: current.minutes(),
seconds: current.seconds(),
} );
wrapper.instance().onChangeMoment( newDate );
wrapper.childAt( 0 ).props().onDateChange( newDate );

expect(
moment( onChangeSpyArgument ).isSame(
Expand All @@ -110,7 +84,7 @@ describe( 'DatePicker', () => {
minutes: current.minutes(),
seconds: current.seconds(),
} );
wrapper.instance().onChangeMoment( newDate );
wrapper.childAt( 0 ).props().onDateChange( newDate );

expect(
moment( onChangeSpyArgument ).isSame(
Expand Down
32 changes: 32 additions & 0 deletions packages/components/src/date-time/test/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* External dependencies
*/
import moment from 'moment';

/**
* Internal dependencies
*/
import { getMomentDate } from '../utils';

describe( 'getMomentDate', () => {
it( 'should return a Moment object representing a given date string', () => {
const currentDate = '1986-10-18T23:00:00';
const momentDate = getMomentDate( currentDate );

expect( moment.isMoment( momentDate ) ).toBe( true );
expect( momentDate.isSame( moment( currentDate ) ) ).toBe( true );
} );

it( 'should return null when given a null argument', () => {
const currentDate = null;
const momentDate = getMomentDate( currentDate );

expect( momentDate ).toBeNull();
} );

it( 'should return a Moment object representing now when given an undefined argument', () => {
const momentDate = getMomentDate();

expect( moment.isMoment( momentDate ) ).toBe( true );
} );
} );
18 changes: 18 additions & 0 deletions packages/components/src/date-time/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* External dependencies
*/
import moment from 'moment';

/**
* Create a Moment object from a date string. With no date supplied, default to a Moment
* object representing now. If a null value is passed, return a null value.
*
* @param {?string} date Date representing the currently selected date or null to signify no selection.
* @return {?moment.Moment} Moment object for selected date or null.
*/
export const getMomentDate = ( date ) => {
if ( null === date ) {
return null;
}
return date ? moment( date ) : moment();
};

0 comments on commit 92923c6

Please sign in to comment.