import React from 'react'; import { Checkbox } from '@rmwc/checkbox'; import { FormField } from '@rmwc/formfield'; import { MenuSurface, MenuSurfaceAnchor } from '@rmwc/menu'; import { TextField, TextFieldHTMLProps, TextFieldProps } from '@rmwc/textfield'; import { DataTable, DataTableContent, DataTableHead, DataTableHeadCell, DataTableRow, DataTableBody, DataTableCell, } from '@rmwc/data-table'; import { FormattedMessage, FormattedDate, FormattedTime } from 'react-intl'; import { Callback, DayAlias, Timeslot, Availability } from '@tutorbook/model'; import { TimeUtils } from '@tutorbook/utils'; import { v4 as uuid } from 'uuid'; import styles from './schedule-input.module.scss'; interface ScheduleInputState { menuOpen: boolean; } type OverriddenProps = | 'textarea' | 'outlined' | 'readOnly' | 'onFocus' | 'onBlur'; interface UniqueScheduleInputProps { value: Availability; onChange: Callback; renderToPortal?: boolean; focused?: boolean; onFocused?: () => any; onBlurred?: () => any; } export type ScheduleInputProps = Omit< TextFieldHTMLProps, keyof UniqueScheduleInputProps | OverriddenProps > & Omit & UniqueScheduleInputProps; export default class ScheduleInput extends React.Component< ScheduleInputProps, ScheduleInputState > { /** * Timeslots that users can choose their availability from: either morning * (7am to 12pm), afternoon (12pm to 5pm), or evening (5pm to 10pm). */ private static times: Readonly<{ [label: string]: Timeslot }> = { morning: new Timeslot( TimeUtils.getDateWithTime(7), TimeUtils.getDateWithTime(12) ), afternoon: new Timeslot( TimeUtils.getDateWithTime(12), TimeUtils.getDateWithTime(17) ), evening: new Timeslot( TimeUtils.getDateWithTime(17), TimeUtils.getDateWithTime(22) ), }; /** * Gets all of the timeslots that are available for selection. Right now, our * selection tool emulates that of Wyzant (i.e. it's just a grid of times and * checkboxes; 'morning', 'afternoon', and 'evening' on the x-axis and the * various days on the y-axis). This returns those timeslots open for * selection (e.g. 'Mondays from 7am to 12pm' --> 'Mondays morning'). */ public static get timeslots(): Availability { const timeslots = new Availability(); for (let day = 0; day < 7; day += 1) Object.values(ScheduleInput.times).forEach((time: Timeslot) => timeslots.push( new Timeslot( TimeUtils.getNextDateWithDay(day as DayAlias, time.from), TimeUtils.getNextDateWithDay(day as DayAlias, time.to) ) ) ); return timeslots; } private menuTimeoutID?: number; private inputRef: React.RefObject = React.createRef(); public constructor(props: ScheduleInputProps) { super(props); this.state = { menuOpen: false }; this.toggleAllChecked = this.toggleAllChecked.bind(this); this.openMenu = this.openMenu.bind(this); this.closeMenu = this.closeMenu.bind(this); } public componentDidUpdate(): void { const { focused } = this.props; if (focused && this.inputRef.current) this.inputRef.current.focus(); } private get allTimeslotsChecked(): boolean { const { value } = this.props; return value.equalTo(ScheduleInput.timeslots); } /** * Returns whether or not a checkbox should be checked by seeing if the given * timeslot exists in `this.state.availability`. If it does, this function * returns `true`. If not, `false`. */ private isChecked(time: Timeslot): boolean { const { value } = this.props; return value.hasTimeslot(time); } /** * We clear the timeout set by `this.closeMenu` to ensure that they user * doesn't get a blip where the schedule input menu disappears and reappears * abruptly. * @todo Perhaps we can remove this workaround by passing a callback to * `this.setState()`. * @see {@link https://bit.ly/2x9eM27} */ private openMenu(): void { window.clearTimeout(this.menuTimeoutID); const { menuOpen } = this.state; if (!menuOpen) this.setState({ menuOpen: true }); } /** * We use `setTimeout` and `clearTimeout` to wait a "tick" on a blur event * before toggling. Waiting ensures that the user hasn't clicked on the * schedule input menu (and thus called `this.openMenu`). * @todo Perhaps we can remove this workaround by passing a callback to * `this.setState()`. * @see {@link https://bit.ly/2x9eM27} */ private closeMenu(): void { this.menuTimeoutID = window.setTimeout(() => { const { menuOpen } = this.state; if (menuOpen) this.setState({ menuOpen: false }); }); } /** * Sets the timeslot as checked by modifying `this.state.availability`: * 1. First, this function removes any matching timeslots from * `this.state.availability`. * 2. If it was checked, this function adds the timeslot to * `this.state.availability`. */ private toggleChecked( timeslot: Timeslot, event: React.FormEvent ): void { const { value, onChange } = this.props; const copy: Availability = value.filter( (t) => !t.equalTo(timeslot) ) as Availability; if (event.currentTarget.checked) copy.push(timeslot); onChange(copy); } /** * Either selects all possible timeslots or unselects all timeslots. * @todo Perhaps just reset the availability to it's state before the select * all checkbox was selected. */ private toggleAllChecked(event: React.FormEvent): void { const { onChange } = this.props; if (event.currentTarget.checked) { onChange(ScheduleInput.timeslots); } else { onChange(new Availability()); } } /** * All props (except `onChange` and `className`) are delegated to the * `TextField` element. * @todo Perhaps use the `injectIntl` function to use the `intl` API to ensure * that the `DataTableHeaderCell`s always contain capitalized day strings. * @todo Allow the user to interact with the static content of the menu (i.e. * the text that doesn't cause an `onFocus` event when clicked). Right now, * interacting with such static content within the menu causes the menu to * lose focus which makes us close it. */ public render(): JSX.Element { const { value, onChange, className, renderToPortal, focused, onFocused, onBlurred, ...textFieldProps } = this.props; const { menuOpen } = this.state; const checkboxId: string = uuid(); return ( {Array(7) .fill(null) .map((_: null, dayNum: number) => ( /* eslint-disable-next-line react/no-array-index-key */ ))} {Object.entries(ScheduleInput.times).map( ([label, timeslot]: [string, Timeslot]) => ( {' - '} {Array(7) .fill(null) .map((_, dayNum: number) => { const timeslotCheckboxRepresents = new Timeslot( TimeUtils.getNextDateWithDay( dayNum as DayAlias, timeslot.from ), TimeUtils.getNextDateWithDay( dayNum as DayAlias, timeslot.to ) ); /* eslint-disable react/no-array-index-key */ return ( { this.toggleChecked( timeslotCheckboxRepresents, evt ); }} /> ); /* eslint-enable react/no-array-index-key */ })} ) )} { if (onFocused) onFocused(); this.openMenu(); }} onBlur={() => { if (onBlurred) onBlurred(); this.closeMenu(); }} /> ); } }