diff --git a/assets/controllers/questionnaire_controller.js b/assets/controllers/questionnaire_controller.js new file mode 100644 index 0000000..96683b9 --- /dev/null +++ b/assets/controllers/questionnaire_controller.js @@ -0,0 +1,74 @@ +import { Controller } from '@hotwired/stimulus' + +/* + * Shows a questionnaire question only while its show_if holds, mirroring + * App\Model\Questionnaire\Definition::isVisible(): the condition must be met and the question it + * depends on must itself be shown. + * + * Every question row carries data-question-key; a conditional one also data-show-if, the JSON + * {question, operator, value} that App\Form\QuestionnaireType writes. A hidden row stays in the + * form and is submitted: a draft keeps its answer, and the server drops it on submit. + */ +export default class extends Controller { + + connect() { + this.update() + } + + update() { + const visible = {} + + // Rows come in definition order, and a condition may only name an earlier question, so + // the question a row depends on has always been decided by the time the row is reached. + for (const row of this.element.querySelectorAll('[data-question-key]')) { + const key = row.dataset.questionKey + const condition = row.dataset.showIf ? JSON.parse(row.dataset.showIf) : null + + visible[key] = null === condition + || (true === visible[condition.question] && this.isMet(condition)) + + row.hidden = false === visible[key] + } + } + + isMet({ question, operator, value }) { + const answer = this.answer(question) + + if (null === answer) { + return false + } + + switch (operator) { + case 'equals': + return answer === value + case 'in': + return value.includes(answer) + case 'lte': + return Number(answer) <= value + default: + return false + } + } + + answer(key) { + const row = this.element.querySelector(`[data-question-key="${key}"]`) + + if (null === row) { + return null + } + + const checked = row.querySelector('input[type="radio"]:checked') + + if (null !== checked) { + return checked.value + } + + const select = row.querySelector('select') + + if (null !== select) { + return '' === select.value ? null : select.value + } + + return null + } +} diff --git a/src/Config/QuestionnaireCatalog.php b/src/Config/QuestionnaireCatalog.php index 4bf5c60..a251a9a 100644 --- a/src/Config/QuestionnaireCatalog.php +++ b/src/Config/QuestionnaireCatalog.php @@ -25,6 +25,11 @@ use Symfony\Component\Yaml\Yaml; */ class QuestionnaireCatalog { + /** + * The questionnaire a teamer fills in after a bus escort; see DispositionVoter::QUESTIONNAIRE. + */ + public const string TEAMER_ESCORT = 'teamer_escort'; + private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections']; private const array SECTION_FIELDS = ['key', 'title', 'questions']; private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if']; diff --git a/src/Controller/Teamer/Questionnaire/DetailController.php b/src/Controller/Teamer/Questionnaire/DetailController.php new file mode 100644 index 0000000..9185e8b --- /dev/null +++ b/src/Controller/Teamer/Questionnaire/DetailController.php @@ -0,0 +1,42 @@ +responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); + + if (true !== $response?->isSubmitted()) { + return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]); + } + + return $this->render('teamer/questionnaire/detail.html.twig', [ + 'disposition' => $disposition, + 'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()), + 'response' => $response, + ]); + } +} diff --git a/src/Controller/Teamer/Questionnaire/EditController.php b/src/Controller/Teamer/Questionnaire/EditController.php new file mode 100644 index 0000000..6892100 --- /dev/null +++ b/src/Controller/Teamer/Questionnaire/EditController.php @@ -0,0 +1,93 @@ +responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ESCORT, $disposition); + + if (true === $response?->isSubmitted()) { + return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]); + } + + $definition = $this->catalog->get(QuestionnaireCatalog::TEAMER_ESCORT); + + $form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [ + 'definition' => $definition, + ]); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + if (null === $response) { + $response = QuestionnaireResponse::forDisposition($definition, $disposition); + $this->entityManager->persist($response); + } + + $context = DispositionContext::build($disposition); + $final = QuestionnaireType::isFinal($form); + + if (true === $final) { + $response->submit($definition, $form->getData(), $context, new \DateTimeImmutable()); + } else { + $response->saveDraft($form->getData(), $context); + } + + $this->entityManager->flush(); + + $this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [ + 'questionnaire' => $response->getQuestionnaire(), + 'questionnaire_response_id' => $response->getId(), + 'disposition_id' => $disposition->getId(), + 'teamer_id' => $disposition->getTeamer()->getId(), + ]); + + if (true === $final) { + $this->addFlash('success', 'Danke! Dein Fragebogen wurde abgeschickt.'); + + return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]); + } + + $this->addFlash('success', 'Dein Entwurf wurde gespeichert.'); + + return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]); + } + + return $this->render('teamer/questionnaire/edit.html.twig', [ + 'disposition' => $disposition, + 'definition' => $definition, + 'response' => $response, + 'form' => $form, + ]); + } +} diff --git a/src/Form/QuestionnaireType.php b/src/Form/QuestionnaireType.php index 5604fc6..0168dce 100644 --- a/src/Form/QuestionnaireType.php +++ b/src/Form/QuestionnaireType.php @@ -120,13 +120,14 @@ class QuestionnaireType extends AbstractType 'label' => $question->label, 'help' => $question->help, 'required' => false, - 'row_attr' => null === $question->showIf ? [] : [ + // read by assets/controllers/questionnaire_controller.js + 'row_attr' => ['data-question-key' => $question->key] + (null === $question->showIf ? [] : [ 'data-show-if' => json_encode([ 'question' => $question->showIf->question, 'operator' => $question->showIf->operator->value, 'value' => $question->showIf->value, ], JSON_THROW_ON_ERROR), - ], + ]), ]; return match ($question->type) { diff --git a/src/Menu/TeamerMenuBuilder.php b/src/Menu/TeamerMenuBuilder.php index 0c097b3..35a93c2 100644 --- a/src/Menu/TeamerMenuBuilder.php +++ b/src/Menu/TeamerMenuBuilder.php @@ -134,6 +134,12 @@ class TeamerMenuBuilder extends AbstractMenuBuilder 'linkAttributes' => [ 'title' => 'Letzte Einsätze', ], + // the questionnaire after a past bus escort + 'extras' => [ + 'routes' => [ + ['pattern' => '/^app_teamer_questionnaire_/'], + ], + ], ]); $menu->addChild('Dokumente', [ diff --git a/templates/forms.html.twig b/templates/forms.html.twig index 3ecb176..4c94e80 100644 --- a/templates/forms.html.twig +++ b/templates/forms.html.twig @@ -173,6 +173,38 @@ {%- endif -%} {%- endblock pickup_choice_widget -%} +{# + # A section of a questionnaire (App\Form\QuestionnaireType): its title and its questions. The + # questions' rows carry data-question-key and, when conditional, data-show-if, which + # assets/controllers/questionnaire_controller.js reads to show and hide them. + #} +{%- block questionnaire_section_row -%} +
+{%- endblock questionnaire_section_row -%} + +{# A 1-5 rating as a row of buttons, the radios themselves hidden. #} +{%- block questionnaire_rating_widget -%} ++ Abgeschickt am {{ response.submittedAt|date('d.m.Y, H:i') }} Uhr. Danke für dein Feedback! +
++ {{ definition.description|nl2br }} +
+ {% endif %} ++ Als Entwurf kannst du den Fragebogen jederzeit speichern und später weiter ausfüllen. + Abgeschickt ist er endgültig. +
+ {{ form_end(form) }} ++ Entwurf zuletzt gespeichert am {{ (response.updatedAt ?? response.createdAt)|date('d.m.Y, H:i') }} Uhr +
+ {% endif %} +