open-learning-exchange/planet

View on GitHub
src/app/exams/exams-view.component.ts

Summary

Maintainability
A
3 hrs
Test Coverage
import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { CoursesService } from '../courses/courses.service';
import { Router, ActivatedRoute, ParamMap } from '@angular/router';
import { Subject, forkJoin, of } from 'rxjs';
import { takeUntil, switchMap, catchError } from 'rxjs/operators';
import { UserService } from '../shared/user.service';
import { SubmissionsService } from '../submissions/submissions.service';
import { CouchService } from '../shared/couchdb.service';
import { FormControl, AbstractControl } from '@angular/forms';
import { CustomValidators } from '../validators/custom-validators';
import { Exam, ExamQuestion } from './exams.model';
import { PlanetMessageService } from '../shared/planet-message.service';

@Component({
  selector: 'planet-exams-view',
  templateUrl: './exams-view.component.html',
  styleUrls: [ './exams-view.scss' ]
})

export class ExamsViewComponent implements OnInit, OnDestroy {

  @Input() isDialog = false;
  @Input() exam: Exam;
  @Input() submission: any;
  @Input() mode: 'take' | 'grade' | 'view' = 'take';
  previewMode = false;
  onDestroy$ = new Subject<void>();
  question: ExamQuestion;
  @Input() questionNum = 0;
  stepNum = 0;
  maxQuestions = 0;
  answer = new FormControl(null, this.answerValidator);
  statusMessage = '';
  spinnerOn = true;
  title = '';
  grade;
  submissionId: string;
  submittedBy = '';
  updatedOn = '';
  fromSubmission = false;
  examType = this.route.snapshot.data.mySurveys === true || this.route.snapshot.paramMap.has('surveyId') ? 'survey' : 'exam';
  @Input() previewExamType: any;
  checkboxState: any = {};
  isNewQuestion = true;
  unansweredQuestions: number[];
  isComplete = false;
  comment: string;

  constructor(
    private router: Router,
    private route: ActivatedRoute,
    private coursesService: CoursesService,
    private submissionsService: SubmissionsService,
    private userService: UserService,
    private couchService: CouchService,
    private planetMessageService: PlanetMessageService
  ) { }

  ngOnInit() {
    this.setCourseListener();
    this.setSubmissionListener();
    this.route.paramMap.pipe(takeUntil(this.onDestroy$)).subscribe((params: ParamMap) => {
      this.previewMode = params.get('preview') === 'true' || this.isDialog;
      this.questionNum = +params.get('questionNum') || this.questionNum;
      if (this.previewMode) {
        ((this.exam || this.submission) ? of({}) : this.couchService.get(`exams/${params.get('examId')}`)).subscribe(
          (res) => {
            this.exam = this.exam || res;
            this.examType = params.get('type') || this.previewExamType;
            this.setExamPreview();
          },
          (err) => {
            this.planetMessageService.showAlert($localize`Preview is not available for this test`);
            this.goBack();
          }
        );
        return;
      }
      this.setExam(params);
    });
  }

  ngOnDestroy() {
    this.onDestroy$.next();
    this.onDestroy$.complete();
  }

  setExam(params) {
    this.stepNum = +params.get('stepNum');
    this.examType = params.get('type') || this.examType;
    const courseId = params.get('id');
    const submissionId = params.get('submissionId');
    const mode = params.get('mode');
    this.mode = mode || this.mode;
    this.answer.setValue(null);
    this.spinnerOn = true;
    if (courseId) {
      this.coursesService.requestCourse({ courseId });
      this.statusMessage = '';
      this.grade = 0;
    } else if (submissionId) {
      this.fromSubmission = true;
      this.mode = mode || 'grade';
      this.grade = mode === 'take' ? 0 : undefined;
      this.comment = undefined;
      this.submissionsService.openSubmission({ submissionId, 'status': params.get('status') });
    }
  }

