WikiEducationFoundation/WikiEduDashboard

View on GitHub
app/assets/javascripts/components/course_creator/course_creator.jsx

Summary

Maintainability
F
3 days
Test Coverage
F
56%
import React from 'react';
import createReactClass from 'create-react-class';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { includes } from 'lodash-es';

import { updateCourse } from '../../actions/course_actions';
import { fetchCampaign, submitCourse, cloneCourse } from '../../actions/course_creation_actions.js';
import { fetchCoursesForUser } from '../../actions/user_courses_actions.js';
import { setValid, setInvalid, checkCourseSlug, activateValidations, resetValidations } from '../../actions/validation_actions';
import { getCloneableCourses, isValid, firstValidationErrorMessage, getAvailableArticles } from '../../selectors';

import Notifications from '../common/notifications.jsx';
import Modal from '../common/modal.jsx';
import CourseUtils from '../../utils/course_utils.js';
import CourseDateUtils from '../../utils/course_date_utils.js';
import CourseType from './course_type.jsx';
import NewOrClone from './new_or_clone.jsx';
import ReuseExistingCourse from './reuse_existing_course.jsx';
import CourseForm from './course_form.jsx';
import CourseDates from './course_dates.jsx';
import { fetchAssignments } from '../../actions/assignment_actions';
import CourseScoping from './course_scoping_methods';
import { getScopingMethods } from '../util/scoping_methods';

import Select from 'react-select';
import selectStyles from '../../styles/single_select.js';

