ahbeng/NUSMods

View on GitHub
website/src/views/planner/PlannerModule.tsx

Summary

Maintainability
D
1 day
Test Coverage
import { memo, useState } from 'react';
import { Draggable } from 'react-beautiful-dnd';
import { Link } from 'react-router-dom';
import { format } from 'date-fns';
import classnames from 'classnames';

import { AlertTriangle, ChevronDown } from 'react-feather';
import { ModuleCode, ModuleTitle, Semester } from 'types/modules';
import { Conflict, PlannerPlaceholder } from 'types/planner';
import config from 'config';
import { renderMCs } from 'utils/modules';
import { conflictToText } from 'utils/planner';
import { toSingaporeTime } from 'utils/timify';
import LinkModuleCodes from 'views/components/LinkModuleCodes';
import { modulePage } from 'views/routes/paths';

import ModuleMenu from './ModuleMenu';
import PlannerModuleSelect from './PlannerModuleSelect';
import styles from './PlannerModule.scss';

type Props = Readonly<{
  // Module information
  moduleTitle: ModuleTitle | null;
  moduleCredit: number | null;
  examDate: string | null;
  moduleCode?: ModuleCode;
  placeholder?: PlannerPlaceholder;
  conflict?: Conflict | null;
  semester?: Semester;
  isInTimetable?: boolean;

  // For draggable
  id: string;
  index: number;

  // Actions
  removeModule: (id: string) => void;
  addCustomData: (moduleCode: ModuleCode) => void;
  setPlaceholderModule: (id: string, moduleCode: ModuleCode) => void;
  addModuleToTimetable: (semester: Semester, module: ModuleCode) => void;
  viewSemesterTimetable: () => void;
}>;

/**
 * Component for a single module on the planner
 */
const PlannerModule = memo<Props>((props) => {
  const [isEditingPlaceholder, setEditingPlaceholder] = useState(false);

  const removeModule = () => props.removeModule(props.id);

  const editCustomData = () => {
    if (props.moduleCode) props.addCustomData(props.moduleCode);
  };

  const addModuleToTimetable = () => {
    if (props.semester && props.moduleCode)
      props.addModuleToTimetable(props.semester, props.moduleCode);
  };

  const renderConflict = (conflict: Conflict) => {
    switch (conflict.type) {
      case 'noInfo':
        return (
          <div className={styles.conflictHeader}>
            <AlertTriangle className={styles.warningIcon} />
            <p>
              No data on this course.{' '}
              <button type="button" className="btn btn-link btn-inline" onClick={editCustomData}>
                Add data
              </button>
            </p>
          </div>
        );

      case 'semester':
        return (
          <div className={styles.conflictHeader}>
            <AlertTriangle className={styles.warningIcon} />
            <p>
              Module may only be offered in{' '}
              {conflict.semestersOffered
                .map((semester) => config.shortSemesterNames[semester])
                .join(', ')}
            </p>
          </div>
        );

      case 'exam':
        return (
          <div className={styles.conflictHeader}>
            <AlertTriangle className={styles.warningIcon} />
            <p>{conflict.conflictModules.join(', ')} have clashing exams</p>
          </div>
        );

      case 'prereq':
        return (
          <>
            <div className={styles.conflictHeader}>
              <AlertTriangle className={styles.warningIcon} />
              <p>These courses may need to be taken first</p>
            </div>

            <ul className={styles.prereqs}>
              {conflict.unfulfilledPrereqs.map((prereq, i) => (
                <li key={i}>
                  <LinkModuleCodes>{conflictToText(prereq)}</LinkModuleCodes>
                </li>
              ))}
            </ul>
          </>
        );

      case 'duplicate':
        return (
          <>
            <div className={styles.conflictHeader}>
              <AlertTriangle className={styles.warningIcon} />
              <p>This might be a duplicate of another course in this semester.</p>
            </div>
          </>
        );

      default:
        return null;
    }
  };

  const renderMeta = () => {
    const { moduleCredit, examDate } = props;
    if (!moduleCredit && !examDate) return null;

    return (
      <div className={styles.moduleMeta}>
        {moduleCredit && <div>{renderMCs(moduleCredit)}</div>}
        {examDate && <div>{format(toSingaporeTime(examDate), 'MMM d, h:mm a')}</div>}
      </div>
    );
  };

  const renderPlaceholderForm = () => {
    const { placeholder, moduleCode, moduleTitle, semester } = props;

    if (!placeholder) return null;

    if (!isEditingPlaceholder) {
      return (
        <>
          <button
            type="button"
            className={classnames('btn btn-sm btn-svg', styles.placeholderSelect, {
              [styles.empty]: !moduleCode,
            })}
            onClick={() => setEditingPlaceholder(true)}
          >
            {moduleCode || 'Select Course'} <ChevronDown />
          </button>{' '}
          {moduleCode && moduleTitle && (
            <Link to={modulePage(moduleCode, moduleTitle)}>{moduleTitle}</Link>
          )}
        </>
      );
    }

    return (
      <form>
        <PlannerModuleSelect
          onSelect={(newModuleCode: ModuleCode | null) => {
            if (newModuleCode) {
              props.setPlaceholderModule(props.id, newModuleCode);
            }

            setEditingPlaceholder(false);
          }}
          onCancel={() => setEditingPlaceholder(false)}
          onBlur={() => setEditingPlaceholder(false)}
          showOnly={placeholder.modules}
          filter={placeholder.filter}
          defaultValue={moduleCode}
          className={styles.placeholderInput}
          semester={semester}
        />
      </form>
    );
  };

  const { id, placeholder, moduleCode, moduleTitle, index, conflict } = props;

  return (
    <Draggable key={moduleCode} draggableId={id} index={index}>
      {(provided, snapshot) => (
        <div
          ref={provided.innerRef}
          className={classnames(styles.module, {
            [styles.warning]: conflict,
            [styles.isDragging]: snapshot.isDragging,
            [styles.placeholder]: placeholder && !moduleCode,
          })}
          {...provided.draggableProps}
          {...provided.dragHandleProps}
        >
          <ModuleMenu
            isInTimetable={props.isInTimetable}
            removeModule={removeModule}
            editCustomData={editCustomData}
            addModuleToTimetable={addModuleToTimetable}
            viewSemesterTimetable={props.viewSemesterTimetable}
          />

          <div className={styles.moduleInfo}>
            <div className={styles.moduleName}>
              {placeholder ? (
                <>
                  <strong className={styles.placeholderName}>{placeholder.name}</strong>
                  {renderPlaceholderForm()}
                </>
              ) : (
                moduleCode && (
                  <Link className="d-block" to={modulePage(moduleCode, moduleTitle)}>
                    <strong>{moduleCode}</strong> {moduleTitle}
                  </Link>
                )
              )}
            </div>

            {renderMeta()}

            {conflict && <div className={styles.conflicts}>{renderConflict(conflict)}</div>}
          </div>
        </div>
      )}
    </Draggable>
  );
});

export default PlannerModule;