src/applications/vaos/components/calendar/CalendarWidget.jsx
/**
* Shared calendar widget component used by the VAOS application.
* @module components/calendar
*/
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import classNames from 'classnames';
import CalendarRow from './CalendarRow';
import CalendarNavigation from './CalendarNavigation';
import CalendarWeekdayHeader from './CalendarWeekdayHeader';
/**
* @const {number} DEFAULT_MAX_DAYS_AHEAD
* @default 90
*/
const DEFAULT_MAX_DAYS_AHEAD = 90;
/**
* Pads single digit number with zero
*
* @param {number} num A given number
* @param {number} size A given size
* @returns {string} A string e.g. 03
*/
function pad(num, size) {
let s = num.toString();
while (s.length < size) s = `0${s}`;
return s;
}
/**
* Gets the first day of the month
*
* @param {Moment} momentDate A given moment date
* @returns {number} A number of the first day of the month
*/
function getFirstDayOfMonth(momentDate) {
return Number(momentDate.startOf('month').format('d'));
}
/**
* Gets the maximum month based on inputs
*
* @param {string} maxDate YYYY-DD-MM
* @returns {string} YYYY-MM
*/
export function getMaxMonth(maxDate, overrideMaxDays) {
const defaultMaxMonth = moment()
.add(DEFAULT_MAX_DAYS_AHEAD, 'days')
.format('YYYY-MM');
const maxMonth = moment(maxDate).startOf('month');
if (maxDate && (maxMonth.isAfter(defaultMaxMonth) || overrideMaxDays)) {
return maxMonth.format('YYYY-MM');
}
// If no available dates array provided, set max to default from now
return defaultMaxMonth;
}
/**
* Gets the initial blank cells
*
* @param {Moment} momentDate A given moment date
* @param {boolean} [showWeekends] Whether to show full weekend slots or not
* @returns {Array} Array of blanks to push start day position
*/
function getInitialBlankCells(momentDate, showWeekends) {
const firstDay = getFirstDayOfMonth(momentDate);
const blanks = [];
if (!showWeekends && (firstDay === 0 || firstDay === 6)) {
return blanks;
}
const weekStart = showWeekends ? 0 : 1;
for (let i = weekStart; i < firstDay; i++) {
blanks.push(null);
}
return blanks;
}
/**
* Gets the days of the week
*
* @param {Moment} momentDate A given moment date
* @param {boolean} [showWeekend] Whether to show full weekend slots or not
* @returns {Array} Array of days
*/
function getDaysOfTheWeek(momentDate, showWeekend) {
const daysToShow = [];
let dayOfWeek;
if (!showWeekend) {
dayOfWeek = getFirstDayOfMonth(momentDate);
}
/**
* Create array of days of the week. If the showing the weekend, don't check
* for Sunday (0) or Saturday (6)
*/
for (let i = 1; i <= momentDate.daysInMonth(); i++) {
if (showWeekend) {
daysToShow.push(
`${momentDate.format('YYYY')}-${momentDate.format('MM')}-${pad(i, 2)}`,
);
} else {
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
daysToShow.push(
`${momentDate.format('YYYY')}-${momentDate.format('MM')}-${pad(
i,
2,
)}`,
);
}
dayOfWeek = dayOfWeek + 1 > 6 ? 0 : dayOfWeek + 1;
}
}
return daysToShow;
}
/**
* Gets cells for days of a week
*
* @param {Moment} momentDate A given moment date
* @param {boolean} [showWeekend] Whether to show full weekend slots or not
* @returns {Array} Array of cells
*/
function getCells(momentDate, showWeekend) {
const cells = [...getInitialBlankCells(momentDate, showWeekend)];
const daysToShow = showWeekend ? 7 : 5;
cells.push(...getDaysOfTheWeek(momentDate, showWeekend));
// Add blank cells to end of month
while (cells.length % daysToShow !== 0) cells.push(null);
return cells;
}
/**
* Parses calendar weeks and returns array
*
* @param {Moment} momentDate A given moment date
* @param {boolean} [showWeekend] Whether to show full weekend slots or not
* @returns {Array} Array of weeks
*/
export function getCalendarWeeks(momentDate, showWeekend) {
const dateCells = getCells(momentDate, showWeekend);
const weeks = [];
const daysToShow = showWeekend ? 7 : 5;
let currentWeek = [];
for (let index = 0; index < dateCells.length; index++) {
if (index > 0 && index % daysToShow === 0) {
weeks.push(currentWeek);
currentWeek = [dateCells[index]];
} else {
currentWeek.push(dateCells[index]);
}
}
weeks.push(currentWeek);
return weeks;
}
/**
* Click event handler for previous calendar entries
*
* @param {Function} onClickPrev Given function when clicking previous button
* on calendar
* @param {Array} months Given months array
* @param {Function} setMonths Given months array
*/
function handlePrev(onClickPrev, months, setMonths) {
const updatedMonths = months.map(m => m.subtract(1, 'months'));
if (onClickPrev) {
onClickPrev(
updatedMonths[0].format('YYYY-MM-DD'),
updatedMonths[updatedMonths.length - 1]
.endOf('month')
.format('YYYY-MM-DD'),
);
}
setMonths(updatedMonths);
}
/**
* Handle Next Function
*
* @param {Function} onClickNext Given function when clicking next button
* on calendar
* @param {Array} months Given months array
* @param {Function} setMonths Months to set array
*/
function handleNext(onClickNext, months, setMonths) {
const updatedMonths = months.map(m => m.add(1, 'months'));
if (onClickNext) {
onClickNext(
updatedMonths[0].format('YYYY-MM-DD'),
updatedMonths[updatedMonths.length - 1]
.endOf('month')
.format('YYYY-MM-DD'),
);
}
setMonths(updatedMonths);
}
/**
* Calendar widget
*
* @param {Object} props
* @param {Array<Slot>} props.availableSlots
* @param {string} props.id
* @param {boolean} props.disabled
* @param {string} props.disabledMessage
* @param {string} props.maxDate YYYY-MM-DD
* @param {number} props.maxSelections
* @param {string} props.maxSelectionsError
* @param {string} props.minDate YYYY-MM-DD
* @param {Function} props.onChange
* @param {Function} props.onNextMonth
* @param {Function} props.onPreviousMonth
* @param {Function} props.renderOptions
* @param {Function} props.renderIndicator
* @param {Function} props.renderSelectedLabel
* @param {boolean} props.required
* @param {string} props.requiredMessage
* @param {boolean} props.showValidation
* @param {string} props.startMonth YYYY-MM
* @param {string} props.timezone America/Denver
* @param {Array<string>} props.value
* @param {boolean} [props.showWeekends=false] Whether to show full weekend slots or not
* @param {boolean} [props.overrideMaxDays=false] Disables the default max days value
* @returns {JSX.Element} props.Calendar Calendar Widget
*/
function CalendarWidget({
availableSlots,
id,
disabled,
disabledMessage,
maxDate,
maxSelections = 1,
maxSelectionsError = "You've exceeded the maximum number of selections",
minDate,
onChange,
onNextMonth,
onPreviousMonth,
overrideMaxDays = false,
renderOptions,
renderIndicator,
renderSelectedLabel,
required,
requiredMessage = 'Please select a date',
showValidation,
startMonth,
timezone,
value = [],
showWeekends = false,
}) {
const [currentlySelectedDate, setCurrentlySelectedDate] = useState(() => {
if (value.length > 0) {
return value[0].split('T')[0];
}
return null;
});
const currentDate = moment();
const maxMonth = getMaxMonth(maxDate, overrideMaxDays);
const [months, setMonths] = useState([moment(startMonth || minDate)]);
const exceededMaximumSelections = value.length > maxSelections;
const hasError = (required && showValidation) || exceededMaximumSelections;
const calendarCss = classNames('vaos-calendar__calendars vads-u-flex--1', {
'vaos-calendar__disabled': disabled,
'usa-input-error': hasError,
});
// declare const from renderMonth here
const nextMonthToDisplay = months[months.length - 1]
?.clone()
.add(1, 'months')
.format('YYYY-MM');
const prevDisabled =
disabled || months[0].format('YYYY-MM') <= currentDate.format('YYYY-MM');
const nextDisabled = disabled || nextMonthToDisplay > maxMonth;
return (
<div className="vaos-calendar vads-u-margin-top--4 vads-u-display--flex">
{disabled && (
<div className="vaos-calendar__disabled-overlay">{disabledMessage}</div>
)}
<div className={calendarCss}>
{hasError && (
<span
className="vaos-calendar__validation-msg usa-input-error-message"
role="alert"
>
{showValidation && requiredMessage}
{exceededMaximumSelections && maxSelectionsError}
</span>
)}
{months.map(
(month, index) =>
month.format('YYYY-MM') <= maxMonth ? (
<div
key={`month-${index}`}
className="vaos-calendar__container vads-u-margin-bottom--3"
aria-labelledby={`h2-${month.format('YYYY-MM')}`}
role="table"
>
<>
{index === 0 && (
<CalendarNavigation
prevOnClick={() =>
handlePrev(onPreviousMonth, months, setMonths)
}
nextOnClick={() =>
handleNext(onNextMonth, months, setMonths)
}
momentMonth={month}
prevDisabled={prevDisabled}
nextDisabled={nextDisabled}
/>
)}
<hr aria-hidden="true" className="vads-u-margin-y--1" />
<CalendarWeekdayHeader showFullWeek={showWeekends} />
<div role="rowgroup">
{getCalendarWeeks(month, showWeekends).map(
(week, weekIndex) => (
<CalendarRow
availableSlots={availableSlots}
cells={week}
id={id}
timezone={timezone}
currentlySelectedDate={currentlySelectedDate}
handleSelectDate={date => {
if (
maxSelections === 1 &&
date === currentlySelectedDate
) {
onChange([]);
}
setCurrentlySelectedDate(
date === currentlySelectedDate ? null : date,
);
}}
handleSelectOption={date => {
if (maxSelections > 1) {
if (value.includes(date)) {
onChange(
value.filter(
selectedDate => selectedDate !== date,
),
);
} else {
onChange(value.concat(date));
}
} else {
onChange([date]);
}
}}
hasError={hasError}
key={`row-${weekIndex}`}
maxDate={maxDate}
maxSelections={maxSelections}
minDate={minDate}
rowNumber={weekIndex}
selectedDates={value}
renderIndicator={renderIndicator}
renderOptions={renderOptions}
renderSelectedLabel={renderSelectedLabel}
disabled={disabled}
showWeekends={showWeekends}
/>
),
)}
</div>
</>
</div>
) : null,
)}
</div>
</div>
);
}
CalendarWidget.propTypes = {
id: PropTypes.string.isRequired,
availableSlots: PropTypes.arrayOf(
PropTypes.shape({
start: PropTypes.string.isRequired,
end: PropTypes.string,
}),
),
disabled: PropTypes.bool,
disabledMessage: PropTypes.object,
maxDate: PropTypes.string,
maxSelections: PropTypes.number,
maxSelectionsError: PropTypes.string,
minDate: PropTypes.string,
overrideMaxDays: PropTypes.bool,
renderIndicator: PropTypes.func,
renderOptions: PropTypes.func,
renderSelectedLabel: PropTypes.func,
required: PropTypes.bool,
requiredMessage: PropTypes.string,
showValidation: PropTypes.bool,
showWeekends: PropTypes.bool,
startMonth: PropTypes.string,
timezone: PropTypes.string,
value: PropTypes.array,
onChange: PropTypes.func,
onNextMonth: PropTypes.func,
onPreviousMonth: PropTypes.func,
};
export default CalendarWidget;