terra-clinical-onset-picker
Version:
The terra-clinical-onset-picker component provides users a way to enter or select an approximate date for use in onset scenarios.
565 lines (529 loc) • 19.8 kB
JSX
import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import classNames from 'classnames/bind';
import DatePicker from 'terra-date-picker';
import Field from 'terra-form-field';
import FieldSet from 'terra-form-fieldset';
import InputField from 'terra-form-input/lib/InputField';
import SelectField from 'terra-form-select/lib/SelectField';
import { injectIntl } from 'react-intl';
import ThemeContext from 'terra-theme-context';
import OnsetUtils from './OnsetUtils';
import styles from './OnsetPicker.module.scss';
const cx = classNames.bind(styles);
const AgeUnits = {
WEEKS: 'weeks',
MONTHS: 'months',
YEARS: 'years',
};
const GranularityOptions = {
AGE: 'age',
YEAR: 'year',
MONTH: 'month',
DATE: 'date',
};
const { PrecisionOptions } = OnsetUtils;
const DATE_FORMAT = 'YYYY-MM-DD';
const propTypes = {
/**
* The date unit of the age value. One of `weeks`, `months`, or `years`.
*/
ageUnit: PropTypes.oneOf(['weeks', 'months', 'years']),
/**
* The ISO 8601 **DATE ONLY** string representation of the birth date to calculate an onset date for the `age` precision.
* Also limits the earliest possible date that can be selected for an onset date for `year`, `month`, and `date` precision.
*/
birthdate: PropTypes.string.isRequired,
/**
* The granularity of the onset date. One of `age`, `year`, `month`, or `date` is accepted.
*/
granularity: PropTypes.oneOf(['age', 'year', 'month', 'date']),
/**
* The id of the onset picker. Used as the base for other required id/name in sub-components.
*/
id: PropTypes.string.isRequired,
/**
* The precision of the onset date. This should be one of precisions passed to the precisionSet prop.
* One of `on/at`, `about`, `before`, `after`, or `unknown`.
*/
precision: PropTypes.oneOf(['on/at', 'about', 'before', 'after', 'unknown']),
/**
* The set of precisions that can be used with the onset picker.
* Combination of `on/at`, `about`, `before`, `after`, and `unknown`.
* Order of precisions determines order in precision select.
*/
precisionSet: PropTypes.arrayOf(PropTypes.oneOf(['on/at', 'about', 'before', 'after', 'unknown'])),
/**
* The ISO 8601 **DATE ONLY** string representation of the onset date to view/modify.
*/
onsetDate: PropTypes.string,
/**
* A callback function to execute when any value of the onsetDate is changed.
* The first parameter is a Object that contains `precision`, `granularity`, `onsetDate`, `onsetDateMetadata`, and `ageUnit`.
* `ageUnit` is only present if the granularity is 'age'.
* `onsetDateMetadata` contains additional properties about the state of the onset date.
*/
onsetOnChange: PropTypes.func,
/**
* Legend for the Onset Picker field group.
*/
legend: PropTypes.string,
/**
* Whether or not the legend is visible. Use this props to hide a legend while still creating it on the DOM for accessibility.
*/
isLegendHidden: PropTypes.bool,
/**
* @private
* The intl object containing translations. This is retrieved from the context automatically by injectIntl.
*/
intl: PropTypes.shape({ formatMessage: PropTypes.func }).isRequired,
/**
* Whether the field is required.
*/
required: PropTypes.bool,
};
const defaultProps = {
precisionSet: ['on/at', 'about', 'before', 'after', 'unknown'],
isLegendHidden: false,
required: false,
};
class OnsetPicker extends React.Component {
constructor(props) {
super(props);
this.changeGranularity = this.changeGranularity.bind(this);
this.changePrecision = this.changePrecision.bind(this);
this.changeAge = this.changeAge.bind(this);
this.changeAgeUnit = this.changeAgeUnit.bind(this);
this.changeYear = this.changeYear.bind(this);
this.changeMonth = this.changeMonth.bind(this);
this.changeDate = this.changeDate.bind(this);
this.handleOnsetUpdate = this.handleOnsetUpdate.bind(this);
let ageValues = {};
if (this.props.ageUnit) {
ageValues = {
age: this.props.onsetDate ? moment(this.props.onsetDate).diff(moment(this.props.birthdate), this.props.ageUnit) : undefined,
ageUnit: this.props.ageUnit,
};
} else if (this.props.onsetDate) {
ageValues = OnsetUtils.onsetToAge(this.props.birthdate, moment(this.props.onsetDate));
}
const birthMoment = moment(props.birthdate).startOf('day');
const currentMoment = moment().startOf('day');
/* The granularity is added so that if 'age' is passed as granularity with invalid birthdate,
then the granularity is set to default i.e 'undefined' */
const granularity = (currentMoment.diff(birthMoment, 'weeks') !== 0 || this.props.granularity !== GranularityOptions.AGE) ? this.props.granularity : undefined;
this.state = {
granularity,
precision: this.props.precision,
onsetDate: this.props.onsetDate ? moment(this.props.onsetDate) : undefined,
age: ageValues.age,
ageUnit: ageValues.ageUnit,
year: undefined,
month: undefined,
};
}
componentDidMount() {
if (this.props.onsetDate) {
const onsetMoment = moment(this.props.onsetDate);
const onsetYear = moment().year(onsetMoment.year());
const firstDayOfYear = onsetYear.startOf('year').format(DATE_FORMAT);
const firstDayOfMonth = onsetYear.month(onsetMoment.month()).startOf('month')
.format(DATE_FORMAT);
if (this.props.granularity === GranularityOptions.YEAR && this.props.onsetDate === firstDayOfYear) {
this.setState({
granularity: GranularityOptions.YEAR,
}, this.handleOnsetUpdate);
} else if (this.props.granularity === GranularityOptions.MONTH && this.props.onsetDate === firstDayOfMonth) {
this.setState({
granularity: GranularityOptions.MONTH,
}, this.handleOnsetUpdate);
} else if (this.props.granularity === GranularityOptions.AGE && this.state.age && this.state.ageUnit) {
this.setState({
granularity: GranularityOptions.AGE,
}, this.handleOnsetUpdate);
} else {
this.setState({
granularity: GranularityOptions.DATE,
}, this.handleOnsetUpdate);
}
}
}
/**
* Trigger supplied callback function with an object of the current state data
*/
handleOnsetUpdate() {
if (this.props.onsetOnChange === undefined) {
return;
}
const onsetObject = {
precision: this.state.precision,
onsetDate: this.state.onsetDate ? this.state.onsetDate.format(DATE_FORMAT) : undefined,
onsetDateMetadata: this.state.onsetDateMetadata,
granularity: this.state.precision !== PrecisionOptions.UNKNOWN ? this.state.granularity : '',
};
if (this.state.granularity === GranularityOptions.AGE && this.state.precision !== PrecisionOptions.UNKNOWN) {
onsetObject.ageUnit = this.state.ageUnit;
}
this.props.onsetOnChange(onsetObject);
}
getYearInput(intl, id) {
return (
<SelectField
required={this.props.required}
className={cx('field-inline', 'year')}
value={this.state.onsetDate ? this.state.onsetDate.year().toString() : undefined}
label={intl.formatMessage({ id: 'Terra.onsetPicker.year' })}
labelAttrs={{
id: `${id}-year-select-label`,
}}
onChange={this.changeYear}
placeholder={intl.formatMessage({ id: 'Terra.onsetPicker.year' })}
selectId={`${id}-year-select`}
>
{OnsetUtils.allowedYears(this.state.granularity, this.state.month, this.props.birthdate)
.map(year => <SelectField.Option value={year.value} display={year.display} key={year.value} />)}
</SelectField>
);
}
/**
* Change state for granularity
*
* @param {granularity} - New granularity value
*/
changeGranularity(granularity) {
if (granularity !== this.state.granularity) {
// RESET DATA IF GRANULARITY IS CHANGED.
this.setState((prevState) => ({
granularity,
precision: prevState.precision,
onsetDate: undefined,
age: undefined,
ageUnit: undefined,
year: undefined,
month: undefined,
}),
this.handleOnsetUpdate);
}
}
/**
* Change state for precision
*
* @param {precision} - New precision value
*/
changePrecision(precision) {
if (precision === PrecisionOptions.UNKNOWN) {
this.setState(() => ({
precision,
onsetDate: undefined,
}), this.handleOnsetUpdate);
} else {
this.setState({ precision }, this.handleOnsetUpdate);
}
}
/**
* Change state for the age when using AGE granularity, and update onset date
*
* @param {event} - Triggered change event
*/
changeAge(event) {
let age;
if (event.target.value) {
age = Number(event.target.value);
this.setState((prevState) => {
// Check if date can be calculated
const momentBirthDate = moment(this.props.birthdate);
let ageDate = moment(momentBirthDate).add(age, prevState.ageUnit);
// Add 1 day to onset date if birthdate is a leap day or onset month has less days than birth month.
if (Number.isInteger(age) && prevState.ageUnit) {
if (ageDate.daysInMonth() < momentBirthDate.daysInMonth() && ageDate.format(DATE_FORMAT) === moment().year(ageDate.year()).month(ageDate.month()).endOf('month')
.format(DATE_FORMAT)) {
ageDate = ageDate.add('1', 'days');
}
} else {
ageDate = undefined;
}
// Check if date is valid
const validDate = ageDate && ageDate >= moment(this.props.birthdate) && ageDate <= moment();
return {
age,
onsetDate: validDate ? ageDate : undefined,
};
}, this.handleOnsetUpdate);
} else {
this.setState(() => ({
age: undefined,
onsetDate: undefined,
}), this.handleOnsetUpdate);
}
}
/**
* Change state for age unit when using AGE granularity, and update onset date
*
* @param {ageUnit} - New ageUnit
*/
changeAgeUnit(ageUnit) {
const validAge = Number.isInteger(this.state.age);
// Check if date can be calculated
const momentBirthDate = moment(this.props.birthdate);
let ageDate = moment(momentBirthDate).add(this.state.age, ageUnit);
if (validAge && ageUnit) {
if (ageDate.daysInMonth() < momentBirthDate.daysInMonth() && ageDate.format(DATE_FORMAT) === moment().year(ageDate.year()).month(ageDate.month()).endOf('month')
.format(DATE_FORMAT)) {
ageDate = ageDate.add('1', 'days');
}
} else {
ageDate = undefined;
}
// Check if date is valid
const validDate = (this.state.age !== undefined) ? (ageDate && ageDate >= moment(this.props.birthdate) && ageDate <= moment()) : false;
this.setState(() => ({
ageUnit,
onsetDate: validDate ? ageDate : undefined,
}), this.handleOnsetUpdate);
}
/**
* Update onset date when year changes
*
* @param {year} - New year value
*/
changeYear(year) {
if (this.state.granularity === GranularityOptions.MONTH) {
this.setState((prevState) => ({
year,
onsetDate: (prevState.month ? moment().month(prevState.month).year(year).startOf('month')
: undefined),
}), this.handleOnsetUpdate);
} else {
this.setState(() => {
const newDate = moment().year(year).startOf('year');
return { onsetDate: newDate };
}, this.handleOnsetUpdate);
}
}
/**
* Update onset date when month changes
*
* @param {month} - New month value
*/
changeMonth(month) {
this.setState((prevState) => ({
month,
onsetDate: (prevState.year ? moment().month(month).year(prevState.year).startOf('month')
: undefined),
}), this.handleOnsetUpdate);
}
/**
* Update onset date when date changes
*
* @param {event} - Triggered change event
* @param {string} - New date input
* @param {object} - Metadata about date
*/
changeDate(event, _, metadata) {
if (!metadata.isCompleteValue) {
this.setState({ onsetDate: undefined, onsetDateMetadata: metadata }, this.handleOnsetUpdate);
} else {
this.setState({ onsetDate: moment(metadata.iSO), onsetDateMetadata: metadata }, this.handleOnsetUpdate);
}
}
render() {
const {
ageUnit,
birthdate,
granularity,
precision,
precisionSet,
onsetDate,
onsetOnChange,
legend,
intl,
isLegendHidden,
required,
...customProps
} = this.props;
let granularitySelect = null;
const granularityOptions = [];
if (this.state.precision !== PrecisionOptions.UNKNOWN) {
const birthMoment = moment(birthdate).startOf('day'); // startOf to clear time from values
const currentMoment = moment().startOf('day');
if (currentMoment.diff(birthMoment, 'weeks') !== 0) {
granularityOptions.push(
<SelectField.Option
value={GranularityOptions.AGE}
display={intl.formatMessage({ id: 'Terra.onsetPicker.age' })}
key={GranularityOptions.AGE}
/>,
);
}
if ((currentMoment.diff(birthMoment, 'years') === 0 && currentMoment.year() !== birthMoment.year()) || (currentMoment.diff(birthMoment, 'years') !== 0)) {
granularityOptions.push(
<SelectField.Option
value={GranularityOptions.YEAR}
display={intl.formatMessage({ id: 'Terra.onsetPicker.year' })}
key={GranularityOptions.YEAR}
/>,
);
}
if ((currentMoment.diff(birthMoment, 'months') === 0 && currentMoment.month() !== birthMoment.month()) || (currentMoment.diff(birthMoment, 'months') !== 0)) {
granularityOptions.push(
<SelectField.Option
value={GranularityOptions.MONTH}
display={intl.formatMessage({ id: 'Terra.onsetPicker.month' })}
key={GranularityOptions.MONTH}
/>,
);
}
granularitySelect = (
<SelectField
className={cx('field-inline', 'granularity')}
value={this.state.granularity}
label={intl.formatMessage({ id: 'Terra.onsetPicker.granularity' })}
labelAttrs={{
id: `${this.props.id}-granularity-select-label`,
}}
onChange={this.changeGranularity}
placeholder={intl.formatMessage({ id: 'Terra.onsetPicker.granularity' })}
selectId={`${this.props.id}-granularity-select`}
>
{granularityOptions}
<SelectField.Option
value={GranularityOptions.DATE}
display={intl.formatMessage({ id: 'Terra.onsetPicker.date' })}
key={GranularityOptions.DATE}
/>
</SelectField>
);
}
let ageInput;
let ageUnitSelect;
if (this.state.granularity === GranularityOptions.AGE) {
ageInput = (
<InputField
required={required}
className={cx('field-inline', 'age')}
defaultValue={this.state.age}
inputAttrs={{
type: 'number',
min: 0,
max: OnsetUtils.allowedAge(this.props.birthdate, this.state.ageUnit),
step: 1,
}}
inputId={`${this.props.id}-age-input`}
label={intl.formatMessage({ id: 'Terra.onsetPicker.age' })}
labelAttrs={{
id: `${this.props.id}-age-input-label`,
}}
onChange={this.changeAge}
/>
);
ageUnitSelect = (
<SelectField
required={required}
className={cx('field-inline', 'age-unit')}
defaultValue={this.state.ageUnit}
label={intl.formatMessage({ id: 'Terra.onsetPicker.agePrecision' })}
labelAttrs={{
id: `${this.props.id}-age-unit-select-label`,
}}
onChange={this.changeAgeUnit}
placeholder={intl.formatMessage({ id: 'Terra.onsetPicker.agePrecision' })}
selectId={`${this.props.id}-age-unit-select`}
>
{OnsetUtils.allowedAgeUnits(this.props.birthdate, intl)
.map(unit => <SelectField.Option value={unit.value} display={unit.display} key={unit.value} />)}
</SelectField>
);
}
let monthSelect;
if (this.state.granularity === GranularityOptions.MONTH) {
monthSelect = (
<React.Fragment>
<SelectField
required={required}
className={cx('field-inline', 'month')}
value={this.state.onsetDate ? this.state.onsetDate.month().toString() : undefined}
label={intl.formatMessage({ id: 'Terra.onsetPicker.month' })}
labelAttrs={{
id: `${this.props.id}-month-select-label`,
}}
onChange={this.changeMonth}
placeholder={intl.formatMessage({ id: 'Terra.onsetPicker.month' })}
selectId={`${this.props.id}-month-select`}
>
{OnsetUtils.allowedMonths(intl, this.props.birthdate, this.state.year)
.map(month => <SelectField.Option value={month.value} display={month.display} key={month.value} />)}
</SelectField>
{this.getYearInput(intl, this.props.id)}
</React.Fragment>
);
}
let yearSelect;
if (this.state.granularity === GranularityOptions.YEAR) {
yearSelect = this.getYearInput(intl, this.props.id);
}
let dateSelect;
if (this.state.granularity === GranularityOptions.DATE) {
dateSelect = (
<Field
className={cx('field-inline', 'date')}
label={intl.formatMessage({ id: 'Terra.onsetPicker.date' })}
labelAttrs={{
'aria-hidden': 'true',
id: `${this.props.id}-date-input-label`,
}}
>
<DatePicker
required={required}
onChangeRaw={this.changeDate}
minDate={this.props.birthdate}
maxDate={moment().format(DATE_FORMAT)}
selectedDate={this.state.onsetDate ? this.state.onsetDate.format(DATE_FORMAT) : undefined}
name={`${this.props.id}-date-input`}
/>
</Field>
);
}
const theme = this.context;
return (
<div id={this.props.id} {...customProps}>
<FieldSet className={cx('fieldset', theme.className)} legend={legend} isLegendHidden={isLegendHidden}>
{/* Precision */}
<SelectField
className={cx('field-inline', 'precision')}
defaultValue={this.state.precision}
label={intl.formatMessage({ id: 'Terra.onsetPicker.precision' })}
labelAttrs={{
id: `${this.props.id}-precision-select-label`,
}}
onChange={this.changePrecision}
placeholder={intl.formatMessage({ id: 'Terra.onsetPicker.precision' })}
selectId={`${this.props.id}-precision-select`}
>
{OnsetUtils.allowedPrecisions(intl, this.props.precisionSet)
.map(precisionEntry => <SelectField.Option value={precisionEntry.value} display={precisionEntry.display} key={precisionEntry.value} />)}
</SelectField>
{granularitySelect}
{(this.state.precision !== PrecisionOptions.UNKNOWN) && (
<div id="dynamic_content">
{ageInput}
{ageUnitSelect}
{monthSelect}
{yearSelect}
{dateSelect}
</div>
)}
</FieldSet>
</div>
);
}
}
OnsetPicker.propTypes = propTypes;
OnsetPicker.defaultProps = defaultProps;
OnsetPicker.contextType = ThemeContext;
export default injectIntl(OnsetPicker);
export {
AgeUnits,
GranularityOptions,
PrecisionOptions,
};