const CourseCreator = createReactClass({
  displayName: 'CourseCreator',

  propTypes: {
    course: PropTypes.object.isRequired,
    cloneableCourses: PropTypes.array.isRequired,
    fetchCoursesForUser: PropTypes.func.isRequired,
    courseCreator: PropTypes.object.isRequired,
    updateCourse: PropTypes.func.isRequired,
    submitCourse: PropTypes.func.isRequired,
    fetchCampaign: PropTypes.func.isRequired,
    cloneCourse: PropTypes.func.isRequired,
    loadingUserCourses: PropTypes.bool.isRequired,
    setValid: PropTypes.func.isRequired,
    setInvalid: PropTypes.func.isRequired,
    checkCourseSlug: PropTypes.func.isRequired,
    isValid: PropTypes.bool.isRequired,
    validations: PropTypes.object.isRequired,
    firstErrorMessage: PropTypes.string,
    activateValidations: PropTypes.func.isRequired
  },

  getInitialState() {
    return {
      tempCourseId: '',
      isSubmitting: false,
      showCourseForm: false,
      showCloneChooser: false,
      showEventDates: false,
      showWizardForm: false,
      showCourseDates: false,
      default_course_type: this.props.courseCreator.defaultCourseType,
      course_string_prefix: this.props.courseCreator.courseStringPrefix,
      use_start_and_end_times: this.props.courseCreator.useStartAndEndTimes,
      courseCreationNotice: this.props.courseCreator.courseCreationNotice,
      copyCourseAssignments: false,
      showingCreateCourseButton: false,
      onLastScoping: false,
      courseCloneId: null,
    };
  },

  componentDidMount() {
    // If a campaign slug is provided, fetch the campaign.
    const campaignParam = this.campaignParam();
    if (campaignParam) {
      this.props.fetchCampaign(campaignParam);
    }
    this.props.fetchCoursesForUser(window.currentUser.id);
    },

  onDropdownChange(event) {
    this.setState({
      courseCloneId: event.id,
    });
    this.props.fetchAssignments(event.value);
  },
  setCopyCourseAssignments(e) {
    return this.setState({
      copyCourseAssignments: e.target.checked
    });
  },

  getWizardController({ hidden, backFunction }) {
    return (
      <div className={`wizard__panel__controls ${hidden ? 'hidden' : ''}`}>
        <div className="left">
          <button onClick={backFunction || this.backToCourseForm} className="dark button">Back</button>
          <p className="tempCourseIdText">{this.state.tempCourseId}</p>
        </div>
        <div className="right">
          <div><p className="red">{this.props.firstErrorMessage}</p></div>
          <Link className="button" to="/" id="course_cancel">{I18n.t('application.cancel')}</Link>
          <button onClick={this.saveCourse} className="dark button button__submit">{CourseUtils.i18n('creator.create_button', this.state.course_string_prefix)}</button>
        </div>
      </div>
    );
  },

  UNSAFE_componentWillReceiveProps(nextProps) {
    this.setState({
      tempCourseId: CourseUtils.generateTempId(nextProps.course)
    });
    return this.handleCourse(nextProps.course, nextProps.isValid);
  },

  campaignParam() {
    // The regex allows for any number of URL parameters, while only capturing the campaign_slug parameter
    const campaignParam = window.location.search.match(/\?.*?campaign_slug=(.*?)(?:$|&)/);
    if (campaignParam) {
      return campaignParam[1];
    }
  },

  saveCourse() {
    this.props.activateValidations();
    if (this.props.isValid && this.dateTimesAreValid()) {
      this.setState({ isSubmitting: true });
      this.props.setInvalid(
        'exists',
        CourseUtils.i18n('creator.checking_for_uniqueness', this.state.course_string_prefix),
        true
      );
      return this.props.checkCourseSlug(CourseUtils.generateTempId(this.props.course));
    }
  },

  handleCourse(course, isValidProp) {
    if (this.state.shouldRedirect === true) {
      window.location = `/courses/${course.slug}`;
      return this.setState({ shouldRedirect: false });
    }

    if (!this.state.isSubmitting && !this.state.justSubmitted) {
      return;
    }
    if (isValidProp) {
      if (course.slug && this.state.justSubmitted) {
        // This has to be a window.location set due to our limited ReactJS scope
        if (this.state.default_course_type === 'ClassroomProgramCourse') {
          window.location = `/courses/${course.slug}/timeline/wizard`;
        } else {
          window.location = `/courses/${course.slug}`;
        }
      } else if (!this.state.justSubmitted) {
        const cleanedCourse = CourseUtils.cleanupCourseSlugComponents(course);
        this.setState({ course: cleanedCourse });
        this.setState({ isSubmitting: false });
        this.setState({ justSubmitted: true });
        // If the save callback fails, which will happen if an invalid wiki is submitted,
        // then we must reset justSubmitted so that the user can fix the problem
        // and submit again.
        const onSaveFailure = () => this.setState({ justSubmitted: false });
        cleanedCourse.scoping_methods = getScopingMethods(this.props.scopingMethods);
        this.props.submitCourse({ course: cleanedCourse }, onSaveFailure);
      }
    } else if (!this.props.validations.exists.valid) {
      this.setState({ isSubmitting: false });
    }
  },

  showEventDates() {
    return this.setState({ showEventDates: !this.state.showEventDates });
  },

  updateCourse(key, value) {
    this.props.updateCourse({ [key]: value });
    if (includes(['title', 'school', 'term'], key)) {
      return this.props.setValid('exists');
    }
  },

  updateCourseType(key, value) {
    this.props.updateCourse({ [key]: value });
  },

  expectedStudentsIsValid() {
    if (this.props.course.expected_students === '0' && this.state.default_course_type === 'ClassroomProgramCourse') {
      this.props.setInvalid('expected_students', I18n.t('application.field_required'));
      return false;
    }
    return true;
  },

  titleSubjectAndDescriptionAreValid() {
    if (this.props.course.title === '' || this.props.course.school === '' || this.props.course.description === '') {
      this.props.setInvalid('course_title', I18n.t('application.field_required'));
      this.props.setInvalid('course_school', I18n.t('application.field_required'));
      this.props.setInvalid('description', I18n.t('application.field_required'));
      return false;
    }
    if (!this.slugPartsAreValid()) {
      this.props.setInvalid('course_title', I18n.t('application.field_required'));
      this.props.setInvalid('course_school', I18n.t('application.field_required'));
      this.props.setInvalid('description', I18n.t('application.field_required'));
      return false;
    }
    return true;
  },

  slugPartsAreValid() {
    if (!this.props.course.title.match(CourseUtils.courseSlugRegex())) { return false; }
    if (!this.props.course.school.match(CourseUtils.courseSlugRegex())) { return false; }
    if (this.props.course.term && !this.props.course.term.match(CourseUtils.courseSlugRegex())) { return false; }
    return true;
  },

  dateTimesAreValid() {
    const startDateTime = new Date(this.props.course.start);
    const endDateTime = new Date(this.props.course.end);
    const startEventTime = new Date(this.props.timeline_start);
    const endEventTime = new Date(this.props.timeline_end);
    if (startDateTime >= endDateTime || startEventTime >= endEventTime) {
      this.props.setInvalid('end', I18n.t('application.field_invalid_date_time'));
      return false;
    }
    if (CourseDateUtils.courseTooLong(this.props.course)) {
      this.props.setInvalid('end', I18n.t('courses.dates_too_long'));
      return false;
    }
    return true;
  },

  showCourseForm(programName) {
    this.updateCourseType('type', programName);

    return this.setState({
      showCourseForm: true,
      showWizardForm: false,
      showCourseScoping: false,
    });
  },

  showCourseDates() {
    this.props.activateValidations();
    if (this.expectedStudentsIsValid() && this.titleSubjectAndDescriptionAreValid()) {
      this.props.resetValidations();
      return this.setState({
        showCourseDates: true,
        showCourseScoping: false,
        showCourseForm: false
      });
    }
  },
  showCourseScoping() {
    this.props.activateValidations();
    if (this.expectedStudentsIsValid() && this.titleSubjectAndDescriptionAreValid() && this.dateTimesAreValid()) {
      this.props.resetValidations();
      return this.setState({
        showCourseDates: false,
        showCourseScoping: true,
        showCourseForm: false,
        showNewOrClone: false
      });
    }
  },

  backToCourseForm() {
    return this.setState({
      showCourseForm: true,
      showCourseDates: false
    });
  },

  showCourseTypes() {
    return this.setState({
      showWizardForm: true,
      showCourseForm: false
    });
  },

  showCloneChooser() {
    this.props.fetchAssignments(this.props.cloneableCourses[0].slug);
    return this.setState({ showCloneChooser: true });
  },

  cancelClone() {
    return this.setState({ showCloneChooser: false });
  },

  chooseNewCourse() {
    if (Features.wikiEd) {
      this.setState({ showCourseForm: true });
    } else {
      this.setState({ showWizardForm: true });
    }
  },

  useThisClass() {
    const courseId = this.state.courseCloneId;
    this.props.cloneCourse(courseId, this.campaignParam(), this.state.copyCourseAssignments);
    return this.setState({ isSubmitting: true, shouldRedirect: true });
  },

  hideCourseForm() {
    return this.setState({ showCourseForm: false });
  },

  hideWizardForm() {
    return this.setState({
      showWizardForm: false,
    });
  },

  render() {
    if (this.props.loadingUserCourses) {
      return <div />;
    }
    // There are four fundamental states: NewOrClone, CourseForm, wizardForm and CloneChooser
    let showCourseForm;
    let showCloneChooser;
    let showNewOrClone;
    let showWizardForm;
    let showCourseDates;
    let showCourseScoping;
    if (this.state.showWizardForm) {
      showWizardForm = true;
    } else if (this.state.showCourseDates) {
      showCourseDates = true;
    } else if (this.state.showCourseForm) {
      showCourseForm = true;
    } else if (this.state.showCourseScoping) {
      showCourseScoping = true;
    } else if (this.state.showCloneChooser) {
      showCloneChooser = true;
      // If user has no courses, just open the CourseForm immediately because there are no cloneable courses.
    } else if (this.props.cloneableCourses.length === 0) {
      if (this.state.showCourseForm || Features.wikiEd) {
        showCourseForm = true;
      } else {
        showWizardForm = true;
      }
    } else {
      showNewOrClone = true;
    }

    let instructions;
    if (showNewOrClone) {
      instructions = CourseUtils.i18n('creator.new_or_clone', this.state.course_string_prefix);
    } else if (showCloneChooser) {
      instructions = CourseUtils.i18n('creator.choose_clone', this.state.course_string_prefix);
    } else if (showCourseForm) {
      instructions = CourseUtils.i18n('creator.intro', this.state.course_string_prefix);
    }

    let specialNotice;
    if (this.state.courseCreationNotice) {
      specialNotice = (
        <p className="timeline-warning" dangerouslySetInnerHTML={{ __html: this.state.courseCreationNotice }} />
      );
    }

    let formStyle;
    if (this.state.isSubmitting === true) {
      formStyle = { pointerEvents: 'none', opacity: 0.5 };
    }

    let courseFormClass = 'wizard__form';
    let courseWizard = 'wizard__program';
    let courseDates = 'wizard__dates';

    courseFormClass += showCourseForm ? '' : ' hidden';
    courseWizard += showWizardForm ? '' : ' hidden';
    courseDates += showCourseDates ? '' : ' hidden';

    // the scoping modal is only enabled for ArticleScopedPrograms
    const scopingModalEnabled = this.props.course.type === 'ArticleScopedProgram';

    // we're on the last page if
    // 1. scopingModalEnabled is enabled, and we're currently showing the course scoping modal's last page
    // 2. scopingModalEnabled is disabled, and we're currently showing the course dates
    // the second one is handled below. The first case is handled inside of app/assets/javascripts/components/course_creator/scoping_method.jsx
    const showingCreateCourseButton = !scopingModalEnabled && showCourseDates;

    const cloneOptions = showNewOrClone ? '' : ' hidden';
    const selectClass = showCloneChooser ? '' : ' hidden';
    const options = [
      ...this.props.cloneableCourses.map(course => ({
        value: course.slug,
        label: course.title,
        id: course.id
      }))
    ];
    const selectClassName = `select-container ${selectClass}`;
    const eventFormClass = this.state.showEventDates ? '' : 'hidden';
    const eventClass = `${eventFormClass}`;
    const reuseCourseSelect = (
      <div style={{ display: 'inline-block', width: '60%', marginRight: '10px' }}>
        <Select
          id="reuse-existing-course-select"
          styles={selectStyles}
          placeholder={'Select a course'}
          onChange={this.onDropdownChange}
          options={options}
          ref={(dropdown) => { this.courseSelect = dropdown; }}
        />
      </div>
    );

    let showCheckbox;
    if (this.props.assignmentsWithoutUsers.length > 0) {
      showCheckbox = true;
    } else {
      showCheckbox = false;
    }
    const checkBoxLabel = (
      <span style={{ marginTop: '1vh' }}>
        <input id="copy_cloned_articles" type="checkbox" onChange={this.setCopyCourseAssignments}/>
        <label htmlFor="checkbox_id">{I18n.t('courses.creator.copy_courses_with_assignments')}</label>
      </span>
    );


    return (
      <Modal key="modal">
        <Notifications />
        <div className="container">
          <div className="wizard__panel active" style={formStyle}>
            {!showCourseScoping && <h3>{CourseUtils.i18n('creator.create_new', this.state.course_string_prefix)}</h3>}
            {specialNotice}
            {instructions && <p>{instructions}</p>}
            <NewOrClone
              cloneClasss={cloneOptions}
              chooseNewCourseAction={this.chooseNewCourse}
              showCloneChooserAction={this.showCloneChooser}
              stringPrefix={this.state.course_string_prefix}
            />
            <CourseType
              back = {this.hideWizardForm}
              wizardClass={courseWizard}
              wizardAction={this.showCourseForm}
            />
            <ReuseExistingCourse
              selectClassName={selectClassName}
              courseSelect={reuseCourseSelect}
              options={options}
              useThisClassAction={this.useThisClass}
              stringPrefix={this.state.course_string_prefix}
              cancelCloneAction={this.cancelClone}
              assignmentsWithoutUsers={showCheckbox}
              checkBoxLabel={checkBoxLabel}
            />
            <CourseForm
              courseFormClass={courseFormClass}
              stringPrefix={this.state.course_string_prefix}
              updateCourseAction={this.updateCourse}
              course={this.props.course}
              eventClass={eventClass}
              defaultCourse={this.state.default_course_type}
              updateCourseProps={this.props.updateCourse}
              next={this.showCourseDates}
              previous={this.showCourseTypes}
              previousWikiEd={this.hideCourseForm}
              tempCourseId={this.state.tempCourseId}
              firstErrorMessage={this.props.firstErrorMessage}
            />
            <CourseDates
              courseDateClass={courseDates}
              course={this.props.course}
              showTimeValues={this.state.use_start_and_end_times}
              showEventDates={this.showEventDates}
              showEventDatesState={this.state.showEventDates}
              stringPrefix={this.state.course_string_prefix}
              updateCourseProps={this.props.updateCourse}
              enableTimeline={this.props.courseCreator.useStartAndEndTimes}
              // the following properties are only required when scopingModalEnabled is enabled
              // that is, when the selected course type is ArticleScopedProgram
              next={scopingModalEnabled && this.showCourseScoping}
              back={scopingModalEnabled && this.backToCourseForm}
              firstErrorMessage={scopingModalEnabled && this.props.firstErrorMessage}
            />
            <CourseScoping
              show={showCourseScoping}
              wizardController={this.getWizardController}
              showCourseDates={this.showCourseDates}
            />
            {!scopingModalEnabled && this.getWizardController({ hidden: !showingCreateCourseButton })}
          </div>
        </div>
      </Modal>
    );
  }
});

const mapStateToProps = state => ({
  course: state.course,
  courseCreator: state.courseCreator,
  cloneableCourses: getCloneableCourses(state),
  loadingUserCourses: state.userCourses.loading,
  validations: state.validations.validations,
  isValid: isValid(state),
  firstErrorMessage: firstValidationErrorMessage(state),
  assignmentsWithoutUsers: getAvailableArticles(state),
  scopingMethods: state.scopingMethods,
});

const mapDispatchToProps = ({
  fetchCampaign,
  fetchCoursesForUser,
  updateCourse,
  submitCourse,
  cloneCourse,
  setValid,
  setInvalid,
  checkCourseSlug,
  activateValidations,
  resetValidations,
  fetchAssignments
});

// exporting two difference ways as a testing hack.
export default connect(mapStateToProps, mapDispatchToProps)(CourseCreator);

export { CourseCreator };