.yaml` is the current version of a questionnaire; `.v.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 { /** * 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']; 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 keyed by file path */ private array $definitions = []; public function __construct( private readonly string $questionnaireDir, ) { } /** * The services.yaml parameter questionnaire_go_live as a date: the teamer questionnaire is * offered for assignments ending on or after it, and to nobody while it is null. */ public static function parseGoLive(?string $goLive): ?\DateTimeImmutable { if (null === $goLive) { return null; } return \DateTimeImmutable::createFromFormat('!Y-m-d', $goLive) ?: throw new \InvalidArgumentException(sprintf('questionnaire_go_live "%s" is not a Y-m-d date', $goLive)); } /** * @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 */ 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('/^(?[a-z0-9_]+)(\.v(?\d+))?\.yaml$/', $file, $name)) { throw $this->error($file, 'file name must be .yaml or .v.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 $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 $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