  setExamPreview() {
    this.answer.setValue(null);
    this.checkboxState = {};
    this.grade = 0;
    this.statusMessage = '';
    const exam = this.submission ? this.submission.parent : this.exam;
    this.setQuestion(exam.questions);
    if (this.submission) {
      this.submittedBy = this.submission.user.name;
      this.updatedOn = this.submission.lastUpdateTime;
      this.setViewAnswerText(this.submission.answers[this.questionNum - 1]);
    }
  }

  nextQuestion({ nextClicked = false, isFinish = false }: { nextClicked?: boolean, isFinish?: boolean } = {}) {
    const { correctAnswer, obs }: { correctAnswer?: boolean | undefined, obs: any } = this.createAnswerObservable(isFinish);
    const previousStatus = this.previewMode ? 'preview' : this.submissionsService.submission.status;
    // Only navigate away from page until after successful post (ensures DB is updated for submission list)
    obs.subscribe(({ nextQuestion }) => {
      if (correctAnswer === false) {
        this.statusMessage = 'incorrect';
        this.answer.setValue(null);
        this.question.choices.forEach(choice => this.checkboxState[choice.id] = false);
        this.spinnerOn = false;
      } else {
        this.routeToNext(nextClicked ? this.questionNum : nextQuestion, previousStatus);
      }
    });
  }

  routeToNext(nextQuestion, previousStatus) {
    this.statusMessage = this.isComplete && this.mode === 'take' ? 'complete' : '';
    if (nextQuestion > -1 && nextQuestion < this.maxQuestions) {
      this.moveQuestion(nextQuestion - this.questionNum + 1);
      return;
    }
    if (this.isDialog) {
      this.spinnerOn = false;
      return;
    }
    this.examComplete();
    if (this.examType === 'survey' && !this.previewMode) {
      this.submissionsService.sendSubmissionNotification(this.route.snapshot.data.newUser, previousStatus === 'complete');
    }
  }

  moveQuestion(direction: number) {
    if (this.isDialog) {
      this.questionNum = this.questionNum + direction;
      this.setExamPreview();
      this.spinnerOn = false;
      return;
    }
    this.router.navigate([ { ...this.route.snapshot.params, questionNum: this.questionNum + direction } ], { relativeTo: this.route });
    if (direction !== 0) {
      this.checkboxState = {};
    }
    this.isNewQuestion = true;
    this.spinnerOn = false;
  }

  examComplete() {
    if (this.route.snapshot.data.newUser === true) {
      this.router.navigate([ '/users/submission', { id: this.submissionId } ]);
    } else {
      this.goBack();
    }
  }

  goBack() {
    this.router.navigate([ '../',
      this.mode === 'take' ? {} :
      { type: this.mode === 'grade' ? 'exam' : 'survey' }
    ], { relativeTo: this.route });
    this.isNewQuestion = true;
  }

  setTakingExam(exam, parentId, type) {
    const user = this.route.snapshot.data.newUser === true ? {} : this.userService.get();
    this.setQuestion(exam.questions);
    this.submissionsService.openSubmission({
      parentId,
      parent: exam,
      user,
      type });
  }

  setQuestion(questions: any[]) {
    this.question = questions[this.questionNum - 1];
    this.maxQuestions = questions.length;
    this.answer.markAsUntouched();
  }

  setCourseListener() {
    this.coursesService.courseUpdated$.pipe(
      takeUntil(this.onDestroy$),
      switchMap(({ course, progress }: { course: any, progress: any }) => {
        // To be readable by non-technical people stepNum & questionNum param will start at 1
        const step = course.steps[this.stepNum - 1];
        this.title = step.stepTitle;
        return forkJoin([ of(course), of(step), this.couchService.get(`exams/${step[this.examType]._id}`).pipe(catchError(() => of(0))) ]);
      })
    ).subscribe(([ course, step, exam ]: any[]) => {
      const type = this.examType;
      const takingExam = exam ? exam : step[type];
      this.setTakingExam(takingExam, takingExam._id + '@' + course._id, type);
    });
  }

