ahbeng/NUSMods

View on GitHub
website/src/views/modules/ReportError.tsx

Summary

Maintainability
B
5 hrs
Test Coverage
import { FC, FormEventHandler, memo, useCallback, useState } from 'react';
import { castArray, groupBy } from 'lodash';
import classnames from 'classnames';
import produce from 'immer';
import axios from 'axios';
import { AlertTriangle } from 'react-feather';
import * as Sentry from '@sentry/browser';

import type {
  DepartmentMatch,
  Division,
  FacultyEmail,
  FacultyMatch,
  ModuleCodeMatch,
  ModuleCodePrefixMatch,
} from 'types/facultyEmail';
import type { Module } from 'types/modules';
import Modal from 'views/components/Modal';
import CloseButton from 'views/components/CloseButton';
import ExternalLink from 'views/components/ExternalLink';
import facultyEmails from 'data/facultyEmail';
import appConfig from 'config';
import useGlobalDebugValue from '../hooks/useGlobalDebugValue';

import styles from './ReportError.scss';
import LoadingSpinner from '../components/LoadingSpinner';
import { isGraduateModule } from '../../utils/modules';
import { CONTACT_INFO } from '../../storage/keys';

interface Props {
  module: Module;
}

interface ReportErrorForm {
  name: string;
  contactId: FacultyEmail['id'] | undefined;
  replyTo: string;
  matricNumber: string;
  message: string;
}

type FormState =
  | { type: 'unsubmitted' }
  | { type: 'error' }
  | { type: 'submitting' }
  | { type: 'submitted' };

interface PersistedContactInfo {
  name: string;
  replyTo: string;
  matricNumber: string;
}

const groupedByMatcherType = groupBy(facultyEmails, (config) => config.match.type) as unknown as {
  moduleCode: FacultyEmail<ModuleCodeMatch>[];
  modulePrefix: FacultyEmail<ModuleCodePrefixMatch>[];
  faculty: FacultyEmail<FacultyMatch>[];
  department: FacultyEmail<DepartmentMatch>[];
};

function retrieveContactInfo(): PersistedContactInfo {
  const persisted = JSON.parse(localStorage.getItem(CONTACT_INFO) ?? '{}');
  return {
    name: '',
    replyTo: '',
    matricNumber: '',
    ...persisted,
  };
}

function persistContactInfo(info: PersistedContactInfo) {
  localStorage.setItem(CONTACT_INFO, JSON.stringify(info));
}

function matchModule(module: Module) {
  let facultyEmail: FacultyEmail | undefined;

  // 1. Check which email to use for this module by specificity.
  //    The most specific is module code, so we check that first
  facultyEmail = groupedByMatcherType.moduleCode.find(({ match }) =>
    castArray(match.moduleCode).find((code) => code === module.moduleCode),
  );
  if (facultyEmail) return facultyEmail;

  // 2. Check module prefix next
  facultyEmail = groupedByMatcherType.modulePrefix.find(({ match }) =>
    module.moduleCode.startsWith(match.prefix),
  );
  if (facultyEmail) return facultyEmail;

  // 3. Department and faculty matchers are split by graduate and undergrad classes. In NUS
  //    the 5-6000 level modules are graduate level modules
  const division: Division = isGraduateModule(module) ? 'grad' : 'undergrad';

  // 4. Check department
  facultyEmail = groupedByMatcherType.department.find(
    ({ match }) => module.department === match.department && division === match.level,
  );
  if (facultyEmail) return facultyEmail;

  // 5. Finally check faculty, which is the least specific
  return groupedByMatcherType.faculty.find(
    ({ match }) => module.faculty === match.faculty && division === match.level,
  );
}

/**
 * Module error reporting component. Posts to a serverless script that then emails the relevant
 * faculty / department with the issue.
 */
