feat: add questionnaire definitions and their catalog
This commit is contained in:
1 parent
2ec2a4c93f
commit
a84275b40e
12 files changed
+1136
No files matched your search
@@ -0,0 +1,369 @@
|
||||
# Questionnaire a teamer fills in after an assignment with bus escort.
|
||||
#
|
||||
# Replaces the Google forms "RL-Feedback 25-26" and "Bus-Feedback 25-26"; the question-by-question
|
||||
# spec, with every decision taken, is in .ddev/plans/questionnaires/. Questions whose answer the
|
||||
# disposition already knows (name, job profile, house, dates, destination, escort pickup) are not
|
||||
# asked: they are snapshotted into the response's context instead.
|
||||
#
|
||||
# Parsed and validated by App\Config\QuestionnaireCatalog; QuestionnaireCatalogTest builds it.
|
||||
#
|
||||
# Labels, help texts, section titles and option labels may be edited in place. Renaming or
|
||||
# removing a question or option key, or changing a type, needs a new version: copy this file to
|
||||
# teamer_escort.v1.yaml first, then bump `version` here. Submitted answers are keyed by it.
|
||||
#
|
||||
# Question fields: key, label, type (text | textarea | choice | multi | date | rating),
|
||||
# required (default false), help, widget (choice only: radio | dropdown, default radio),
|
||||
# options (choice and multi only), show_if ({question, equals | in | lte}, earlier questions only).
|
||||
|
||||
key: teamer_escort
|
||||
version: 1
|
||||
title: "Reiseleiter/-in Feedback"
|
||||
description: |-
|
||||
Nur durch euer Feedback können wir besser werden! 🙂
|
||||
|
||||
Bei Bewertunsskalen: 1= schlecht, 5 = sehr gut
|
||||
|
||||
sections:
|
||||
- key: organisation
|
||||
title: "Organisation & Ablauf"
|
||||
questions:
|
||||
- key: office_briefing_rating
|
||||
label: "Wie gut hast du dich vorab vom Büro gebrieft gefühlt?"
|
||||
type: rating
|
||||
required: true
|
||||
|
||||
- key: office_briefing_low_reason
|
||||
label: "Wenn 1 - 3 Sterne Bewertung, warum?"
|
||||
type: textarea
|
||||
required: true
|
||||
show_if: { question: office_briefing_rating, lte: 3 }
|
||||
|
||||
- key: team_week_seminar
|
||||
label: "War das Wochenteam-Seminar hilfreich für dich? Warst du bzw. wurdest du gut vorbereitet?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: very_helpful, label: "Ja, die Schulung war sehr hilfreich und ich fühlte mich bestens vorbereitet." }
|
||||
- { key: mostly_helpful, label: "Ja, größtenteils – einige Themen hätte man noch vertiefen können." }
|
||||
- { key: partly_helpful, label: "Es ging – die Schulung war teilweise hilfreich, aber nicht ausreichend für alle Aufgaben." }
|
||||
- { key: rather_not_helpful, label: "Eher nein – ich fühlte mich in vielen Bereichen unvorbereitet." }
|
||||
- { key: not_helpful, label: "Nein, die Schulung war kaum hilfreich und hat mich nicht auf den Arbeitsalltag vorbereitet." }
|
||||
- { key: not_attended, label: "Ich habe nicht teilgenommen." }
|
||||
|
||||
- key: onsite_organisation_rating
|
||||
label: "Wie gut lief die Reiseorganisation vor Ort?"
|
||||
type: rating
|
||||
required: true
|
||||
|
||||
- key: had_organisational_problems
|
||||
label: "Gab es organisatorische Probleme?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: organisational_problems
|
||||
label: "Wenn ja, welche?"
|
||||
type: text
|
||||
show_if: { question: had_organisational_problems, equals: "yes" }
|
||||
|
||||
- key: communication
|
||||
title: "Kommunikation & Zusammenarbeit"
|
||||
questions:
|
||||
- key: teamer_collaboration_rating
|
||||
label: "Wie lief die Kommunikation & Zusammenarbeit mit den E&P Teamer:innen vor Ort während der Reise?"
|
||||
type: rating
|
||||
required: true
|
||||
|
||||
- key: support_wished
|
||||
label: "Welche Unterstützung hättest du dir zusätzlich gewünscht?"
|
||||
type: textarea
|
||||
|
||||
- key: house_manager_problem_handling
|
||||
label: "Wie ist deine Hausleitung mit Problemen vor Ort umgegangen?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut – Probleme wurden schnell, fair und lösungsorientiert behandelt." }
|
||||
- { key: good, label: "Gut – die meisten Anliegen wurden ernst genommen und bearbeitet." }
|
||||
- { key: mixed, label: "Teils gut/teils schlecht – manche Probleme wurden gelöst, andere nicht." }
|
||||
- { key: rather_poor, label: "Eher schlecht – es wurde wenig unternommen oder nicht auf Augenhöhe kommuniziert." }
|
||||
- { key: very_poor, label: "Sehr schlecht – Probleme wurden ignoriert oder verschlimmert." }
|
||||
|
||||
- key: felt_overwhelmed
|
||||
label: "Gab es Momente, in denen du dich überfordert gefühlt hast?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: overwhelmed_reason
|
||||
label: "Wenn ja, wodurch?"
|
||||
type: textarea
|
||||
show_if: { question: felt_overwhelmed, equals: "yes" }
|
||||
|
||||
- key: improvement
|
||||
title: "Verbesserung"
|
||||
questions:
|
||||
- key: wished_differently
|
||||
label: "Was hättest du dir anders oder besser gewünscht?"
|
||||
type: textarea
|
||||
|
||||
- key: would_work_again
|
||||
label: "Würdest du wieder als Reiseleitung für E&P arbeiten?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: would_not_work_again_reason
|
||||
label: "Wenn nein, warum nicht?"
|
||||
type: textarea
|
||||
show_if: { question: would_work_again, equals: "no" }
|
||||
|
||||
- key: other
|
||||
title: "Sonstiges"
|
||||
questions:
|
||||
- key: anything_else
|
||||
label: |-
|
||||
Du hast nun schon vieles beantwortet,
|
||||
aber nirgendwo konntest du folgende Aspekte loswerden:
|
||||
type: textarea
|
||||
|
||||
- key: bus_trip
|
||||
title: "Busfahrt"
|
||||
questions:
|
||||
- key: direction
|
||||
label: "Hin- oder Rückfahrt"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: outbound, label: "Hinfahrt" }
|
||||
- { key: return, label: "Rückfahrt" }
|
||||
|
||||
- key: bus_company
|
||||
label: "Bus-Unternehmen:"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: boonk, label: "Boonk Reisen" }
|
||||
- { key: piccolonia, label: "Piccolonia" }
|
||||
- { key: heess, label: "Heeß" }
|
||||
- { key: finkbeiner, label: "Finkbeiner" }
|
||||
- { key: other, label: "Anderes Unternehmen" }
|
||||
|
||||
- key: bus_company_other
|
||||
label: "Name des Busunternehmens:"
|
||||
type: text
|
||||
required: true
|
||||
show_if: { question: bus_company, equals: other }
|
||||
|
||||
- key: bus_team
|
||||
title: "Busteam"
|
||||
questions:
|
||||
- key: driver_names
|
||||
label: "Busfahrer-Namen:"
|
||||
type: text
|
||||
required: true
|
||||
|
||||
- key: bus_first_pickup
|
||||
title: "1. Abfahrtsort"
|
||||
questions:
|
||||
- key: on_time_at_first_pickup
|
||||
label: "Bus pünktlich am 1. Abfahrtsort?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: delay_at_first_pickup
|
||||
label: "Wieviel Verspätung hatte der Bus am 1. Abfahrtsort?"
|
||||
type: text
|
||||
required: true
|
||||
show_if: { question: on_time_at_first_pickup, equals: "no" }
|
||||
|
||||
- key: bus_rating
|
||||
title: "Bewertung Bus & Fahrer"
|
||||
questions:
|
||||
- key: overall_impression
|
||||
label: "Gesamteindruck des Busses"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: bad, label: "schlecht" }
|
||||
|
||||
- key: vehicle_age
|
||||
label: "Alter des Fahrzeugs"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: very_new, label: "Sehr neu" }
|
||||
- { key: new, label: "neu" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: somewhat_old, label: "etwas älter" }
|
||||
- { key: very_old, label: "sehr alt" }
|
||||
|
||||
- key: seating_comfort
|
||||
label: "Sitzkomfort im Bus"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: bad, label: "schlecht" }
|
||||
|
||||
- key: driver_friendliness
|
||||
label: "Freundlichkeit der Fahrer"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: very_friendly, label: "Sehr freundlich" }
|
||||
- { key: friendly, label: "freundlich" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_friendly, label: "nicht so freundlich" }
|
||||
- { key: unfriendly, label: "unfreundlich" }
|
||||
|
||||
- key: breaks
|
||||
label: "Pausenzeiten"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: unacceptable, label: "geht gar nicht" }
|
||||
|
||||
- key: temperature
|
||||
label: "Temperatur im Bus"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: unacceptable, label: "geht gar nicht" }
|
||||
|
||||
- key: guest_behaviour
|
||||
label: "Verhalten der Gäste im Bus"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: very_good, label: "Sehr gut" }
|
||||
- { key: good, label: "Gut" }
|
||||
- { key: ok, label: "OK" }
|
||||
- { key: not_so_good, label: "nicht so gut" }
|
||||
- { key: unacceptable, label: "geht gar nicht" }
|
||||
|
||||
- key: bus_pickups
|
||||
title: "Ladestellen"
|
||||
questions:
|
||||
- key: drivers_knew_pickups
|
||||
label: "Waren den Fahrern alle Abfahrtsorte bekannt?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: unknown_pickups
|
||||
label: "Welche Abfahrtsorte waren nicht bekannt? Gab es dadurch Probleme?"
|
||||
type: textarea
|
||||
required: true
|
||||
show_if: { question: drivers_knew_pickups, equals: "no" }
|
||||
|
||||
- key: bus_destination
|
||||
title: "Zielkenntnis"
|
||||
questions:
|
||||
- key: drivers_knew_destination
|
||||
label: "War den Busfahrern der Zielort und die Ausladestelle bekannt?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: "yes", label: "Ja" }
|
||||
- { key: "no", label: "Nein" }
|
||||
|
||||
- key: bus_equipment
|
||||
title: "Bus, Technik & Allgemeines"
|
||||
questions:
|
||||
- key: video
|
||||
label: "Video/DVD-Gerät"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: working, label: "funktionstüchtig" }
|
||||
- { key: broken, label: "defekt" }
|
||||
- { key: missing, label: "nicht vorhanden" }
|
||||
|
||||
- key: wc
|
||||
label: "WC"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: working, label: "funktionstüchtig" }
|
||||
- { key: broken, label: "defekt" }
|
||||
- { key: closed, label: "nicht geöffnet" }
|
||||
|
||||
- key: luggage
|
||||
label: "Gepäck"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: trailer, label: "Anhänger" }
|
||||
- { key: ski_box, label: "Skikoffer" }
|
||||
- { key: trailer_and_ski_box, label: "Anhänger und Skikoffer" }
|
||||
- { key: none, label: "Kein Anhänger oder Skikoffer" }
|
||||
|
||||
- key: smoking
|
||||
label: "Rauchen im Bus"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: driver, label: "Busfahrer hat im Bus geraucht" }
|
||||
- { key: driver_and_guests, label: "Busfahrer und Gäste haben im Bus geraucht" }
|
||||
- { key: nobody, label: "Es wurde nicht geraucht" }
|
||||
|
||||
- key: snow_chains
|
||||
label: "Schneeketten"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: fitted, label: "Fahrer hat Ketten aufgezogen" }
|
||||
- { key: not_needed, label: "Ketten wurden nicht gebraucht" }
|
||||
- { key: not_fitted, label: "Ketten nicht aufgezogen (trotz starkem Schneefall)" }
|
||||
|
||||
- key: bus_damage
|
||||
title: "Schäden"
|
||||
questions:
|
||||
- key: bus_damage
|
||||
label: "Entstanden während der Fahrt Schäden am Bus?"
|
||||
type: choice
|
||||
required: true
|
||||
options:
|
||||
- { key: none, label: "Keine Schäden" }
|
||||
- { key: settled_with_driver, label: "Bereits mit Fahrer abgeklärt" }
|
||||
- { key: claim_expected, label: "Da wird vom Busunternehmen noch was kommen" }
|
||||
|
||||
- key: damage_description
|
||||
label: "Was ist passiert?"
|
||||
type: textarea
|
||||
required: true
|
||||
show_if: { question: bus_damage, equals: claim_expected }
|
||||
|
||||
- key: bus_remarks
|
||||
title: "Anmerkungen zur Busfahrt"
|
||||
questions:
|
||||
- key: bus_remarks
|
||||
label: "Das ging gar nicht / ist besonders hervorzuheben:"
|
||||
type: textarea
|
||||
@@ -151,6 +151,8 @@ services:
|
||||
# Only the path enters the container - the file itself is parsed on demand by
|
||||
# App\Config\EmailTextCatalog, since its contents matter on few requests.
|
||||
$emailTextsFile: '%kernel.project_dir%/config/email_texts.yaml'
|
||||
# Likewise parsed on demand, by App\Config\QuestionnaireCatalog.
|
||||
$questionnaireDir: '%kernel.project_dir%/config/questionnaires'
|
||||
$mailingBusTransport: '%mailing_bus_transport%'
|
||||
$seasonStart: '%season_start%'
|
||||
$seasonEnd: '%season_end%'
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace App\Config;
|
||||
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\ConditionOperator;
|
||||
use App\Enum\QuestionType;
|
||||
use App\Model\Questionnaire\Condition;
|
||||
use App\Model\Questionnaire\Definition;
|
||||
use App\Model\Questionnaire\Option;
|
||||
use App\Model\Questionnaire\Question;
|
||||
use App\Model\Questionnaire\Section;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* Reads questionnaire definitions from config/questionnaires/.
|
||||
*
|
||||
* `<key>.yaml` is the current version of a questionnaire; `<key>.v<n>.yaml` keeps an older one that
|
||||
* responses were submitted under, so they can still be rendered with the labels they were given.
|
||||
*
|
||||
* Files are parsed lazily and memoised, like EmailTextCatalog. A definition is checked strictly
|
||||
* while it is built - unknown fields, unknown types, duplicate keys and show_if conditions that
|
||||
* point at a later question or a non-existent option all throw - because a typo in the YAML should
|
||||
* fail in QuestionnaireCatalogTest, not in front of a teamer.
|
||||
*/
|
||||
class QuestionnaireCatalog
|
||||
{
|
||||
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'];
|
||||
private const array OPTION_FIELDS = ['key', 'label'];
|
||||
|
||||
// A question key becomes a form field name and a JSON key in stored answers.
|
||||
private const string KEY_PATTERN = '/^[a-z][a-z0-9_]*$/';
|
||||
|
||||
private const int RATING_MIN = 1;
|
||||
private const int RATING_MAX = 5;
|
||||
|
||||
/**
|
||||
* @var array<string, Definition> keyed by file path
|
||||
*/
|
||||
private array $definitions = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $questionnaireDir,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $version null for the current version
|
||||
*/
|
||||
public function get(string $key, ?int $version = null): Definition
|
||||
{
|
||||
$current = $this->load($this->questionnaireDir.'/'.$key.'.yaml');
|
||||
|
||||
if (null === $version || $version === $current->version) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
return $this->load(sprintf('%s/%s.v%d.yaml', $this->questionnaireDir, $key, $version));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every definition in the directory, archived versions included.
|
||||
*
|
||||
* @return list<Definition>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$files = glob($this->questionnaireDir.'/*.yaml') ?: [];
|
||||
sort($files);
|
||||
|
||||
return array_map($this->load(...), $files);
|
||||
}
|
||||
|
||||
private function load(string $file): Definition
|
||||
{
|
||||
if (!is_file($file)) {
|
||||
throw new \InvalidArgumentException(sprintf('Questionnaire definition "%s" does not exist', $file));
|
||||
}
|
||||
|
||||
return $this->definitions[$file] ??= $this->build(Yaml::parseFile($file), basename($file));
|
||||
}
|
||||
|
||||
private function build(mixed $data, string $file): Definition
|
||||
{
|
||||
$this->assertFields($data, self::ROOT_FIELDS, ['key', 'version', 'title', 'sections'], $file);
|
||||
|
||||
if (1 !== preg_match('/^(?<key>[a-z0-9_]+)(\.v(?<version>\d+))?\.yaml$/', $file, $name)) {
|
||||
throw $this->error($file, 'file name must be <key>.yaml or <key>.v<version>.yaml');
|
||||
}
|
||||
|
||||
if ($data['key'] !== $name['key']) {
|
||||
throw $this->error($file, sprintf('key "%s" does not match the file name', $data['key']));
|
||||
}
|
||||
|
||||
if (!is_int($data['version']) || $data['version'] < 1) {
|
||||
throw $this->error($file, 'version must be a positive integer');
|
||||
}
|
||||
|
||||
if (isset($name['version']) && (int) $name['version'] !== $data['version']) {
|
||||
throw $this->error($file, sprintf('version %d does not match the file name', $data['version']));
|
||||
}
|
||||
|
||||
if (!is_array($data['sections']) || [] === $data['sections'] || !array_is_list($data['sections'])) {
|
||||
throw $this->error($file, 'sections must be a non-empty list');
|
||||
}
|
||||
|
||||
$sections = [];
|
||||
$sectionKeys = [];
|
||||
/** @var array<string, Question> $questions every question parsed so far, for show_if */
|
||||
$questions = [];
|
||||
|
||||
foreach ($data['sections'] as $index => $sectionData) {
|
||||
$context = sprintf('%s: section #%d', $file, $index + 1);
|
||||
$this->assertFields($sectionData, self::SECTION_FIELDS, ['key', 'questions'], $context);
|
||||
$key = $this->key($sectionData['key'], $context);
|
||||
|
||||
if (in_array($key, $sectionKeys, true)) {
|
||||
throw $this->error($context, sprintf('duplicate section key "%s"', $key));
|
||||
}
|
||||
|
||||
$sectionKeys[] = $key;
|
||||
$context = sprintf('%s: section "%s"', $file, $key);
|
||||
|
||||
if (!is_array($sectionData['questions']) || [] === $sectionData['questions'] || !array_is_list($sectionData['questions'])) {
|
||||
throw $this->error($context, 'questions must be a non-empty list');
|
||||
}
|
||||
|
||||
$sectionQuestions = [];
|
||||
|
||||
foreach ($sectionData['questions'] as $questionData) {
|
||||
$question = $this->buildQuestion($questionData, $questions, $context);
|
||||
$questions[$question->key] = $question;
|
||||
$sectionQuestions[] = $question;
|
||||
}
|
||||
|
||||
$sections[] = new Section($key, $this->optionalString($sectionData['title'] ?? null, 'title', $context), $sectionQuestions);
|
||||
}
|
||||
|
||||
return new Definition(
|
||||
$data['key'],
|
||||
$data['version'],
|
||||
$this->string($data['title'], 'title', $file),
|
||||
$this->optionalString($data['description'] ?? null, 'description', $file),
|
||||
$sections,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Question> $earlier
|
||||
*/
|
||||
private function buildQuestion(mixed $data, array $earlier, string $context): Question
|
||||
{
|
||||
$this->assertFields($data, self::QUESTION_FIELDS, ['key', 'label', 'type'], $context.': question');
|
||||
$key = $this->key($data['key'], $context.': question');
|
||||
$context = sprintf('%s: question "%s"', $context, $key);
|
||||
|
||||
if (isset($earlier[$key])) {
|
||||
throw $this->error($context, 'duplicate question key');
|
||||
}
|
||||
|
||||
$type = QuestionType::tryFrom((string) $data['type'])
|
||||
?? throw $this->error($context, sprintf('unknown type "%s"', $data['type']));
|
||||
|
||||
$required = $data['required'] ?? false;
|
||||
|
||||
if (!is_bool($required)) {
|
||||
throw $this->error($context, 'required must be true or false');
|
||||
}
|
||||
|
||||
$widget = null;
|
||||
|
||||
if (array_key_exists('widget', $data)) {
|
||||
if (QuestionType::CHOICE !== $type) {
|
||||
throw $this->error($context, 'widget is only allowed on a choice question');
|
||||
}
|
||||
|
||||
$widget = ChoiceWidget::tryFrom((string) $data['widget'])
|
||||
?? throw $this->error($context, sprintf('unknown widget "%s"', $data['widget']));
|
||||
} elseif (QuestionType::CHOICE === $type) {
|
||||
$widget = ChoiceWidget::RADIO;
|
||||
}
|
||||
|
||||
$options = [];
|
||||
|
||||
if ($type->hasOptions()) {
|
||||
$options = $this->buildOptions($data['options'] ?? null, $context);
|
||||
} elseif (array_key_exists('options', $data)) {
|
||||
throw $this->error($context, sprintf('options are not allowed on a %s question', $type->value));
|
||||
}
|
||||
|
||||
$showIf = array_key_exists('show_if', $data)
|
||||
? $this->buildCondition($data['show_if'], $earlier, $context)
|
||||
: null;
|
||||
|
||||
return new Question(
|
||||
$key,
|
||||
$this->string($data['label'], 'label', $context),
|
||||
$type,
|
||||
$required,
|
||||
$this->optionalString($data['help'] ?? null, 'help', $context),
|
||||
$widget,
|
||||
$options,
|
||||
$showIf,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Option>
|
||||
*/
|
||||
private function buildOptions(mixed $data, string $context): array
|
||||
{
|
||||
if (!is_array($data) || [] === $data || !array_is_list($data)) {
|
||||
throw $this->error($context, 'options must be a non-empty list');
|
||||
}
|
||||
|
||||
$options = [];
|
||||
$keys = [];
|
||||
|
||||
foreach ($data as $optionData) {
|
||||
$this->assertFields($optionData, self::OPTION_FIELDS, self::OPTION_FIELDS, $context.': option');
|
||||
// An option key is stored as the answer; YAML would turn an unquoted 1 into an int.
|
||||
$key = $this->key((string) $optionData['key'], $context.': option');
|
||||
|
||||
if (in_array($key, $keys, true)) {
|
||||
throw $this->error($context, sprintf('duplicate option key "%s"', $key));
|
||||
}
|
||||
|
||||
$keys[] = $key;
|
||||
$options[] = new Option($key, $this->string($optionData['label'], 'option label', $context));
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, Question> $earlier
|
||||
*/
|
||||
private function buildCondition(mixed $data, array $earlier, string $context): Condition
|
||||
{
|
||||
$context .= ': show_if';
|
||||
$operators = array_map(static fn (ConditionOperator $operator): string => $operator->value, ConditionOperator::cases());
|
||||
$this->assertFields($data, ['question', ...$operators], ['question'], $context);
|
||||
|
||||
$given = array_values(array_intersect($operators, array_keys($data)));
|
||||
|
||||
if (1 !== count($given)) {
|
||||
throw $this->error($context, sprintf('needs exactly one of %s', implode(', ', $operators)));
|
||||
}
|
||||
|
||||
$operator = ConditionOperator::from($given[0]);
|
||||
$value = $data[$given[0]];
|
||||
|
||||
// Only an earlier question can be answered by the time this one is reached, and it rules
|
||||
// out cycles without having to look for them.
|
||||
$controlling = $earlier[$data['question']]
|
||||
?? throw $this->error($context, sprintf('question "%s" is not an earlier question', $data['question']));
|
||||
|
||||
match ($operator) {
|
||||
ConditionOperator::EQUALS, ConditionOperator::IN => $this->assertOptionValue($controlling, $operator, $value, $context),
|
||||
ConditionOperator::LTE => $this->assertRatingValue($controlling, $value, $context),
|
||||
};
|
||||
|
||||
return new Condition($controlling->key, $operator, $value);
|
||||
}
|
||||
|
||||
private function assertOptionValue(Question $controlling, ConditionOperator $operator, mixed $value, string $context): void
|
||||
{
|
||||
if (QuestionType::CHOICE !== $controlling->type) {
|
||||
throw $this->error($context, sprintf('%s needs a choice question, "%s" is %s', $operator->value, $controlling->key, $controlling->type->value));
|
||||
}
|
||||
|
||||
$values = ConditionOperator::IN === $operator ? $value : [$value];
|
||||
|
||||
if (!is_array($values) || [] === $values || !array_is_list($values)) {
|
||||
throw $this->error($context, 'in needs a non-empty list of option keys');
|
||||
}
|
||||
|
||||
foreach ($values as $optionKey) {
|
||||
if (!is_string($optionKey) || null === $controlling->getOption($optionKey)) {
|
||||
throw $this->error($context, sprintf('"%s" is not an option of "%s"', is_string($optionKey) ? $optionKey : json_encode($optionKey), $controlling->key));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertRatingValue(Question $controlling, mixed $value, string $context): void
|
||||
{
|
||||
if (QuestionType::RATING !== $controlling->type) {
|
||||
throw $this->error($context, sprintf('lte needs a rating question, "%s" is %s', $controlling->key, $controlling->type->value));
|
||||
}
|
||||
|
||||
if (!is_int($value) || $value < self::RATING_MIN || $value > self::RATING_MAX) {
|
||||
throw $this->error($context, sprintf('lte needs a rating from %d to %d', self::RATING_MIN, self::RATING_MAX));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $allowed
|
||||
* @param list<string> $required
|
||||
*/
|
||||
private function assertFields(mixed $data, array $allowed, array $required, string $context): void
|
||||
{
|
||||
if (!is_array($data) || array_is_list($data)) {
|
||||
throw $this->error($context, 'must be a mapping');
|
||||
}
|
||||
|
||||
if ([] !== $unknown = array_diff(array_keys($data), $allowed)) {
|
||||
throw $this->error($context, sprintf('unknown field(s) %s', implode(', ', $unknown)));
|
||||
}
|
||||
|
||||
if ([] !== $missing = array_diff($required, array_keys($data))) {
|
||||
throw $this->error($context, sprintf('missing field(s) %s', implode(', ', $missing)));
|
||||
}
|
||||
}
|
||||
|
||||
private function key(mixed $value, string $context): string
|
||||
{
|
||||
if (!is_string($value) || 1 !== preg_match(self::KEY_PATTERN, $value)) {
|
||||
throw $this->error($context, sprintf('key %s must be lower snake_case', json_encode($value)));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function string(mixed $value, string $field, string $context): string
|
||||
{
|
||||
if (!is_string($value) || '' === trim($value)) {
|
||||
throw $this->error($context, sprintf('%s must be a non-empty string', $field));
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function optionalString(mixed $value, string $field, string $context): ?string
|
||||
{
|
||||
return null === $value ? null : $this->string($value, $field, $context);
|
||||
}
|
||||
|
||||
private function error(string $context, string $message): \UnexpectedValueException
|
||||
{
|
||||
return new \UnexpectedValueException(sprintf('%s: %s', $context, $message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* How a single-choice question is rendered: a radio list or a select.
|
||||
*/
|
||||
enum ChoiceWidget: string
|
||||
{
|
||||
case RADIO = 'radio';
|
||||
case DROPDOWN = 'dropdown';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* How a show_if condition compares the controlling question's answer with its value.
|
||||
*/
|
||||
enum ConditionOperator: string
|
||||
{
|
||||
case EQUALS = 'equals';
|
||||
case IN = 'in';
|
||||
case LTE = 'lte';
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
/**
|
||||
* The kinds of question a questionnaire definition may use. Each maps to exactly one form field
|
||||
* in QuestionnaireType; adding one means a case here and a line there, nothing more.
|
||||
*/
|
||||
enum QuestionType: string
|
||||
{
|
||||
case TEXT = 'text';
|
||||
case TEXTAREA = 'textarea';
|
||||
case CHOICE = 'choice';
|
||||
case MULTI = 'multi';
|
||||
case DATE = 'date';
|
||||
case RATING = 'rating';
|
||||
|
||||
public function hasOptions(): bool
|
||||
{
|
||||
return self::CHOICE === $this || self::MULTI === $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
use App\Enum\ConditionOperator;
|
||||
|
||||
/**
|
||||
* A question's show_if: it is shown, and can be required, only while this holds.
|
||||
*/
|
||||
readonly class Condition
|
||||
{
|
||||
/**
|
||||
* @param string|list<string>|int $value an option key for EQUALS, a list of them for IN,
|
||||
* a rating for LTE
|
||||
*/
|
||||
public function __construct(
|
||||
public string $question,
|
||||
public ConditionOperator $operator,
|
||||
public string|array|int $value,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
/**
|
||||
* One version of a questionnaire as read from config/questionnaires/. Responses store its key and
|
||||
* version, and their answers are keyed by Question::$key.
|
||||
*/
|
||||
readonly class Definition
|
||||
{
|
||||
/**
|
||||
* @param list<Section> $sections
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public int $version,
|
||||
public string $title,
|
||||
public ?string $description,
|
||||
public array $sections,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Question> in the order they are asked, keyed by question key
|
||||
*/
|
||||
public function getQuestions(): array
|
||||
{
|
||||
$questions = [];
|
||||
|
||||
foreach ($this->sections as $section) {
|
||||
foreach ($section->questions as $question) {
|
||||
$questions[$question->key] = $question;
|
||||
}
|
||||
}
|
||||
|
||||
return $questions;
|
||||
}
|
||||
|
||||
public function getQuestion(string $key): ?Question
|
||||
{
|
||||
return $this->getQuestions()[$key] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
readonly class Option
|
||||
{
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $label,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\QuestionType;
|
||||
|
||||
readonly class Question
|
||||
{
|
||||
/**
|
||||
* @param list<Option> $options only for CHOICE and MULTI
|
||||
* @param ChoiceWidget|null $widget only for CHOICE
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public string $label,
|
||||
public QuestionType $type,
|
||||
public bool $required = false,
|
||||
public ?string $help = null,
|
||||
public ?ChoiceWidget $widget = null,
|
||||
public array $options = [],
|
||||
public ?Condition $showIf = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getOption(string $key): ?Option
|
||||
{
|
||||
foreach ($this->options as $option) {
|
||||
if ($key === $option->key) {
|
||||
return $option;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Model\Questionnaire;
|
||||
|
||||
readonly class Section
|
||||
{
|
||||
/**
|
||||
* @param list<Question> $questions
|
||||
*/
|
||||
public function __construct(
|
||||
public string $key,
|
||||
public ?string $title,
|
||||
public array $questions,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Config;
|
||||
|
||||
use App\Config\QuestionnaireCatalog;
|
||||
use App\Enum\ChoiceWidget;
|
||||
use App\Enum\ConditionOperator;
|
||||
use App\Enum\QuestionType;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
/**
|
||||
* The definitions are edited by hand, and a typo in one would otherwise surface as a broken form
|
||||
* in front of a teamer. Building every shipped definition here is what proves the files are
|
||||
* valid; the rejection cases pin that the catalog does not quietly accept a malformed one.
|
||||
*/
|
||||
class QuestionnaireCatalogTest extends TestCase
|
||||
{
|
||||
private const string SHIPPED_DIR = __DIR__.'/../../config/questionnaires';
|
||||
|
||||
private string $dir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->dir = sys_get_temp_dir().'/questionnaire_catalog_'.bin2hex(random_bytes(4));
|
||||
mkdir($this->dir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
array_map(unlink(...), glob($this->dir.'/*') ?: []);
|
||||
rmdir($this->dir);
|
||||
}
|
||||
|
||||
public function testEveryShippedDefinitionBuilds(): void
|
||||
{
|
||||
$definitions = new QuestionnaireCatalog(self::SHIPPED_DIR)->all();
|
||||
|
||||
$this->assertNotEmpty($definitions);
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
$this->assertNotEmpty($definition->getQuestions(), $definition->key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the disposition already knows was decided not to be asked; it is snapshotted into the
|
||||
* response instead. These are the Google forms' keys for those questions.
|
||||
*/
|
||||
public function testTeamerEscortDoesNotAskWhatTheDispositionKnows(): void
|
||||
{
|
||||
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort');
|
||||
|
||||
foreach (['name', 'job_profile', 'house', 'assignment_start', 'travel_date', 'destination', 'guide_name', 'first_pickup_location'] as $key) {
|
||||
$this->assertNull($definition->getQuestion($key), $key);
|
||||
}
|
||||
}
|
||||
|
||||
public function testTeamerEscortLowRatingReasonIsRequiredOnlyForLowRatings(): void
|
||||
{
|
||||
$question = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort')->getQuestion('office_briefing_low_reason');
|
||||
|
||||
$this->assertTrue($question->required);
|
||||
$this->assertSame('office_briefing_rating', $question->showIf->question);
|
||||
$this->assertSame(ConditionOperator::LTE, $question->showIf->operator);
|
||||
$this->assertSame(3, $question->showIf->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google's single "Bitte ankreuzen:" checkbox list allowed "WC defekt" and "WC funktionstüchtig"
|
||||
* together; it was split into one single choice per topic.
|
||||
*/
|
||||
public function testTeamerEscortEquipmentIsOneSingleChoicePerTopic(): void
|
||||
{
|
||||
$definition = new QuestionnaireCatalog(self::SHIPPED_DIR)->get('teamer_escort');
|
||||
|
||||
foreach (['video', 'wc', 'luggage', 'smoking', 'snow_chains'] as $key) {
|
||||
$question = $definition->getQuestion($key);
|
||||
$this->assertSame(QuestionType::CHOICE, $question->type, $key);
|
||||
$this->assertTrue($question->required, $key);
|
||||
}
|
||||
}
|
||||
|
||||
public function testChoiceDefaultsToRadio(): void
|
||||
{
|
||||
$this->write('sample', $this->sample());
|
||||
|
||||
$question = new QuestionnaireCatalog($this->dir)->get('sample')->getQuestion('answer');
|
||||
|
||||
$this->assertSame(ChoiceWidget::RADIO, $question->widget);
|
||||
$this->assertFalse($question->required);
|
||||
}
|
||||
|
||||
public function testOlderVersionIsReadFromItsArchivedFile(): void
|
||||
{
|
||||
$this->write('sample', ['version' => 2] + $this->sample());
|
||||
$this->write('sample.v1', $this->sample(label: 'Alte Frage'));
|
||||
|
||||
$catalog = new QuestionnaireCatalog($this->dir);
|
||||
|
||||
$this->assertSame(2, $catalog->get('sample')->version);
|
||||
$this->assertSame(2, $catalog->get('sample', 2)->version);
|
||||
$this->assertSame('Alte Frage', $catalog->get('sample', 1)->getQuestion('answer')->label);
|
||||
}
|
||||
|
||||
public function testUnknownQuestionnaireIsRejected(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
|
||||
new QuestionnaireCatalog($this->dir)->get('missing');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Closure(array): array $mutate
|
||||
*/
|
||||
#[DataProvider('provideMalformedDefinitions')]
|
||||
public function testMalformedDefinitionIsRejected(\Closure $mutate, string $message): void
|
||||
{
|
||||
$this->write('sample', $mutate($this->sample()));
|
||||
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessage($message);
|
||||
|
||||
new QuestionnaireCatalog($this->dir)->get('sample');
|
||||
}
|
||||
|
||||
public static function provideMalformedDefinitions(): iterable
|
||||
{
|
||||
yield 'misspelt field' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['requried' => true]),
|
||||
'unknown field(s) requried',
|
||||
];
|
||||
yield 'unknown type' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['type' => 'slider']),
|
||||
'unknown type "slider"',
|
||||
];
|
||||
yield 'key not snake_case' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['key' => 'Reason']),
|
||||
'must be lower snake_case',
|
||||
];
|
||||
yield 'duplicate question key' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['key' => 'answer', 'show_if' => null] + $d['sections'][0]['questions'][1]),
|
||||
'duplicate question key',
|
||||
];
|
||||
yield 'options on a text question' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['options' => [['key' => 'a', 'label' => 'A']]]),
|
||||
'options are not allowed on a textarea question',
|
||||
];
|
||||
yield 'choice without options' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 0, ['options' => []]),
|
||||
'options must be a non-empty list',
|
||||
];
|
||||
yield 'duplicate option key' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 0, ['options' => [['key' => 'yes', 'label' => 'Ja'], ['key' => 'yes', 'label' => 'Jawohl']]]),
|
||||
'duplicate option key "yes"',
|
||||
];
|
||||
yield 'widget on a multi question' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 0, ['type' => 'multi', 'widget' => 'dropdown']),
|
||||
'widget is only allowed on a choice question',
|
||||
];
|
||||
yield 'show_if on a later question' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'later', 'equals' => 'yes']]),
|
||||
'"later" is not an earlier question',
|
||||
];
|
||||
yield 'show_if on a non-existent option' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'answer', 'equals' => 'maybe']]),
|
||||
'"maybe" is not an option of "answer"',
|
||||
];
|
||||
yield 'show_if with two operators' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'answer', 'equals' => 'yes', 'in' => ['no']]]),
|
||||
'needs exactly one of',
|
||||
];
|
||||
yield 'lte on a choice question' => [
|
||||
static fn (array $d): array => self::withQuestion($d, 1, ['show_if' => ['question' => 'answer', 'lte' => 3]]),
|
||||
'lte needs a rating question',
|
||||
];
|
||||
yield 'lte outside the rating scale' => [
|
||||
static fn (array $d): array => self::withQuestion(self::withQuestion($d, 0, ['type' => 'rating', 'options' => null]), 1, ['show_if' => ['question' => 'answer', 'lte' => 6]]),
|
||||
'lte needs a rating from 1 to 5',
|
||||
];
|
||||
yield 'key does not match the file name' => [
|
||||
static fn (array $d): array => ['key' => 'other'] + $d,
|
||||
'does not match the file name',
|
||||
];
|
||||
}
|
||||
|
||||
public function testArchivedFileWithTheWrongVersionIsRejected(): void
|
||||
{
|
||||
$this->write('sample', ['version' => 3] + $this->sample());
|
||||
$this->write('sample.v1', ['version' => 2] + $this->sample());
|
||||
|
||||
$this->expectException(\UnexpectedValueException::class);
|
||||
$this->expectExceptionMessage('version 2 does not match the file name');
|
||||
|
||||
new QuestionnaireCatalog($this->dir)->get('sample', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* A choice "answer" and a textarea "reason" shown when the answer is "yes".
|
||||
*/
|
||||
private function sample(string $label = 'Frage'): array
|
||||
{
|
||||
return [
|
||||
'key' => 'sample',
|
||||
'version' => 1,
|
||||
'title' => 'Beispiel',
|
||||
'sections' => [[
|
||||
'key' => 'main',
|
||||
'questions' => [
|
||||
[
|
||||
'key' => 'answer',
|
||||
'label' => $label,
|
||||
'type' => 'choice',
|
||||
'options' => [['key' => 'yes', 'label' => 'Ja'], ['key' => 'no', 'label' => 'Nein']],
|
||||
],
|
||||
[
|
||||
'key' => 'reason',
|
||||
'label' => 'Warum?',
|
||||
'type' => 'textarea',
|
||||
'show_if' => ['question' => 'answer', 'equals' => 'yes'],
|
||||
],
|
||||
],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges $fields into the sample's question at $index; a null value removes the field.
|
||||
*/
|
||||
private static function withQuestion(array $definition, int $index, array $fields): array
|
||||
{
|
||||
$question = array_merge($definition['sections'][0]['questions'][$index], $fields);
|
||||
$definition['sections'][0]['questions'][$index] = array_filter($question, static fn (mixed $value): bool => null !== $value);
|
||||
|
||||
return $definition;
|
||||
}
|
||||
|
||||
private function write(string $name, array $definition): void
|
||||
{
|
||||
file_put_contents(sprintf('%s/%s.yaml', $this->dir, $name), Yaml::dump($definition, 10));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user