  setSubmissionListener() {
    this.submissionsService.submissionUpdated$.pipe(takeUntil(this.onDestroy$)).subscribe(({ submission }) => {
      this.submittedBy = this.submissionsService.submissionName(submission.user);
      this.updatedOn = submission.lastUpdateTime;
      this.unansweredQuestions = submission.parent.questions.reduce((unanswered, q, index) => [
        ...unanswered, ...((submission.answers[index] && submission.answers[index].passed) ? [] : [ index + 1 ])
      ], []);
      this.submissionId = submission._id;
      const ans = submission.answers[this.questionNum - 1] || {};
      if (this.fromSubmission === true) {
        this.examType = submission.parent.type === 'surveys' ? 'survey' : 'exam';
        this.title = submission.parent.name;
        this.setQuestion(submission.parent.questions);
        this.grade = (ans && ans.grade !== undefined) ? ans.grade : this.grade;
        this.comment = ans && ans.gradeComment;
      }
      if (this.mode === 'take' && this.isNewQuestion) {
        this.setAnswerForRetake(ans);
      } else if (this.mode !== 'take') {
        this.setViewAnswerText(ans);
      }
      this.isNewQuestion = false;
      this.isComplete = this.unansweredQuestions && this.unansweredQuestions.every(number => this.questionNum === number);
    });
  }

  setAnswer(event, option) {
    this.answer.setValue(Array.isArray(this.answer.value) ? this.answer.value : []);
    const value = this.answer.value;
    if (event.checked === true) {
      value.push(option);
    } else if (event.checked === false) {
      value.splice(value.indexOf(option), 1);
    }
    this.checkboxState[option.id] = event.checked;
  }

  calculateCorrect() {
    const value = this.answer.value;
    const answers = value instanceof Array ? value : [ value ];
    if (answers.every(answer => answer === null || answer === undefined)) {
      return undefined;
    }
    const isMultiCorrect = (correctChoice, ans: any[]) => (
      correctChoice.every(choice => ans.find((a: any) => a.id === choice)) &&
      ans.every((a: any) => correctChoice.find(choice => a.id === choice))
    );
    return this.question.correctChoice instanceof Array ?
      isMultiCorrect(this.question.correctChoice, answers) :
      answers[0].id === this.question.correctChoice;
  }

  createAnswerObservable(isFinish = false) {
    switch (this.mode) {
      case 'take':
        const correctAnswer = this.question.correctChoice.length > 0 ? this.calculateCorrect() : undefined;
        const obs = this.previewMode ?
          of({ nextQuestion: this.questionNum }) :
          this.submissionsService.submitAnswer(this.answer.value, correctAnswer, this.questionNum - 1, isFinish);
        return { obs, correctAnswer };
      case 'grade':
        return { obs: this.submissionsService.submitGrade(this.grade, this.questionNum - 1, this.comment) };
      default:
        return { obs: of({ nextQuestion: this.questionNum + 1 }) };
    }
  }

  setAnswerForRetake(answer: any) {
    const setSelectMultipleAnswer = (answers: any[]) => {
      answers.forEach(ans => {
        this.setAnswer({ checked: true }, ans);
      });
    };
    this.answer.setValue(null);
    if (!answer.value) {
      return;
    }
    switch (this.question.type) {
      case 'selectMultiple':
        setSelectMultipleAnswer(answer.value);
        break;
      case 'select':
        this.answer.setValue(this.question.choices.find((choice) => choice.text === answer.value.text));
        break;
      default:
        this.answer.setValue(answer.value);
    }
  }

  answerValidator(ac: AbstractControl) {
    if (typeof ac.value === 'string') {
      return CustomValidators.required(ac);
    }
    return ac.value !== null ? null : { required: true };
  }

  setViewAnswerText(answer: any) {
    this.answer.setValue(Array.isArray(answer.value) ? answer.value.map((a: any) => a.text).join(', ').trim() : answer.value);
    this.grade = answer.grade;
    this.comment = answer.gradeComment;
  }

}