const ReportError = memo<Props>(({ module }) => {
  const [isOpen, setIsOpen] = useState(false);
  const [formState, setFormState] = useState<FormState>({ type: 'unsubmitted' });

  // Causes the error reporting function to email modules@nusmods.com instead.
  // In production, use SET_ERROR_REPORTING_DEBUG(true) to enable debug mode
  const debug = useGlobalDebugValue('SET_ERROR_REPORTING_DEBUG', NUSMODS_ENV === 'development');

  const [formData, setFormData] = useState<ReportErrorForm>(() => ({
    ...retrieveContactInfo(),
    message: '',
    contactId: matchModule(module)?.id,
  }));

  const updateFormValue = useCallback(
    (key: keyof ReportErrorForm): FormEventHandler =>
      (evt) => {
        const newFormData = produce(formData, (draft) => {
          draft[key] = (evt.target as HTMLInputElement).value;
        });

        setFormData(newFormData);
        persistContactInfo({
          name: newFormData.name,
          replyTo: newFormData.replyTo,
          matricNumber: newFormData.matricNumber,
        });
      },
    [formData],
  );

  const onSubmit = useCallback(() => {
    setFormState({ type: 'submitting' });

    const { name, replyTo, matricNumber, message, contactId } = formData;
    axios
      .post(appConfig.moduleErrorApi, {
        name,
        contactId,
        moduleCode: module.moduleCode,
        matricNumber: matricNumber.toUpperCase(),
        replyTo,
        message,
        debug,
      })
      .then(() => setFormState({ type: 'submitted' }))
      .catch((error) => {
        Sentry.setExtras({ ...formData });
        Sentry.captureException(error);
        setFormState({ type: 'error' });
      });
  }, [formData, module, debug]);

  return (
    <>
      <button
        type="button"
        className={classnames('btn btn-link', styles.button)}
        onClick={() => setIsOpen(!isOpen)}
      >
        <AlertTriangle className={styles.icon} />
        Report errors
      </button>

      <Modal
        isOpen={isOpen}
        onRequestClose={() => setIsOpen(false)}
        shouldCloseOnOverlayClick={false}
        animate
      >
        <CloseButton onClick={() => setIsOpen(false)} />
        <h2 className={styles.heading}>Reporting an issue with {module.moduleCode}</h2>
        <p>
          NUSMods updates its information from the Registrar's Office every few hours. Please wait
          up to 24 hours for information to be updated before reporting any issues.
        </p>
        <p>
          This form will send an email about this course to the faculty. If you think the issue is a
          bug in NUSMods, please email <a href="mailto:bugs@nusmods.com">bugs@nusmods.com</a>{' '}
          instead.
        </p>

        {debug && (
          <div className="alert alert-warning">
            <strong>Debug mode</strong> - this form will email modules@nusmods.com instead
          </div>
        )}

        {formState.type === 'error' && (
          <div className="alert alert-danger" role="alert">
            There was an error submitting the form. Please try again later or send the email
            yourself.
          </div>
        )}

        {formState.type === 'submitted' ? (
          <div className="alert alert-success">
            Thank you for reporting the error. A copy of the email that was sent to the faculty has
            also been cc'd to you.
          </div>
        ) : (
          <FormContent
            formData={formData}
            onSubmit={onSubmit}
            updateFormValue={updateFormValue}
            isSubmitting={formState.type === 'submitting'}
          />
        )}
      </Modal>
    </>
  );
});

ReportError.displayName = 'ReportError';

interface FormContentProps {
  formData: ReportErrorForm;
  onSubmit: () => void;
  updateFormValue: (key: keyof ReportErrorForm) => FormEventHandler;
  isSubmitting: boolean;
}

const FormContent: FC<FormContentProps> = ({
  formData,
  onSubmit,
  updateFormValue,
  isSubmitting,
}) => {
  const selectedContact = facultyEmails.find((config) => config.id === formData.contactId);

  return (
    <form
      className={classnames('form-row', { disabled: isSubmitting })}
      onSubmit={(evt) => {
        evt.preventDefault();
        onSubmit();
      }}
    >
      <div className="form-group col-sm-12">
        <label htmlFor="report-error-name">Your full name</label>
        <input
          className="form-control"
          id="report-error-name"
          value={formData.name}
          onChange={updateFormValue('name')}
          required
        />
      </div>

      <div className="form-group col-sm-12">
        <label htmlFor="report-error-matric-number">Your matriculation number</label>
        <input
          id="report-error-matric-number"
          className="form-control"
          value={formData.matricNumber}
          onChange={updateFormValue('matricNumber')}
          placeholder="A1234567B"
          minLength={9}
          maxLength={9}
          pattern="[aA][0-9]{7}[a-zA-Z]"
          required
        />
      </div>

      <div className="form-group col-sm-12">
        <label htmlFor="report-error-faculty">Department/faculty offering the course</label>
        <select
          className="form-control"
          id="report-error-faculty"
          value={formData.contactId}
          onChange={updateFormValue('contactId')}
          required
        >
          <option value="">Select a department/faculty</option>
          {facultyEmails.map((config) => (
            <option value={config.id} key={config.id}>
              {config.label}
            </option>
          ))}
        </select>

        {selectedContact && (
          <p className="form-text text-muted">
            This will email <a href={`mailto:${selectedContact.email}`}>{selectedContact.email}</a>
          </p>
        )}

        <p className="form-text text-muted">
          If the department or faculty for this course cannot be found on this list, please refer to
          CourseReg's contact list for{' '}
          <ExternalLink href="https://www.nus.edu.sg/coursereg/docs/UGFac_Contacts.pdf">
            undergraduate
          </ExternalLink>{' '}
          or{' '}
          <ExternalLink href="https://www.nus.edu.sg/coursereg/docs/GDFac_Contacts.pdf">
            graduate
          </ExternalLink>{' '}
          students.
        </p>
      </div>

      <div className="form-group col-sm-12">
        <label htmlFor="report-error-email">Your school email</label>
        <input
          type="email"
          id="report-error-email"
          className="form-control"
          pattern=".+@.+nus.+"
          value={formData.replyTo}
          placeholder="e0012345@u.nus.edu"
          onChange={updateFormValue('replyTo')}
          required
        />
      </div>

      <div className="form-group col-sm-12">
        <label htmlFor="report-error-message">Describe in detail the issues with the course</label>
        <textarea
          id="report-error-message"
          className="form-control"
          value={formData.message}
          onChange={updateFormValue('message')}
          rows={8}
          required
        />
      </div>

      <footer className={classnames(styles.footer, 'col-sm-12')}>
        <button type="submit" className="btn btn-primary btn-lg" disabled={isSubmitting}>
          {isSubmitting && <LoadingSpinner small white />} Submit
        </button>
      </footer>
    </form>
  );
};

const ReportErrorWrapper: FC<Props> = ({ module }) => (
  // Force form state to re-initialize when the module changes
  <ReportError module={module} key={module.moduleCode} />
);

export default ReportErrorWrapper;