Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
86.39% |
273 / 316 |
|
50.00% |
10 / 20 |
CRAP | |
0.00% |
0 / 1 |
EventQuestionsRegistry | |
86.39% |
273 / 316 |
|
50.00% |
10 / 20 |
123.70 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getQuestions | |
99.17% |
119 / 120 |
|
0.00% |
0 / 1 |
3 | |||
getQuestionsForTesting | n/a |
0 / 0 |
n/a |
0 / 0 |
2 | |||||
overrideQuestionsForTesting | n/a |
0 / 0 |
n/a |
0 / 0 |
2 | |||||
getQuestionsForHTMLForm | |
62.96% |
17 / 27 |
|
0.00% |
0 / 1 |
15.08 | |||
extractUserAnswersHTMLForm | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
newAnswerFromHTMLForm | |
82.76% |
24 / 29 |
|
0.00% |
0 / 1 |
12.74 | |||
isPlaceholderValue | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
10.50 | |||
getQuestionsForAPI | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
7 | |||
formatAnswersForAPI | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
6.01 | |||
extractUserAnswersAPI | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
newAnswerFromAPI | |
79.17% |
19 / 24 |
|
0.00% |
0 / 1 |
12.09 | |||
getQuestionLabelsForOrganizerForm | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
getQuestionLabelForStats | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
getQuestionOptionsForStats | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
getQuestionOptionMessageByID | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
getAvailableQuestionNames | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
nameToDBID | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
dbIDToName | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
getNonPIIQuestionLabels | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
getNonPIIQuestionIDs | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
getParticipantQuestionsToShow | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | declare( strict_types=1 ); |
4 | |
5 | namespace MediaWiki\Extension\CampaignEvents\Questions; |
6 | |
7 | use BadMethodCallException; |
8 | use LogicException; |
9 | use UnexpectedValueException; |
10 | |
11 | class EventQuestionsRegistry { |
12 | public const SERVICE_NAME = 'CampaignEventsEventQuestionsRegistry'; |
13 | |
14 | public const RADIO_BUTTON_QUESTION_TYPE = 'radio'; |
15 | public const SELECT_QUESTION_TYPE = 'select'; |
16 | public const FREE_TEXT_QUESTION_TYPE = 'text'; |
17 | |
18 | public const MULTIPLE_CHOICE_TYPES = [ |
19 | self::RADIO_BUTTON_QUESTION_TYPE, |
20 | self::SELECT_QUESTION_TYPE |
21 | ]; |
22 | |
23 | /** |
24 | * @var bool Determines whether Wikimedia-specific questions should be shown. In the future, this might be |
25 | * replaced with a hook for adding custom questions. |
26 | */ |
27 | private bool $wikimediaQuestionsEnabled; |
28 | |
29 | /** @var array|null Question overrides used in test. Null means no override. */ |
30 | private ?array $testOverrides = null; |
31 | |
32 | /** |
33 | * @param bool $wikimediaQuestionsEnabled |
34 | */ |
35 | public function __construct( bool $wikimediaQuestionsEnabled ) { |
36 | $this->wikimediaQuestionsEnabled = $wikimediaQuestionsEnabled; |
37 | } |
38 | |
39 | /** |
40 | * Returns the internal registry with all the question. Each entry in the registry must have the following |
41 | * properties: |
42 | * - name (string): Readable identifier of the question, can be used in forms and APIs (potentially prefixed) |
43 | * - db-id (integer): Identifier of the question in the database |
44 | * - wikimedia (bool): Whether the question is Wikimedia-specific |
45 | * - pii (bool): Whether the question is considered PII |
46 | * - stats-label-message (string): Key of a message that should be used when introducing the response statistics |
47 | * for this question. |
48 | * - non-pii-label-message (string): Key of a message that should be used when introducing the response of |
49 | * non-pii responses |
50 | * - questionData (array): User-facing properties of the question, with the following keys: |
51 | * - type (string, required): Type of the question, must be one of the self::*_QUESTION_TYPE constants |
52 | * - label-message (string, required): i18n key for the question label |
53 | * - options-messages (array, optional): For multiple-choice questions, the list of possible answers. |
54 | * NOTE: For multipe-choice questions, the option 0 must be a placeholder value to let the |
55 | * user skip the question. |
56 | * - otherOptions (array): List of fields to show conditionally if the parent field has a certain value. Currently, |
57 | * this has the following requirements: |
58 | * - the parent question must be a multiple-choice question |
59 | * - the key of each 'other' element must correspond to the value that the parent question should have in order |
60 | * for the 'other' field to be shown |
61 | * - the 'other' field must be a free-text field |
62 | * Each 'other' option has the following keys: |
63 | * - type (string, required): Must be set to self::FREE_TEXT_QUESTION_TYPE, and is only required for robustness |
64 | * - placeholder-message (string, required): Placeholder for the text field, required in place of the label |
65 | * @return array[] |
66 | */ |
67 | private function getQuestions(): array { |
68 | if ( $this->testOverrides !== null ) { |
69 | return $this->testOverrides; |
70 | } |
71 | |
72 | $questions = [ |
73 | [ |
74 | 'name' => 'gender', |
75 | 'db-id' => 1, |
76 | 'wikimedia' => false, |
77 | 'pii' => true, |
78 | 'stats-label-message' => 'campaignevents-register-question-gender-stats-label', |
79 | 'organizer-label-message' => 'campaignevents-register-question-gender-organizer-label', |
80 | 'questionData' => [ |
81 | 'type' => self::RADIO_BUTTON_QUESTION_TYPE, |
82 | 'label-message' => 'campaignevents-register-question-gender', |
83 | 'options-messages' => [ |
84 | 'campaignevents-register-question-gender-option-not-say' => 0, |
85 | 'campaignevents-register-question-gender-option-man' => 1, |
86 | 'campaignevents-register-question-gender-option-woman' => 2, |
87 | 'campaignevents-register-question-gender-option-agender' => 3, |
88 | 'campaignevents-register-question-gender-option-nonbinary' => 4, |
89 | 'campaignevents-register-question-gender-option-other' => 5, |
90 | ], |
91 | ], |
92 | ], |
93 | [ |
94 | 'name' => 'age', |
95 | 'db-id' => 2, |
96 | 'wikimedia' => false, |
97 | 'pii' => true, |
98 | 'stats-label-message' => 'campaignevents-register-question-age-stats-label', |
99 | 'organizer-label-message' => 'campaignevents-register-question-age-organizer-label', |
100 | 'questionData' => [ |
101 | 'type' => self::SELECT_QUESTION_TYPE, |
102 | 'label-message' => 'campaignevents-register-question-age', |
103 | 'options-messages' => [ |
104 | 'campaignevents-register-question-age-placeholder' => 0, |
105 | 'campaignevents-register-question-age-option-under-25' => 1, |
106 | 'campaignevents-register-question-age-option-25-34' => 2, |
107 | 'campaignevents-register-question-age-option-35-44' => 3, |
108 | 'campaignevents-register-question-age-option-45-54' => 4, |
109 | 'campaignevents-register-question-age-option-55-64' => 5, |
110 | 'campaignevents-register-question-age-option-65-74' => 6, |
111 | 'campaignevents-register-question-age-option-75-84' => 7, |
112 | 'campaignevents-register-question-age-option-85-plus' => 8, |
113 | ], |
114 | ], |
115 | ], |
116 | [ |
117 | 'name' => 'profession', |
118 | 'db-id' => 3, |
119 | 'wikimedia' => false, |
120 | 'pii' => true, |
121 | 'stats-label-message' => 'campaignevents-register-question-profession-stats-label', |
122 | 'organizer-label-message' => 'campaignevents-register-question-profession-organizer-label', |
123 | 'questionData' => [ |
124 | 'type' => self::SELECT_QUESTION_TYPE, |
125 | 'label-message' => 'campaignevents-register-question-profession', |
126 | 'options-messages' => [ |
127 | 'campaignevents-register-question-profession-placeholder' => 0, |
128 | 'campaignevents-register-question-profession-option-artist-creative' => 1, |
129 | 'campaignevents-register-question-profession-option-educator' => 2, |
130 | 'campaignevents-register-question-profession-option-librarian' => 3, |
131 | 'campaignevents-register-question-profession-option-mass-media' => 4, |
132 | 'campaignevents-register-question-profession-option-museum-archive' => 5, |
133 | 'campaignevents-register-question-profession-option-nonprofit' => 6, |
134 | 'campaignevents-register-question-profession-option-researcher' => 7, |
135 | 'campaignevents-register-question-profession-option-software-engineer' => 8, |
136 | 'campaignevents-register-question-profession-option-student' => 9, |
137 | 'campaignevents-register-question-profession-option-other' => 10, |
138 | ], |
139 | ], |
140 | ], |
141 | [ |
142 | 'name' => 'confidence', |
143 | 'db-id' => 4, |
144 | 'wikimedia' => true, |
145 | 'pii' => false, |
146 | 'stats-label-message' => 'campaignevents-register-question-confidence-stats-label', |
147 | 'non-pii-label-message' => 'campaignevents-individual-stats-label-message-confidence', |
148 | 'organizer-label-message' => 'campaignevents-register-question-confidence-organizer-label', |
149 | 'questionData' => [ |
150 | 'type' => self::RADIO_BUTTON_QUESTION_TYPE, |
151 | 'label-message' => 'campaignevents-register-question-confidence-contributing', |
152 | 'options-messages' => [ |
153 | 'campaignevents-register-question-confidence-contributing-not-say' => 0, |
154 | 'campaignevents-register-question-confidence-contributing-option-none' => 1, |
155 | 'campaignevents-register-question-confidence-contributing-option-some-not-confident' => 2, |
156 | 'campaignevents-register-question-confidence-contributing-option-some-confident' => 3, |
157 | 'campaignevents-register-question-confidence-contributing-option-confident' => 4, |
158 | ], |
159 | ], |
160 | ], |
161 | [ |
162 | 'name' => 'affiliate', |
163 | 'db-id' => 5, |
164 | 'wikimedia' => true, |
165 | 'pii' => false, |
166 | 'stats-label-message' => 'campaignevents-register-question-affiliate-stats-label', |
167 | 'non-pii-label-message' => 'campaignevents-individual-stats-label-message-affiliate', |
168 | 'organizer-label-message' => 'campaignevents-register-question-affiliate-organizer-label', |
169 | 'questionData' => [ |
170 | 'type' => self::SELECT_QUESTION_TYPE, |
171 | 'label-message' => 'campaignevents-register-question-affiliate', |
172 | 'options-messages' => [ |
173 | 'campaignevents-register-question-affiliate-placeholder' => 0, |
174 | 'campaignevents-register-question-affiliate-option-affiliate' => 1, |
175 | 'campaignevents-register-question-affiliate-option-none' => 2, |
176 | ], |
177 | ], |
178 | 'otherOptions' => [ |
179 | 1 => [ |
180 | 'type' => self::FREE_TEXT_QUESTION_TYPE, |
181 | 'placeholder-message' => 'campaignevents-register-question-affiliate-details-placeholder', |
182 | ], |
183 | ], |
184 | ], |
185 | ]; |
186 | |
187 | return array_filter( |
188 | $questions, |
189 | fn ( array $question ): bool => !$question['wikimedia'] || $this->wikimediaQuestionsEnabled |
190 | ); |
191 | } |
192 | |
193 | /** |
194 | * @codeCoverageIgnore |
195 | * @return array[] |
196 | */ |
197 | public function getQuestionsForTesting(): array { |
198 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
199 | throw new BadMethodCallException( 'This method can only be used in tests' ); |
200 | } |
201 | return $this->getQuestions(); |
202 | } |
203 | |
204 | /** |
205 | * @codeCoverageIgnore |
206 | * @param array[] $questions |
207 | */ |
208 | public function overrideQuestionsForTesting( array $questions ): void { |
209 | if ( !defined( 'MW_PHPUNIT_TEST' ) ) { |
210 | throw new BadMethodCallException( 'This method can only be used in tests' ); |
211 | } |
212 | $this->testOverrides = $questions; |
213 | } |
214 | |
215 | /** |
216 | * Returns the questions corresponding to the given question database IDs, in the format accepted by HTMLForm. |
217 | * Each "child" question is given the CSS class `ext-campaignevents-participant-question-other-option`, which can |
218 | * be used to style the child field. |
219 | * |
220 | * @note This method ignores any IDs not corresponding to known questions. |
221 | * |
222 | * @param int[] $questionIDs |
223 | * @param Answer[] $curAnswers Current user answers, used for the default values |
224 | * @return array[] |
225 | */ |
226 | public function getQuestionsForHTMLForm( array $questionIDs, array $curAnswers ): array { |
227 | $curAnswersByID = []; |
228 | foreach ( $curAnswers as $answer ) { |
229 | $curAnswersByID[$answer->getQuestionDBID()] = $answer; |
230 | } |
231 | $fields = []; |
232 | foreach ( $this->getQuestions() as $question ) { |
233 | $questionID = $question['db-id']; |
234 | if ( !in_array( $questionID, $questionIDs, true ) ) { |
235 | continue; |
236 | } |
237 | $curAnswer = $curAnswersByID[$questionID] ?? null; |
238 | $fieldName = 'Question' . ucfirst( $question['name'] ); |
239 | $fields[$fieldName] = $question[ 'questionData' ]; |
240 | if ( $curAnswer ) { |
241 | $type = $question['questionData']['type']; |
242 | if ( $type === self::FREE_TEXT_QUESTION_TYPE ) { |
243 | $default = $curAnswer->getText(); |
244 | } elseif ( in_array( $type, self::MULTIPLE_CHOICE_TYPES, true ) ) { |
245 | $default = $curAnswer->getOption(); |
246 | } else { |
247 | throw new UnexpectedValueException( "Unhandled question type $type" ); |
248 | } |
249 | $fields[$fieldName]['default'] = $default; |
250 | } |
251 | foreach ( $question[ 'otherOptions' ] ?? [] as $showIfVal => $optionData ) { |
252 | $optionName = $fieldName . '_Other' . '_' . $showIfVal; |
253 | $optionData['hide-if'] = [ '!==', $fieldName, (string)$showIfVal ]; |
254 | $optionData['cssclass'] = 'ext-campaignevents-participant-question-other-option'; |
255 | $fields[$optionName] = $optionData; |
256 | if ( $curAnswer && $curAnswer->getOption() === $showIfVal ) { |
257 | $fields[$optionName]['default'] = $curAnswer->getText(); |
258 | } |
259 | } |
260 | } |
261 | return $fields; |
262 | } |
263 | |
264 | /** |
265 | * Parses an array of form field values from an HTMLForm that was built using getQuestionsForHTMLForm(), |
266 | * and returns an array of answers to store. |
267 | * |
268 | * @param array $formData As given by HTMLForm |
269 | * @param int[] $enabledQuestionIDs Enabled question for the event, should match the value passed to |
270 | * getQuestionsForHTMLForm(). |
271 | * @return Answer[] |
272 | * @throws InvalidAnswerDataException |
273 | */ |
274 | public function extractUserAnswersHTMLForm( array $formData, array $enabledQuestionIDs ): array { |
275 | $answers = []; |
276 | foreach ( $this->getQuestions() as $question ) { |
277 | if ( !in_array( $question['db-id'], $enabledQuestionIDs, true ) ) { |
278 | continue; |
279 | } |
280 | $answer = $this->newAnswerFromHTMLForm( $question, $formData ); |
281 | if ( $answer ) { |
282 | $answers[] = $answer; |
283 | } |
284 | } |
285 | return $answers; |
286 | } |
287 | |
288 | /** |
289 | * @param array $questionSpec Must be an entry in the registry |
290 | * @param array $formData |
291 | * @return Answer|null |
292 | * @throws InvalidAnswerDataException |
293 | */ |
294 | private function newAnswerFromHTMLForm( array $questionSpec, array $formData ): ?Answer { |
295 | $fieldName = 'Question' . ucfirst( $questionSpec['name'] ); |
296 | if ( !isset( $formData[$fieldName] ) ) { |
297 | return null; |
298 | } |
299 | $type = $questionSpec['questionData']['type']; |
300 | $ansValue = $formData[$fieldName]; |
301 | if ( $this->isPlaceholderValue( $type, $ansValue ) ) { |
302 | return null; |
303 | } |
304 | if ( $type === self::FREE_TEXT_QUESTION_TYPE ) { |
305 | if ( !is_string( $ansValue ) ) { |
306 | throw new InvalidAnswerDataException( $fieldName ); |
307 | } |
308 | $ansOption = null; |
309 | $ansText = $ansValue; |
310 | } elseif ( in_array( $type, self::MULTIPLE_CHOICE_TYPES, true ) ) { |
311 | // Note, HTMLForm always uses strings for field values |
312 | if ( !is_numeric( $ansValue ) ) { |
313 | throw new InvalidAnswerDataException( $fieldName ); |
314 | } |
315 | $ansValue = (int)$ansValue; |
316 | if ( !in_array( $ansValue, $questionSpec['questionData']['options-messages'], true ) ) { |
317 | throw new InvalidAnswerDataException( $fieldName ); |
318 | } |
319 | $ansOption = $ansValue; |
320 | $ansText = null; |
321 | $otherOptionName = $fieldName . '_Other' . '_' . $ansValue; |
322 | if ( isset( $questionSpec['otherOptions'][$ansValue] ) && isset( $formData[$otherOptionName] ) ) { |
323 | $answerOther = $formData[$otherOptionName]; |
324 | if ( $answerOther !== '' ) { |
325 | if ( !is_string( $answerOther ) ) { |
326 | throw new InvalidAnswerDataException( $otherOptionName ); |
327 | } |
328 | $ansText = $answerOther; |
329 | } |
330 | } |
331 | } else { |
332 | throw new UnexpectedValueException( "Unhandled question type $type" ); |
333 | } |
334 | |
335 | return new Answer( $questionSpec['db-id'], $ansOption, $ansText ); |
336 | } |
337 | |
338 | /** |
339 | * @param string $questionType |
340 | * @param mixed $value |
341 | * @return bool |
342 | */ |
343 | private function isPlaceholderValue( string $questionType, $value ): bool { |
344 | switch ( $questionType ) { |
345 | case self::RADIO_BUTTON_QUESTION_TYPE: |
346 | case self::SELECT_QUESTION_TYPE: |
347 | return $value === 0 || $value === '0'; |
348 | case self::FREE_TEXT_QUESTION_TYPE: |
349 | return $value === ''; |
350 | default: |
351 | throw new LogicException( 'Unhandled question type' ); |
352 | } |
353 | } |
354 | |
355 | /** |
356 | * Returns the questions corresponding to the given question database IDs, in a format suitable for the API. |
357 | * |
358 | * @note This method ignores any IDs not corresponding to known questions. |
359 | * |
360 | * @param int[]|null $questionIDs Only include questions with these IDs, or null to include all questions |
361 | * @return array[] The keys are question IDs, and the values are arrays with the following keys: `name`, `type`, |
362 | * `label-message`. `options-messages` is also present if the question is multiple choice. `other-option` is |
363 | * also set if the question has additional options. This is an array where the keys are possible values for |
364 | * the parent question, and the values are arrays with `type` and `label-message`. |
365 | */ |
366 | public function getQuestionsForAPI( array $questionIDs = null ): array { |
367 | $ret = []; |
368 | foreach ( $this->getQuestions() as $question ) { |
369 | $questionID = $question['db-id']; |
370 | if ( $questionIDs !== null && !in_array( $questionID, $questionIDs, true ) ) { |
371 | continue; |
372 | } |
373 | $questionData = $question['questionData']; |
374 | $ret[$questionID] = [ |
375 | 'name' => $question['name'], |
376 | 'type' => $questionData['type'], |
377 | 'label-message' => $questionData['label-message'], |
378 | ]; |
379 | if ( isset( $questionData['options-messages'] ) ) { |
380 | $ret[$questionID]['options-messages'] = $questionData['options-messages']; |
381 | } |
382 | $otherOptions = []; |
383 | foreach ( $question[ 'otherOptions' ] ?? [] as $showIfVal => $optionData ) { |
384 | $otherOptions[$showIfVal] = [ |
385 | 'type' => $optionData['type'], |
386 | 'label-message' => $optionData['placeholder-message'], |
387 | ]; |
388 | } |
389 | if ( $otherOptions ) { |
390 | $ret[$questionID]['other-options'] = $otherOptions; |
391 | } |
392 | } |
393 | return $ret; |
394 | } |
395 | |
396 | /** |
397 | * Formats a list of answers for use in API (GET) requests. This is the same format expected by |
398 | * extractUserAnswersAPI(). |
399 | * |
400 | * @param Answer[] $answers |
401 | * @param int[] $enabledQuestions IDs of the questions currently enabled for the event |
402 | * @return array<string,array> Maps question names to arrays containing the following properties: |
403 | * - value: ?int, identifier of the chosen option for multiple choice questions |
404 | * - other: string (optional), free text of the answer |
405 | * - removed: true (optional), only set if the question has since been removed from the event |
406 | */ |
407 | public function formatAnswersForAPI( array $answers, array $enabledQuestions ): array { |
408 | $answersByID = []; |
409 | foreach ( $answers as $answer ) { |
410 | $answersByID[$answer->getQuestionDBID()] = $answer; |
411 | } |
412 | $ret = []; |
413 | foreach ( $this->getQuestions() as $question ) { |
414 | $questionID = $question['db-id']; |
415 | if ( !isset( $answersByID[$questionID] ) ) { |
416 | continue; |
417 | } |
418 | $answer = $answersByID[$questionID]; |
419 | $option = $answer->getOption(); |
420 | $formattedAnswer = [ |
421 | 'value' => $option, |
422 | ]; |
423 | if ( $answer->getText() !== null ) { |
424 | $formattedAnswer['other'] = $answer->getText(); |
425 | } |
426 | if ( !in_array( $questionID, $enabledQuestions, true ) ) { |
427 | $formattedAnswer['removed'] = true; |
428 | } |
429 | $ret[$question['name']] = $formattedAnswer; |
430 | } |
431 | return $ret; |
432 | } |
433 | |
434 | /** |
435 | * Extracts user answers given in the API format returned by formatAnswersForAPI(). |
436 | * |
437 | * @param array[] $data |
438 | * @param int[] $enabledQuestionIDs |
439 | * @return Answer[] |
440 | * @throws InvalidAnswerDataException If an answer's value is malformed |
441 | */ |
442 | public function extractUserAnswersAPI( array $data, array $enabledQuestionIDs ): array { |
443 | $answers = []; |
444 | foreach ( $this->getQuestions() as $question ) { |
445 | if ( !in_array( $question['db-id'], $enabledQuestionIDs, true ) ) { |
446 | continue; |
447 | } |
448 | $questionName = $question['name']; |
449 | if ( !isset( $data[$questionName] ) ) { |
450 | continue; |
451 | } |
452 | $answer = $this->newAnswerFromAPI( $question, $questionName, $data[$questionName] ); |
453 | if ( $answer ) { |
454 | $answers[] = $answer; |
455 | } |
456 | } |
457 | return $answers; |
458 | } |
459 | |
460 | /** |
461 | * @param array $questionSpec Must be an entry in the registry |
462 | * @param string $questionName |
463 | * @param array $answerData |
464 | * @return Answer|null |
465 | * @throws InvalidAnswerDataException If an answer's value is malformed |
466 | */ |
467 | private function newAnswerFromAPI( array $questionSpec, string $questionName, array $answerData ): ?Answer { |
468 | $type = $questionSpec['questionData']['type']; |
469 | $ansValue = $answerData['value']; |
470 | if ( $this->isPlaceholderValue( $type, $ansValue ) ) { |
471 | return null; |
472 | } |
473 | if ( $type === self::FREE_TEXT_QUESTION_TYPE ) { |
474 | if ( !is_string( $ansValue ) ) { |
475 | throw new InvalidAnswerDataException( $questionName ); |
476 | } |
477 | $ansOption = null; |
478 | $ansText = $ansValue; |
479 | } elseif ( in_array( $type, self::MULTIPLE_CHOICE_TYPES, true ) ) { |
480 | if ( !is_int( $ansValue ) ) { |
481 | throw new InvalidAnswerDataException( $questionName ); |
482 | } |
483 | if ( !in_array( $ansValue, $questionSpec['questionData']['options-messages'], true ) ) { |
484 | throw new InvalidAnswerDataException( $questionName ); |
485 | } |
486 | $ansOption = $ansValue; |
487 | $ansText = null; |
488 | if ( isset( $questionSpec['otherOptions'][$ansValue] ) && isset( $answerData['other'] ) ) { |
489 | $answerOther = $answerData['other']; |
490 | if ( $answerOther !== '' ) { |
491 | if ( !is_string( $answerOther ) ) { |
492 | throw new InvalidAnswerDataException( $questionName ); |
493 | } |
494 | $ansText = $answerData['other']; |
495 | } |
496 | } |
497 | } else { |
498 | throw new UnexpectedValueException( "Unhandled question type $type" ); |
499 | } |
500 | |
501 | return new Answer( $questionSpec['db-id'], $ansOption, $ansText ); |
502 | } |
503 | |
504 | /** |
505 | * Returns message keys to use as labels for each question in the form shown to organizers when they enable |
506 | * or edit an event registation. Labels for PII and non-PII questions are provided separately. Question labels |
507 | * are mapped to question IDs. |
508 | * |
509 | * @return string[][] |
510 | * @phan-return array{pii:array<string,int>,non-pii:array<string,int>} |
511 | */ |
512 | public function getQuestionLabelsForOrganizerForm(): array { |
513 | $ret = [ 'pii' => [], 'non-pii' => [] ]; |
514 | foreach ( $this->getQuestions() as $question ) { |
515 | $key = $question['pii'] ? 'pii' : 'non-pii'; |
516 | $questionMsg = $question['organizer-label-message']; |
517 | $ret[$key][$questionMsg] = $question['db-id']; |
518 | } |
519 | return $ret; |
520 | } |
521 | |
522 | /** |
523 | * Returns the key of a message to be used when introducing stats for the given question. |
524 | * |
525 | * @param int $questionID |
526 | * @return string |
527 | */ |
528 | public function getQuestionLabelForStats( int $questionID ): string { |
529 | foreach ( $this->getQuestions() as $question ) { |
530 | if ( $question['db-id'] === $questionID ) { |
531 | return $question['stats-label-message']; |
532 | } |
533 | } |
534 | throw new UnknownQuestionException( "Unknown question with ID $questionID" ); |
535 | } |
536 | |
537 | /** |
538 | * @param int $questionID |
539 | * @return array<int,string> Map of [ answer ID => message key ] |
540 | */ |
541 | public function getQuestionOptionsForStats( int $questionID ): array { |
542 | foreach ( $this->getQuestions() as $question ) { |
543 | if ( $question['db-id'] === $questionID ) { |
544 | $questionData = $question['questionData']; |
545 | if ( !in_array( $questionData['type'], self::MULTIPLE_CHOICE_TYPES, true ) ) { |
546 | throw new LogicException( 'Not implemented' ); |
547 | } |
548 | $options = array_flip( $questionData['options-messages'] ); |
549 | unset( $options[0] ); |
550 | return $options; |
551 | } |
552 | } |
553 | throw new UnknownQuestionException( "Unknown question with ID $questionID" ); |
554 | } |
555 | |
556 | /** |
557 | * @param int $questionID |
558 | * @param int $optionID |
559 | * @return string |
560 | */ |
561 | public function getQuestionOptionMessageByID( int $questionID, int $optionID ): string { |
562 | foreach ( $this->getQuestions() as $question ) { |
563 | if ( $question['db-id'] === $questionID ) { |
564 | $optionMessages = array_flip( $question['questionData']['options-messages'] ); |
565 | if ( !isset( $optionMessages[ $optionID ] ) ) { |
566 | throw new UnknownQuestionOptionException( "Unknown option with ID $optionID" ); |
567 | } |
568 | return $optionMessages[ $optionID ]; |
569 | } |
570 | } |
571 | throw new UnknownQuestionException( "Unknown question with ID $questionID" ); |
572 | } |
573 | |
574 | /** |
575 | * @return string[] |
576 | */ |
577 | public function getAvailableQuestionNames(): array { |
578 | return array_column( $this->getQuestions(), 'name' ); |
579 | } |
580 | |
581 | /** |
582 | * Given a question name, returns the corresponding database ID. |
583 | * |
584 | * @param string $name |
585 | * @return int |
586 | * @throws UnknownQuestionException |
587 | */ |
588 | public function nameToDBID( string $name ): int { |
589 | foreach ( $this->getQuestions() as $question ) { |
590 | if ( $question['name'] === $name ) { |
591 | return $question['db-id']; |
592 | } |
593 | } |
594 | throw new UnknownQuestionException( "Unknown question name $name" ); |
595 | } |
596 | |
597 | /** |
598 | * Given a question database ID, returns its name. |
599 | * |
600 | * @param int $dbID |
601 | * @return string |
602 | * @throws UnknownQuestionException |
603 | */ |
604 | public function dbIDToName( int $dbID ): string { |
605 | foreach ( $this->getQuestions() as $question ) { |
606 | if ( $question['db-id'] === $dbID ) { |
607 | return $question['name']; |
608 | } |
609 | } |
610 | throw new UnknownQuestionException( "Unknown question DB ID $dbID" ); |
611 | } |
612 | |
613 | /** |
614 | * Returns non PII questions labels. |
615 | * |
616 | * @param array $eventQuestions |
617 | * @return array |
618 | */ |
619 | public function getNonPIIQuestionLabels( array $eventQuestions ): array { |
620 | $nonPIIquestionLabels = []; |
621 | foreach ( $this->getQuestions() as $question ) { |
622 | if ( in_array( $question[ 'db-id' ], $eventQuestions, true ) && $question['pii'] === false ) { |
623 | $nonPIIquestionLabels[] = $question['non-pii-label-message']; |
624 | } |
625 | } |
626 | return $nonPIIquestionLabels; |
627 | } |
628 | |
629 | /** |
630 | * Returns non PII questions IDs. |
631 | * |
632 | * @param array $eventQuestions |
633 | * @return array |
634 | */ |
635 | public function getNonPIIQuestionIDs( array $eventQuestions ): array { |
636 | $nonPIIquestionIDs = []; |
637 | foreach ( $this->getQuestions() as $question ) { |
638 | if ( in_array( $question[ 'db-id' ], $eventQuestions, true ) && $question['pii'] === false ) { |
639 | $nonPIIquestionIDs[] = $question['db-id']; |
640 | } |
641 | } |
642 | return $nonPIIquestionIDs; |
643 | } |
644 | |
645 | /** |
646 | * Given a list of questions currently enabled for an event and a list of all answers a user has ever provided for |
647 | * the event, merge the two to get a list of questions that the user should see when registering, or when editing |
648 | * their registration information. |
649 | * |
650 | * @param int[] $enabledQuestions |
651 | * @param Answer[] $userAnswers |
652 | * @return int[] |
653 | */ |
654 | public static function getParticipantQuestionsToShow( array $enabledQuestions, array $userAnswers ): array { |
655 | $answeredQuestionIDs = array_map( static fn ( Answer $a ) => $a->getQuestionDBID(), $userAnswers ); |
656 | $allQuestions = array_unique( array_merge( $enabledQuestions, $answeredQuestionIDs ) ); |
657 | // Sorting not strictly necessary, just for debugging etc. |
658 | sort( $allQuestions ); |
659 | return $allQuestions; |
660 | } |
661 | } |