UNPKG

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
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, };