refactor: split the questionnaire into teamer and bus feedback
This commit is contained in:
1 parent
2f4f9800dd
commit
a0170f5952
35 files changed
+1114
-554
No files matched your search
@@ -4,7 +4,6 @@ namespace App\Config;
|
||||
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\ConditionOperator;
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
use App\Enum\QuestionType;
|
||||
use App\Model\Questionnaire\Condition;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
@@ -27,12 +26,17 @@ use Symfony\Component\Yaml\Yaml;
|
||||
class QuestionnaireCatalog
|
||||
{
|
||||
/**
|
||||
* The questionnaire a teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
|
||||
* The questionnaire every teamer fills in after a placement; see DispositionVoter::QUESTIONNAIRE.
|
||||
*/
|
||||
public const string TEAMER_ASSIGNMENT = 'teamer_assignment';
|
||||
public const string TEAMER_FEEDBACK = 'teamer_feedback';
|
||||
|
||||
/**
|
||||
* The one a teamer who escorted the bus fills in as well, on the same page.
|
||||
*/
|
||||
public const string BUS_FEEDBACK = 'bus_feedback';
|
||||
|
||||
private const array ROOT_FIELDS = ['key', 'version', 'title', 'description', 'sections'];
|
||||
private const array SECTION_FIELDS = ['key', 'title', 'only_for', 'questions'];
|
||||
private const array SECTION_FIELDS = ['key', 'title', 'questions'];
|
||||
private const array QUESTION_FIELDS = ['key', 'label', 'type', 'required', 'help', 'widget', 'options', 'show_if'];
|
||||
private const array OPTION_FIELDS = ['key', 'label'];
|
||||
|
||||
@@ -80,6 +84,26 @@ class QuestionnaireCatalog
|
||||
return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version));
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys of the questionnaires there are, i.e. of their current files.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function keys(): array
|
||||
{
|
||||
$keys = [];
|
||||
|
||||
foreach (glob($this->questionnaireDir.'/*.yaml') ?: [] as $file) {
|
||||
if (1 === preg_match('/^([a-z0-9_]+)\.yaml$/', basename($file), $match)) {
|
||||
$keys[] = $match[1];
|
||||
}
|
||||
}
|
||||
|
||||
sort($keys);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every definition in the directory, archived versions included.
|
||||
*
|
||||
@@ -130,8 +154,6 @@ class QuestionnaireCatalog
|
||||
$sectionKeys = [];
|
||||
/** @var array<string, Question> $questions every question parsed so far, for show_if */
|
||||
$questions = [];
|
||||
/** @var array<string, QuestionnaireFlag|null> $questionFlags the only_for of each question's section */
|
||||
$questionFlags = [];
|
||||
|
||||
foreach ($data['sections'] as $index => $sectionData) {
|
||||
$context = sprintf('%s: section #%d', $file, $index + 1);
|
||||
@@ -149,38 +171,16 @@ class QuestionnaireCatalog
|
||||
throw $this->error($context, 'questions must be a non-empty list');
|
||||
}
|
||||
|
||||
$onlyFor = null;
|
||||
|
||||
if (array_key_exists('only_for', $sectionData)) {
|
||||
$onlyFor = QuestionnaireFlag::tryFrom((string) $sectionData['only_for'])
|
||||
?? throw $this->error($context, sprintf('unknown only_for "%s"', $sectionData['only_for']));
|
||||
}
|
||||
|
||||
$sectionQuestions = [];
|
||||
|
||||
foreach ($sectionData['questions'] as $questionData) {
|
||||
$question = $this->buildQuestion($questionData, $questions, $context);
|
||||
|
||||
// A question that may be asked without the flag cannot depend on one that is only
|
||||
// asked with it: it would stay hidden for everyone without the flag.
|
||||
if (null !== $question->showIf
|
||||
&& null !== ($controllingFlag = $questionFlags[$question->showIf->question])
|
||||
&& $controllingFlag !== $onlyFor
|
||||
) {
|
||||
throw $this->error(sprintf('%s: question "%s": show_if', $context, $question->key), sprintf('"%s" is only asked for %s', $question->showIf->question, $controllingFlag->value));
|
||||
}
|
||||
|
||||
$questions[$question->key] = $question;
|
||||
$questionFlags[$question->key] = $onlyFor;
|
||||
$sectionQuestions[] = $question;
|
||||
}
|
||||
|
||||
$sections[] = new Section(
|
||||
$key,
|
||||
$this->optionalString($sectionData['title'] ?? null, 'title', $context),
|
||||
$sectionQuestions,
|
||||
$onlyFor,
|
||||
);
|
||||
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
|
||||
}
|
||||
|
||||
return new Definition(
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Controller\Administrative\Questionnaire;
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\QuestionnaireResponse;
|
||||
use App\Repository\QuestionnaireResponseRepository;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Requirement\Requirement;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
@@ -23,10 +25,12 @@ class DetailController extends AbstractController
|
||||
|
||||
public function __construct(
|
||||
private readonly QuestionnaireCatalog $catalog,
|
||||
private readonly QuestionnaireResponseRepository $responseRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail')]
|
||||
// the requirement keeps /administrative/questionnaire/list from being read as a uuid
|
||||
#[Route('/administrative/questionnaire/{uuid}', name: 'app_administrative_questionnaire_detail', requirements: ['uuid' => Requirement::UUID])]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
@@ -38,9 +42,19 @@ class DetailController extends AbstractController
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
// the other questionnaire of the same placement, e.g. the bus feedback beside the teamer feedback
|
||||
$siblings = array_filter(
|
||||
$this->responseRepository->findByDisposition($response->getDisposition()),
|
||||
static fn (QuestionnaireResponse $sibling): bool => $sibling !== $response && $sibling->isSubmitted(),
|
||||
);
|
||||
|
||||
return $this->render('administrative/questionnaire/detail.html.twig', [
|
||||
'definition' => $this->catalog->get($response->getQuestionnaire(), $response->getVersion()),
|
||||
'response' => $response,
|
||||
'siblings' => array_map(fn (QuestionnaireResponse $sibling): array => [
|
||||
'response' => $sibling,
|
||||
'title' => $this->catalog->get($sibling->getQuestionnaire(), $sibling->getVersion())->title,
|
||||
], array_values($siblings)),
|
||||
'returnUrl' => $this->getReturnUrl($request, 'app_administrative_questionnaire_index'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The submitted teamer questionnaires. Only administrative users read them: they rate the house
|
||||
* manager among others, so house managers are deliberately not let in.
|
||||
* The submitted responses to one questionnaire, teamer or bus feedback. Only administrative users
|
||||
* read them: they rate the house manager among others, so house managers are deliberately not
|
||||
* let in.
|
||||
*/
|
||||
class IndexController extends AbstractController
|
||||
{
|
||||
@@ -26,14 +27,25 @@ class IndexController extends AbstractController
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/administrative/questionnaire', name: 'app_administrative_questionnaire_index')]
|
||||
#[Route(
|
||||
'/administrative/questionnaire/list/{questionnaire}',
|
||||
name: 'app_administrative_questionnaire_index',
|
||||
requirements: ['questionnaire' => '[a-z0-9_]+'],
|
||||
defaults: ['questionnaire' => QuestionnaireCatalog::TEAMER_FEEDBACK],
|
||||
)]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function index(Request $request): Response
|
||||
public function index(string $questionnaire, Request $request): Response
|
||||
{
|
||||
$keys = $this->catalog->keys();
|
||||
|
||||
if (!in_array($questionnaire, $keys, true)) {
|
||||
throw $this->createNotFoundException(sprintf('No questionnaire "%s"', $questionnaire));
|
||||
}
|
||||
|
||||
$filterDto = $this->filterHandler->getFilterSettings();
|
||||
|
||||
$pagination = $this->paginator->paginate(
|
||||
$this->responseRepository->getListQuery(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $filterDto),
|
||||
$this->responseRepository->getListQuery($questionnaire, $filterDto),
|
||||
$request->query->getInt('page', 1),
|
||||
$request->query->getInt('limit', 50),
|
||||
[
|
||||
@@ -43,7 +55,9 @@ class IndexController extends AbstractController
|
||||
);
|
||||
|
||||
return $this->render('administrative/questionnaire/index.html.twig', [
|
||||
'definition' => $this->catalog->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT),
|
||||
'definition' => $this->catalog->get($questionnaire),
|
||||
// for switching between the questionnaires
|
||||
'definitions' => array_map($this->catalog->get(...), $keys),
|
||||
'pagination' => $pagination,
|
||||
'filterDto' => $filterDto,
|
||||
]);
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace App\Controller\Administrative\Questionnaire;
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Entity\Assignment;
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
use App\Form\QuestionnaireType;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -15,10 +15,11 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The teamer questionnaire as a teamer gets it, for trying out on any assignment - before or after
|
||||
* the trip, placed or not. It shows the assignment's own variant, with or without the bus
|
||||
* sections; ?bus_escort=1 or 0 tries the other one. Nothing is saved: "Absenden" shows the answers
|
||||
* as they would be stored, "Entwurf speichern" just keeps them in the form.
|
||||
* The questionnaires as a teamer gets them, for trying out on any assignment - before or after the
|
||||
* trip, placed or not. It shows what a placement on this assignment gets - the teamer feedback,
|
||||
* and the bus feedback too for a bus escort; ?bus_escort=1 or 0 tries the other case. Nothing is
|
||||
* saved: "Absenden" shows the answers as they would be stored, "Entwurf speichern" just keeps them
|
||||
* in the form.
|
||||
*/
|
||||
class TestController extends AbstractController
|
||||
{
|
||||
@@ -39,12 +40,13 @@ class TestController extends AbstractController
|
||||
$busEscort = $request->query->has('bus_escort')
|
||||
? $request->query->getBoolean('bus_escort')
|
||||
: $assignment->hasBusEscort();
|
||||
$definition = $this->catalog
|
||||
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
|
||||
->forFlags($busEscort ? [QuestionnaireFlag::BUS_ESCORT] : [])
|
||||
;
|
||||
$definitions = [$this->catalog->get(QuestionnaireCatalog::TEAMER_FEEDBACK)];
|
||||
|
||||
$form = $this->createForm(QuestionnaireType::class, [], ['definition' => $definition]);
|
||||
if ($busEscort) {
|
||||
$definitions[] = $this->catalog->get(QuestionnaireCatalog::BUS_FEEDBACK);
|
||||
}
|
||||
|
||||
$form = $this->createForm(QuestionnaireType::class, [], ['definitions' => $definitions]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$submitted = null;
|
||||
@@ -52,7 +54,10 @@ class TestController extends AbstractController
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
if (QuestionnaireType::isFinal($form)) {
|
||||
$submitted = $definition->withoutHiddenAnswers($form->getData());
|
||||
$submitted = array_map(static fn (Definition $definition): array => [
|
||||
'definition' => $definition,
|
||||
'answers' => $definition->withoutHiddenAnswers($form->getData()[$definition->key] ?? []),
|
||||
], $definitions);
|
||||
} else {
|
||||
$draft = true;
|
||||
}
|
||||
@@ -60,7 +65,6 @@ class TestController extends AbstractController
|
||||
|
||||
return $this->render('administrative/questionnaire/test.html.twig', [
|
||||
'assignment' => $assignment,
|
||||
'definition' => $definition,
|
||||
'form' => $form,
|
||||
'submitted' => $submitted,
|
||||
'draft' => $draft,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controller\Common;
|
||||
|
||||
use App\Controller\Traits\ReturnUrlTrait;
|
||||
use App\Form\QuestionnaireResponseFilterType;
|
||||
use App\Htmx\HxRedirectResponse;
|
||||
use App\Service\Common\QuestionnaireResponseFilterHandler;
|
||||
@@ -11,8 +12,13 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* One filter for both questionnaire lists; it returns to the list it was opened from.
|
||||
*/
|
||||
class QuestionnaireResponseFilterController extends AbstractController
|
||||
{
|
||||
use ReturnUrlTrait;
|
||||
|
||||
public function __construct(private readonly QuestionnaireResponseFilterHandler $filterHandler)
|
||||
{
|
||||
}
|
||||
@@ -28,7 +34,7 @@ class QuestionnaireResponseFilterController extends AbstractController
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->filterHandler->handleRequest($form);
|
||||
|
||||
return new HxRedirectResponse($this->generateUrl('app_administrative_questionnaire_index'));
|
||||
return new HxRedirectResponse($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
|
||||
}
|
||||
|
||||
return $this->render('common/modal_questionnaire_response_filter.html.twig', [
|
||||
@@ -39,10 +45,10 @@ class QuestionnaireResponseFilterController extends AbstractController
|
||||
|
||||
#[Route('/common/questionnaire-response/filter/reset', name: 'app_common_questionnaire_response_filter_reset')]
|
||||
#[IsGranted('ROLE_ADMINISTRATIVE')]
|
||||
public function reset(): Response
|
||||
public function reset(Request $request): Response
|
||||
{
|
||||
$this->filterHandler->resetFilterSettings();
|
||||
|
||||
return $this->redirectToRoute('app_administrative_questionnaire_index');
|
||||
return $this->redirect($this->getReturnUrl($request, 'app_administrative_questionnaire_index'));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Controller\Teamer\Questionnaire;
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Disposition;
|
||||
use App\Repository\QuestionnaireResponseRepository;
|
||||
use App\Service\Questionnaire\DispositionContext;
|
||||
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -12,8 +13,8 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The teamer's own submitted questionnaire, read-only, rendered with the definition version it
|
||||
* was submitted under.
|
||||
* The teamer's own submitted questionnaires for a placement, read-only, each rendered with the
|
||||
* definition version it was submitted under.
|
||||
*/
|
||||
class DetailController extends AbstractController
|
||||
{
|
||||
@@ -30,16 +31,34 @@ class DetailController extends AbstractController
|
||||
#[MapEntity(mapping: ['uuid' => 'uuid'])]
|
||||
Disposition $disposition,
|
||||
): Response {
|
||||
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
|
||||
$submitted = [];
|
||||
$open = false;
|
||||
$responses = $this->responseRepository->findByDisposition($disposition);
|
||||
|
||||
if (true !== $response?->isSubmitted()) {
|
||||
// what applies now, plus anything submitted under an earlier state of the assignment
|
||||
$keys = array_unique([...DispositionContext::questionnaires($disposition->getAssignment()), ...array_keys($responses)]);
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$response = $responses[$key] ?? null;
|
||||
|
||||
if (true === $response?->isSubmitted()) {
|
||||
$submitted[] = [
|
||||
'definition' => $this->catalog->get($key, $response->getVersion()),
|
||||
'response' => $response,
|
||||
];
|
||||
} else {
|
||||
$open = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $submitted) {
|
||||
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,
|
||||
'submitted' => $submitted,
|
||||
'open' => $open,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,13 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* The teamer's questionnaire after a placement - with the bus sections if it was a bus escort -
|
||||
* saved as a draft until it is submitted. A submitted one is final and only shown read-only
|
||||
* The teamer's questionnaires after a placement on one page: the teamer feedback, and after a bus
|
||||
* escort the bus feedback too. Saved as drafts until submitted; one submit submits all of them,
|
||||
* each as a response of its own. Submitted ones are final and only shown read-only
|
||||
* (DetailController).
|
||||
*
|
||||
* Only the questionnaires not yet submitted are offered, so one that applies later - an escort
|
||||
* added after the teamer submitted - can still be filled in.
|
||||
*/
|
||||
class EditController extends AbstractController
|
||||
{
|
||||
@@ -40,45 +44,60 @@ class EditController extends AbstractController
|
||||
Disposition $disposition,
|
||||
Request $request,
|
||||
): Response {
|
||||
$response = $this->responseRepository->findOneByDisposition(QuestionnaireCatalog::TEAMER_ASSIGNMENT, $disposition);
|
||||
$responses = $this->responseRepository->findByDisposition($disposition);
|
||||
$definitions = [];
|
||||
|
||||
if (true === $response?->isSubmitted()) {
|
||||
foreach (DispositionContext::questionnaires($disposition->getAssignment()) as $key) {
|
||||
if (true !== ($responses[$key] ?? null)?->isSubmitted()) {
|
||||
$definitions[] = $this->catalog->get($key);
|
||||
}
|
||||
}
|
||||
|
||||
if ([] === $definitions) {
|
||||
return $this->redirectToRoute('app_teamer_questionnaire_detail', ['uuid' => $disposition->getUuid()]);
|
||||
}
|
||||
|
||||
$definition = $this->catalog
|
||||
->get(QuestionnaireCatalog::TEAMER_ASSIGNMENT)
|
||||
->forFlags(DispositionContext::flags($disposition->getAssignment()))
|
||||
;
|
||||
$data = [];
|
||||
|
||||
$form = $this->createForm(QuestionnaireType::class, $response?->getAnswers() ?? [], [
|
||||
'definition' => $definition,
|
||||
]);
|
||||
foreach ($definitions as $definition) {
|
||||
$data[$definition->key] = ($responses[$definition->key] ?? null)?->getAnswers() ?? [];
|
||||
}
|
||||
|
||||
$form = $this->createForm(QuestionnaireType::class, $data, ['definitions' => $definitions]);
|
||||
$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);
|
||||
foreach ($definitions as $definition) {
|
||||
$response = $responses[$definition->key] ?? null;
|
||||
|
||||
if (null === $response) {
|
||||
$response = QuestionnaireResponse::forDisposition($definition, $disposition);
|
||||
$this->entityManager->persist($response);
|
||||
$responses[$definition->key] = $response;
|
||||
}
|
||||
|
||||
$answers = $form->getData()[$definition->key] ?? [];
|
||||
|
||||
if (true === $final) {
|
||||
$response->submit($definition, $answers, $context, new \DateTimeImmutable());
|
||||
} else {
|
||||
$response->saveDraft($answers, $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(),
|
||||
]);
|
||||
foreach ($definitions as $definition) {
|
||||
$this->logger->info($final ? 'Submit questionnaire' : 'Save questionnaire draft', [
|
||||
'questionnaire' => $definition->key,
|
||||
'questionnaire_response_id' => $responses[$definition->key]->getId(),
|
||||
'disposition_id' => $disposition->getId(),
|
||||
'teamer_id' => $disposition->getTeamer()->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (true === $final) {
|
||||
$this->addFlash('success', 'Danke! Dein Fragebogen wurde abgeschickt.');
|
||||
@@ -91,11 +110,15 @@ class EditController extends AbstractController
|
||||
return $this->redirectToRoute('app_teamer_questionnaire_edit', ['uuid' => $disposition->getUuid()]);
|
||||
}
|
||||
|
||||
$drafts = array_filter($responses, static fn (QuestionnaireResponse $response): bool => !$response->isSubmitted());
|
||||
|
||||
return $this->render('teamer/questionnaire/edit.html.twig', [
|
||||
'disposition' => $disposition,
|
||||
'definition' => $definition,
|
||||
'response' => $response,
|
||||
'form' => $form,
|
||||
'lastSaved' => [] === $drafts ? null : max(array_map(
|
||||
static fn (QuestionnaireResponse $response): \DateTimeInterface => $response->getUpdatedAt() ?? $response->getCreatedAt(),
|
||||
$drafts,
|
||||
)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* Something a questionnaire's subject may or may not have, deciding whether a section marked
|
||||
* `only_for` it is part of the questionnaire at all. Unlike show_if, it is not answered by the
|
||||
* respondent but known beforehand - for a disposition, by DispositionContext::flags().
|
||||
*/
|
||||
enum QuestionnaireFlag: string
|
||||
{
|
||||
case BUS_ESCORT = 'bus_escort';
|
||||
}
|
||||
@@ -21,8 +21,10 @@ use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Builds a questionnaire form from its definition. The data is the flat answers array keyed by
|
||||
* question key; each section is a child with inherit_data, so it groups the fields on screen
|
||||
* Builds one form from one or more questionnaire definitions - a teamer after a bus escort answers
|
||||
* teamer_feedback and bus_feedback on one page. The data is keyed by questionnaire key, each
|
||||
* holding that questionnaire's flat answers keyed by question key: every questionnaire is a child
|
||||
* of its own, and each of its sections a child with inherit_data, which groups the fields on screen
|
||||
* without nesting the answers.
|
||||
*
|
||||
* Two buttons: "draft" saves whatever is filled in, "submit" also demands every required question
|
||||
@@ -39,21 +41,32 @@ class QuestionnaireType extends AbstractType
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
/** @var Definition $definition */
|
||||
$definition = $options['definition'];
|
||||
/** @var list<Definition> $definitions */
|
||||
$definitions = $options['definitions'];
|
||||
|
||||
foreach ($definition->sections as $section) {
|
||||
$sectionBuilder = $builder->create($section->key, FormType::class, [
|
||||
'inherit_data' => true,
|
||||
'label' => $section->title ?? false,
|
||||
'block_prefix' => 'questionnaire_section',
|
||||
foreach ($definitions as $definition) {
|
||||
$questionnaireBuilder = $builder->create($definition->key, FormType::class, [
|
||||
'label' => $definition->title,
|
||||
'help' => $definition->description,
|
||||
'empty_data' => [],
|
||||
'block_prefix' => 'questionnaire_part',
|
||||
]);
|
||||
|
||||
foreach ($section->questions as $question) {
|
||||
$sectionBuilder->add($question->key, ...$this->field($question));
|
||||
foreach ($definition->sections as $section) {
|
||||
$sectionBuilder = $builder->create($section->key, FormType::class, [
|
||||
'inherit_data' => true,
|
||||
'label' => $section->title ?? false,
|
||||
'block_prefix' => 'questionnaire_section',
|
||||
]);
|
||||
|
||||
foreach ($section->questions as $question) {
|
||||
$sectionBuilder->add($question->key, ...$this->field($definition, $question));
|
||||
}
|
||||
|
||||
$questionnaireBuilder->add($sectionBuilder);
|
||||
}
|
||||
|
||||
$builder->add($sectionBuilder);
|
||||
$builder->add($questionnaireBuilder);
|
||||
}
|
||||
|
||||
$builder
|
||||
@@ -63,24 +76,26 @@ class QuestionnaireType extends AbstractType
|
||||
->add(self::SUBMIT, SubmitType::class, [
|
||||
'label' => 'Absenden',
|
||||
])
|
||||
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definition): void {
|
||||
->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($definitions): void {
|
||||
$form = $event->getForm();
|
||||
|
||||
if (!$this->isFinal($form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$answers = $form->getData();
|
||||
foreach ($definitions as $definition) {
|
||||
$answers = $form->getData()[$definition->key] ?? [];
|
||||
|
||||
foreach ($definition->sections as $section) {
|
||||
foreach ($section->questions as $question) {
|
||||
if ($question->required
|
||||
&& $definition->isVisible($question, $answers)
|
||||
&& $this->isEmpty($answers[$question->key] ?? null)
|
||||
) {
|
||||
$form->get($section->key)->get($question->key)->addError(
|
||||
new FormError('Bitte beantworte diese Frage.')
|
||||
);
|
||||
foreach ($definition->sections as $section) {
|
||||
foreach ($section->questions as $question) {
|
||||
if ($question->required
|
||||
&& $definition->isVisible($question, $answers)
|
||||
&& $this->isEmpty($answers[$question->key] ?? null)
|
||||
) {
|
||||
$form->get($definition->key)->get($section->key)->get($question->key)->addError(
|
||||
new FormError('Bitte beantworte diese Frage.')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,8 +106,10 @@ class QuestionnaireType extends AbstractType
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver
|
||||
->setRequired('definition')
|
||||
->setAllowedTypes('definition', Definition::class)
|
||||
->setRequired('definitions')
|
||||
->setAllowedTypes('definitions', Definition::class.'[]')
|
||||
->setAllowedValues('definitions', static fn (array $definitions): bool => [] !== $definitions
|
||||
&& count($definitions) === count(array_unique(array_map(static fn (Definition $definition): string => $definition->key, $definitions))))
|
||||
->setDefaults([
|
||||
'data_class' => null,
|
||||
'empty_data' => [],
|
||||
@@ -114,16 +131,17 @@ class QuestionnaireType extends AbstractType
|
||||
/**
|
||||
* @return array{0: class-string, 1: array<string, mixed>}
|
||||
*/
|
||||
private function field(Question $question): array
|
||||
private function field(Definition $definition, Question $question): array
|
||||
{
|
||||
$common = [
|
||||
'label' => $question->label,
|
||||
'help' => $question->help,
|
||||
'required' => false,
|
||||
// read by assets/controllers/questionnaire_controller.js
|
||||
'row_attr' => ['data-question-key' => $question->key] + (null === $question->showIf ? [] : [
|
||||
// read by assets/controllers/questionnaire_controller.js; prefixed with the questionnaire,
|
||||
// since question keys are only unique within one
|
||||
'row_attr' => ['data-question-key' => $definition->key.'.'.$question->key] + (null === $question->showIf ? [] : [
|
||||
'data-show-if' => json_encode([
|
||||
'question' => $question->showIf->question,
|
||||
'question' => $definition->key.'.'.$question->showIf->question,
|
||||
'operator' => $question->showIf->operator->value,
|
||||
'value' => $question->showIf->value,
|
||||
], JSON_THROW_ON_ERROR),
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
|
||||
/**
|
||||
* One version of a questionnaire as read from config/questionnaires/. Responses store its key and
|
||||
* version, and their answers are keyed by Question::$key.
|
||||
@@ -22,26 +20,6 @@ readonly class Definition
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The questionnaire as one subject gets it: without the sections marked `only_for` a flag the
|
||||
* subject does not have. Key and version stay, so answers to it are answers to this definition.
|
||||
*
|
||||
* @param list<QuestionnaireFlag> $flags
|
||||
*/
|
||||
public function forFlags(array $flags): static
|
||||
{
|
||||
return new static(
|
||||
$this->key,
|
||||
$this->version,
|
||||
$this->title,
|
||||
$this->description,
|
||||
array_values(array_filter(
|
||||
$this->sections,
|
||||
static fn (Section $section): bool => null === $section->onlyFor || in_array($section->onlyFor, $flags, true),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Question> in the order they are asked, keyed by question key
|
||||
*/
|
||||
|
||||
@@ -2,19 +2,15 @@
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
|
||||
readonly class Section
|
||||
{
|
||||
/**
|
||||
* @param list<Question> $questions
|
||||
* @param QuestionnaireFlag|null $onlyFor part of the questionnaire only when the subject has this flag
|
||||
* @param list<Question> $questions
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public ?string $title,
|
||||
public array $questions,
|
||||
public ?QuestionnaireFlag $onlyFor = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -77,30 +77,38 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* The teamer's placements whose questionnaire (config/questionnaires/teamer_assignment.yaml) is
|
||||
* still to be submitted: not started, or saved as a draft. The rules are those of
|
||||
* The teamer's placements with a questionnaire still to be submitted - not started, or saved as
|
||||
* a draft: the teamer feedback, and after a bus escort the bus feedback too
|
||||
* (DispositionContext::questionnaires(), spelled out here in DQL). The rules are those of
|
||||
* DispositionVoter::QUESTIONNAIRE - over (today included), on or after go-live, neither called
|
||||
* off nor skip-formalities - so every row shown on the dashboard can be opened.
|
||||
*
|
||||
* The response is joined as an arbitrary entity because the association is one-way; the
|
||||
* questionnaire key has to be part of the JOIN condition, or a response to another
|
||||
* questionnaire would hide this one.
|
||||
* The responses are joined as arbitrary entities because the association is one-way; the
|
||||
* questionnaire key has to be part of each JOIN condition, or one questionnaire's response
|
||||
* would stand in for the other's.
|
||||
*
|
||||
* Rows are [0 => Disposition, 'responseStatus' => ?QuestionnaireResponseStatus].
|
||||
* Rows are [0 => Disposition, 'teamerStatus' => ?QuestionnaireResponseStatus,
|
||||
* 'busStatus' => ?QuestionnaireResponseStatus].
|
||||
*/
|
||||
public function getPendingQuestionnaireQuery(Teamer $teamer, \DateTimeImmutable $goLive): Query
|
||||
{
|
||||
$qb = $this->createQueryBuilder('disposition');
|
||||
|
||||
return $qb
|
||||
->select('disposition', 'assignment', 'destination', 'response.status AS responseStatus')
|
||||
->select('disposition', 'assignment', 'destination', 'teamer_response.status AS teamerStatus', 'bus_response.status AS busStatus')
|
||||
->innerJoin('disposition.assignment', 'assignment')
|
||||
->innerJoin('assignment.destination', 'destination')
|
||||
->leftJoin(
|
||||
QuestionnaireResponse::class,
|
||||
'response',
|
||||
'teamer_response',
|
||||
Join::ON,
|
||||
'response.disposition = disposition AND response.questionnaire = :questionnaire'
|
||||
'teamer_response.disposition = disposition AND teamer_response.questionnaire = :teamerQuestionnaire'
|
||||
)
|
||||
->leftJoin(
|
||||
QuestionnaireResponse::class,
|
||||
'bus_response',
|
||||
Join::ON,
|
||||
'bus_response.disposition = disposition AND bus_response.questionnaire = :busQuestionnaire'
|
||||
)
|
||||
->where($qb->expr()->andX(
|
||||
$qb->expr()->eq('disposition.teamer', ':teamer'),
|
||||
@@ -110,13 +118,23 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
$qb->expr()->gte('destination.dateTo', ':goLive'),
|
||||
$qb->expr()->lte('destination.dateTo', ':today'),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('response.id'),
|
||||
$qb->expr()->eq('response.status', ':draft')
|
||||
$qb->expr()->isNull('teamer_response.id'),
|
||||
$qb->expr()->eq('teamer_response.status', ':draft'),
|
||||
// a bus escort, i.e. Assignment::hasBusEscort(), owing the bus feedback
|
||||
$qb->expr()->andX(
|
||||
$qb->expr()->isNotNull('assignment.pickup'),
|
||||
$qb->expr()->neq('assignment.pickup', 0),
|
||||
$qb->expr()->orX(
|
||||
$qb->expr()->isNull('bus_response.id'),
|
||||
$qb->expr()->eq('bus_response.status', ':draft')
|
||||
)
|
||||
)
|
||||
)
|
||||
))
|
||||
->orderBy('destination.dateTo', \SortDirection::Descending)
|
||||
->setParameter('teamer', $teamer)
|
||||
->setParameter('questionnaire', QuestionnaireCatalog::TEAMER_ASSIGNMENT)
|
||||
->setParameter('teamerQuestionnaire', QuestionnaireCatalog::TEAMER_FEEDBACK)
|
||||
->setParameter('busQuestionnaire', QuestionnaireCatalog::BUS_FEEDBACK)
|
||||
->setParameter('dispositionCalledOff', Disposition::STATUS_CALLED_OFF)
|
||||
->setParameter('assignmentStatuses', [Assignment::STATUS_CALLED_OFF, Assignment::STATUS_DELETED])
|
||||
->setParameter('skipFormalities', false)
|
||||
@@ -135,7 +153,8 @@ class DispositionRepository extends ServiceEntityRepository
|
||||
return array_map(
|
||||
static fn (array $row): array => [
|
||||
'disposition' => $row[0],
|
||||
'draft' => QuestionnaireResponseStatus::DRAFT === $row['responseStatus'],
|
||||
'draft' => QuestionnaireResponseStatus::DRAFT === $row['teamerStatus']
|
||||
|| QuestionnaireResponseStatus::DRAFT === $row['busStatus'],
|
||||
],
|
||||
$this->getPendingQuestionnaireQuery($teamer, $goLive)->getResult(),
|
||||
);
|
||||
|
||||
@@ -80,8 +80,17 @@ class QuestionnaireResponseRepository extends ServiceEntityRepository
|
||||
return $qb->getQuery();
|
||||
}
|
||||
|
||||
public function findOneByDisposition(string $questionnaire, Disposition $disposition): ?QuestionnaireResponse
|
||||
/**
|
||||
* @return array<string, QuestionnaireResponse> keyed by questionnaire key
|
||||
*/
|
||||
public function findByDisposition(Disposition $disposition): array
|
||||
{
|
||||
return $this->findOneBy(['questionnaire' => $questionnaire, 'disposition' => $disposition]);
|
||||
$responses = [];
|
||||
|
||||
foreach ($this->findBy(['disposition' => $disposition]) as $response) {
|
||||
$responses[$response->getQuestionnaire()] = $response;
|
||||
}
|
||||
|
||||
return $responses;
|
||||
}
|
||||
}
|
||||
@@ -117,11 +117,11 @@ class DispositionVoter extends Voter
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the teamer may fill in, and later read, the questionnaire after this placement
|
||||
* (config/questionnaires/teamer_assignment.yaml). Every placement gets it; one with a bus
|
||||
* escort gets the bus sections as well, which is the form's business, not this voter's. Only
|
||||
* the teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE,
|
||||
* not through this attribute.
|
||||
* Whether the teamer may fill in, and later read, the questionnaires after this placement:
|
||||
* every placement gets the teamer feedback, a bus escort the bus feedback as well
|
||||
* (DispositionContext::questionnaires() - which ones is not this voter's business). Only the
|
||||
* teamer themself: the office reads the submitted responses through ROLE_ADMINISTRATIVE, not
|
||||
* through this attribute.
|
||||
*
|
||||
* "Over" is the destination's end date, not Disposition::STATUS_ENDED: the cron sets that a
|
||||
* run late, and it moves on to checking_invoice and completed while the date stays true. An
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace App\Service\Questionnaire;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Entity\Assignment;
|
||||
use App\Entity\Disposition;
|
||||
use App\Enum\QuestionnaireFlag;
|
||||
|
||||
/**
|
||||
* What a disposition already knows, snapshotted into a QuestionnaireResponse so the questionnaire
|
||||
@@ -16,14 +16,19 @@ use App\Enum\QuestionnaireFlag;
|
||||
final class DispositionContext
|
||||
{
|
||||
/**
|
||||
* Which `only_for` sections of the questionnaire apply to a placement on this assignment.
|
||||
* Takes the assignment so the office's test mode can ask before anyone is placed.
|
||||
* The questionnaires a placement on this assignment gets, in the order they are shown: every
|
||||
* teamer the teamer feedback, a bus escort the bus feedback as well. Takes the assignment so
|
||||
* the office's test mode can ask before anyone is placed.
|
||||
*
|
||||
* @return list<QuestionnaireFlag>
|
||||
* DispositionRepository::getPendingQuestionnaireQuery() spells the same rule in DQL.
|
||||
*
|
||||
* @return list<string> questionnaire keys
|
||||
*/
|
||||
public static function flags(Assignment $assignment): array
|
||||
public static function questionnaires(Assignment $assignment): array
|
||||
{
|
||||
return $assignment->hasBusEscort() ? [QuestionnaireFlag::BUS_ESCORT] : [];
|
||||
return $assignment->hasBusEscort()
|
||||
? [QuestionnaireCatalog::TEAMER_FEEDBACK, QuestionnaireCatalog::BUS_FEEDBACK]
|
||||
: [QuestionnaireCatalog::TEAMER_FEEDBACK];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user