quiz-6.0.0-alpha4/src/Form/QuizQuestionAnsweringForm.php
src/Form/QuizQuestionAnsweringForm.php
<?php
namespace Drupal\quiz\Form;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\Html;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerTrait;
use Drupal\quiz\Services\QuizSessionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* The form used to deliver questions to users and capture their responses.
*/
class QuizQuestionAnsweringForm extends FormBase {
use MessengerTrait;
/**
* QuizQuestionAnsweringForm constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
* @param \Drupal\quiz\Services\QuizSessionInterface $quizSession
* The quiz session service.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/
public function __construct(
protected EntityTypeManagerInterface $entityTypeManager,
protected QuizSessionInterface $quizSession,
protected TimeInterface $time,
) {
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->get('entity_type.manager'),
$container->get('quiz.session'),
$container->get('datetime.time'),
);
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array {
/** @var \Drupal\quiz\Entity\QuizQuestion[] $questions */
$questions = $form_state->getBuildInfo()['args'][0];
$quiz_session = $this->quizSession;
/** @var \Drupal\quiz\Entity\QuizResult $quiz_result */
$quiz_result = $form_state->getBuildInfo()['args'][1];
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage('quiz');
$quiz = $storage->loadRevision($quiz_result->get('vid')->getString());
// Store quiz and result in the form.
$form['#quiz'] = $quiz;
$form['#quiz_result'] = $quiz_result;
if (!is_array($questions)) {
/** @var \Drupal\quiz\Entity\QuizQuestion $questions */
// One single question (or page?)
if ($questions->bundle() == 'page') {
foreach ($quiz_result->getLayout() as $qra) {
if ($qra->get('question_id')->getString() == $questions->id()) {
// Found the page in the layout.
// Add the page as the first "question".
$questions = [$questions];
foreach ($quiz_result->getLayout() as $qra2) {
// Get all page children.
if ($qra2->qqr_pid == $qra->qqr_id) {
// This question belongs in the requested page.
$questions[] = $qra2->getQuizQuestion();
}
}
break;
}
}
unset($qra, $qra2);
}
else {
// Make this a page of 1 question.
$questions = [$questions->id() => $questions];
}
}
$form['#attributes']['class'] = ['answering-form'];
$form['#tree'] = TRUE;
// Mark this as the current question.
$quiz_result->setQuestion(reset($questions)
->getResponse($quiz_result)
->get('number')->value);
foreach ($questions as $question) {
$class = Html::getClass('quiz-question-' . $question->bundle());
// Element for a single question.
// @todo Instead of doing this load I think we can refer back to the layout.
$qra = $question->getResponse($quiz_result);
$element = $question->getAnsweringForm($form_state, $qra);
// Render the question using the "question" view mode.
$build = $this->entityTypeManager
->getViewBuilder('quiz_question')
->view($question, 'question');
$header_markup = NULL;
if (!$qra->get('display_number')->isEmpty()) {
$header_markup = ['#markup' => "<h2>" . $this->t("Question @question", ['@question' => $qra->get('display_number')->getString()]) . "</h2>"];
}
$form['question'][$question->id()] = [
'#attributes' => ['class' => [$class]],
'#type' => 'container',
'header' => $header_markup,
'question' => $build,
'answer' => $element,
];
$form['question'][$question->id()]['answer']['#quiz_result_answer'] = $qra;
$blank_and_change = $qra->get('is_skipped')->getString() && $quiz->get('allow_change_blank')->getString();
if (!$quiz->get('allow_change')->getString() && !$qra->get('answer_timestamp')->isEmpty()) {
if ($blank_and_change) {
// Allow it.
}
else {
// This question was already answered, or answering blank question is
// disabled.
$form['question'][$question->id()]['#disabled'] = TRUE;
if (empty($form_state->getUserInput())) {
// Only show message if not submitting.
$this->messenger()->addWarning($this->t('Changing answers is disabled.'));
}
}
}
if ($quiz->get('mark_doubtful')->getString() && $question->isQuestion()) {
$form['question'][$question->id()]['is_doubtful'] = [
'#type' => 'checkbox',
'#title' => $this->t('Doubtful?'),
'#default_value' => $qra->get('is_doubtful')->getString(),
];
}
}
$is_last = $quiz->isLastQuestion();
$form['navigation']['#type'] = 'actions';
$form['navigation']['submit_hidden'] = [
'#weight' => -9999,
'#type' => 'submit',
'#value' => $is_last ? $this->t('Finish') : $this->t('Next'),
'#attributes' => ['style' => 'display: none'],
];
$form['navigation']['timer_expired_finish'] = [
'#weight' => -9998,
'#type' => 'submit',
'#value' => $this->t('Timer Expired Finish'),
'#submit' => ['::submitFinalize'],
'#limit_validation_errors' => [['timer_expired_finish']],
'#attributes' => ['style' => 'display: none'],
];
$form['navigation']['actions'] = [
'#type' => 'container',
'#weight' => 5,
];
if ($quiz->get('backwards_navigation')->getString() && ($quiz_session->getCurrentQuestion($quiz) != 1)) {
// Backwards navigation enabled, and we are looking at not the first
// question. @todo detect when on the first page.
$form['navigation']['actions']['return'] = [
'#type' => 'container',
'#weight' => 0,
];
$form['navigation']['actions']['return']['back'] = [
'#weight' => 10,
'#type' => 'submit',
'#value' => $this->t('Back'),
'#submit' => ['::submitBack'],
'#limit_validation_errors' => [],
];
if ($is_last) {
$form['navigation']['#last'] = TRUE;
$form['navigation']['messages'] = [
'#type' => 'container',
'#weight' => 0,
];
$form['navigation']['messages']['last_text'] = [
'#weight' => 0,
'#markup' => '<p class="quiz-last-text"><em>' . $this->t('This is the last question. Press Finish to deliver your answers') . '</em></p>',
];
}
}
$form['navigation']['actions']['proceed'] = [
'#type' => 'container',
'#weight' => 20,
];
$form['navigation']['actions']['proceed']['submit'] = [
'#weight' => 30,
'#type' => 'submit',
'#value' => $is_last ? $this->t('Finish') : $this->t('Next'),
'#ajax' => [],
];
if ($is_last && $quiz->get('backwards_navigation')->getString() && !$quiz->get('repeat_until_correct')->getString()) {
// Display a confirmation dialogue if this is the last question and a user
// is able to navigate backwards but not forced to answer correctly.
$form['#attributes']['class'][] = 'quiz-answer-confirm';
$form['#attributes']['data-confirm-message'] = $this->t("By proceeding you won't be able to go back and edit your answers.");
$form['#attached']['library'][] = 'quiz/confirm';
}
if ($quiz->get('allow_skipping')->getString()) {
$form['navigation']['actions']['proceed']['skip'] = [
'#weight' => 20,
'#type' => 'submit',
'#value' => $is_last ? $this->t('Skip and finish') : $this->t('Skip'),
'#submit' => ['::submitBlank'],
'#limit_validation_errors' => [],
];
}
return $form;
}
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'quiz_question_answering_form';
}
/**
* Submit handler for the question answering form.
*
* There is no validation code here, but there may be feedback code for
* correct feedback.
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$quiz_session = $this->quizSession;
/** @var \Drupal\quiz\Entity\QuizResult $quiz_result */
$quiz_result = $form_state->getBuildInfo()['args'][1];
$feedback_count = 0;
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage('quiz');
/** @var \Drupal\quiz\Entity\Quiz $quiz */
$quiz = $storage->loadRevision($quiz_result->get('vid')->getString());
$time_reached = $quiz_result->isTimeReached();
$layout = $quiz_result->getLayout();
if ($time_reached) {
// Too late.
// @todo move to quiz_question_answering_form_validate(), and then put all
// the "quiz end" logic in a sharable place. We just need to not fire the
// logic that saves all the users answers.
$this->messenger()->addError($this->t('The last answer was not submitted, as the time ran out.'));
}
else {
$submitted = $form_state->getValue('question');
foreach ($layout as $qra) {
if (isset($submitted[$qra->get('question_id')->getString()])) {
// User submitted a response to this question.
$qqid = $qra->get('question_id')->getString();
$qra->set('answer_timestamp', $this->time->getRequestTime());
// Get the unscaled points awarded from the question response
// implementation and then apply the weighted ratio in this quiz. For
// example a MCQ question itself may be worth 4 points but worth 10
// points in this quiz. A score of 2 would mean 5 points being
// recorded.
$qra->set('points_awarded', $qra->score($form_state->getValues()['question'][$qqid]) * $qra->getWeightedRatio());
// Mark question as not skipped, in case it was skipped previously.
$qra->set('is_skipped', FALSE);
// Mark as doubtful.
$qra->set('is_doubtful', !empty($form_state->getValues()['question'][$qqid]['is_doubtful']) ? 1 : 0);
// Mark correctness.
$qra->set('is_correct', $qra->isCorrect());
$qra->save();
// Does this question type have feedback?
// We need to track it across pages.
$feedback_count += $qra->getQuizQuestion()->hasFeedback();
// Increment the question position.
$quiz_result->setQuestion((int) $qra->get('number')->getString() + 1);
}
}
}
// Wat do?
$next_number = $quiz_session->getCurrentQuestion($quiz);
if ($time_reached || !isset($layout[$next_number])) {
// If this is the last question, finalize the quiz.
$this->submitFinalize($form, $form_state);
}
else {
// No question feedback. Go to next question.
$form_state->setRedirect('quiz.question.take', [
'quiz' => $quiz->id(),
'question_number' => $next_number,
]);
}
$review_options = $quiz->get('review_options')->get(0);
if ($review_options && !empty($review_options->getValue()['question']) && array_filter($review_options->getValue()['question']) && $feedback_count) {
// This page contains questions that can provide feedback, and question
// feedback is enabled on the quiz.
$form_state->setRedirect('quiz.question.feedback', [
'quiz' => $quiz->id(),
'question_number' => $next_number - 1,
]);
// For ajax_quiz.
$form_state->set('feedback', TRUE);
}
}
/**
* Submit action for "leave blank".
*/
public function submitBlank(array $form, FormStateInterface $form_state) {
/** @var \Drupal\quiz\Entity\QuizResult $quiz_result */
$quiz_result = $form_state->getBuildInfo()['args'][1];
$quiz_session = $this->quizSession;
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage('quiz');
$quiz = $storage->loadRevision($quiz_result->get('vid')->getString());
if (!empty($form_state->getUserInput()['question'])) {
// Loop over all question inputs provided, and record them as skipped.
foreach (array_keys($form_state->getUserInput()['question']) as $qqid) {
foreach ($quiz_result->getLayout() as $idx => $qra) {
// Find the blank submitted question in the current layout.
if ($qra->get('question_id')->getString() == $qqid) {
// Reset the question and mark it as taken, so restrictions like
// backwards navigation function correctly.
$qra->set('is_skipped', TRUE);
$qra->set('is_correct', NULL);
$qra->set('points_awarded', FALSE);
$qra->set('answer_timestamp', $this->time->getRequestTime());
$qra->save();
}
}
$quiz_result->setQuestion($quiz_session->getCurrentQuestion($quiz) + 1);
}
}
else {
// Advance to next question, no input here.
$quiz_result->setQuestion($quiz, $quiz_session->getCurrentQuestion($quiz) + 1);
}
// Advance to next question.
$form_state->setRedirect('quiz.question.take', [
'quiz' => $quiz->id(),
'question_number' => $quiz_session->getCurrentQuestion($quiz),
]);
$layout = $quiz_result->getLayout();
if (!isset($layout[$quiz_session->getCurrentQuestion($quiz)])) {
// If this is the last question, finalize the quiz.
$this->submitFinalize($form, $form_state);
}
}
/**
* Helper function to finalize a quiz attempt.
*
* @see quiz_question_answering_form_submit()
* @see quiz_question_answering_form_submit_blank()
*/
public function submitFinalize(array $form, FormStateInterface $form_state) {
/** @var \Drupal\quiz\Entity\QuizResult $quiz_result */
$quiz_result = $form_state->getBuildInfo()['args'][1];
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $storage */
$storage = $this->entityTypeManager->getStorage('quiz');
$quiz = $storage->loadRevision($quiz_result->get('vid')->getString());
$quiz_session = $this->quizSession;
// No more questions. Score quiz and finalize result.
$quiz_result->finalize();
if (empty($quiz->review_options['question']) || !array_filter($quiz->review_options['question']) || empty($form_state['feedback'])) {
// Only redirect to question results if there is not question feedback.
// /** @todo D8*/.
$form_state->setRedirect('entity.quiz_result.canonical', [
'quiz' => $quiz->id(),
'quiz_result' => $quiz_result->id(),
]);
}
// Remove all information about this quiz from the session.
// @todo but for anon, we might have to keep some so they could access
// results
// When quiz is completed we need to make sure that even though the quiz has
// been removed from the session, that the user can still access the
// feedback for the last question, THEN go to the results page.
$quiz_session->setTemporaryResult($quiz_result);
$quiz_session->removeQuiz($quiz);
}
/**
* Submit handler for "back".
*/
public function submitBack(&$form, FormStateInterface $form_state): void {
// Back a question.
$quiz_session = $this->quizSession;
$quiz = $form['#quiz'];
$quiz_result = $quiz_session->getResult($quiz);
$quiz_result->setQuestion($quiz_session->getCurrentQuestion($quiz) - 1);
// Handle going back to a question which is in a previous page.
$layout = $quiz_result->getLayout();
$question = $layout[$quiz_session->getCurrentQuestion($quiz)];
if (!empty($question->qqr_pid)) {
foreach ($layout as $question2) {
if ($question2->qqr_id == $question->qqr_pid) {
$quiz_result->setQuestion($question2->get('number')->value);
}
}
}
$form_state->setRedirect('quiz.question.take', [
'quiz' => $quiz->id(),
'question_number' => $quiz_session->getCurrentQuestion($quiz),
]);
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state): void {
$quiz_result = $form_state->getBuildInfo()['args'][1];
$time_reached = $quiz_result->isTimeReached();
if ($time_reached) {
// Let's not validate anything, because the input won't get saved
// in submit either.
}
}
}
