Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 179 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
ResponseStatisticsModule | |
0.00% |
0 / 179 |
|
0.00% |
0 / 7 |
552 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
createContent | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
30 | |||
makeContentWithAggregates | |
0.00% |
0 / 31 |
|
0.00% |
0 / 1 |
30 | |||
makeQuestionCategorySectionContainer | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
makeQuestionCategorySection | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
makeQuestionSection | |
0.00% |
0 / 38 |
|
0.00% |
0 / 1 |
6 | |||
makeAnswerTable | |
0.00% |
0 / 71 |
|
0.00% |
0 / 1 |
56 |
1 | <?php |
2 | |
3 | declare( strict_types=1 ); |
4 | |
5 | namespace MediaWiki\Extension\CampaignEvents\FrontendModules; |
6 | |
7 | use LogicException; |
8 | use MediaWiki\Context\IContextSource; |
9 | use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration; |
10 | use MediaWiki\Extension\CampaignEvents\Organizers\Organizer; |
11 | use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore; |
12 | use MediaWiki\Extension\CampaignEvents\Questions\EventAggregatedAnswers; |
13 | use MediaWiki\Extension\CampaignEvents\Questions\EventAggregatedAnswersStore; |
14 | use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry; |
15 | use MediaWiki\Extension\CampaignEvents\Questions\ParticipantAnswersStore; |
16 | use MediaWiki\Language\Language; |
17 | use OOUI\IconWidget; |
18 | use OOUI\MessageWidget; |
19 | use OOUI\Tag; |
20 | use Wikimedia\Message\IMessageFormatterFactory; |
21 | use Wikimedia\Message\ITextFormatter; |
22 | use Wikimedia\Message\MessageValue; |
23 | |
24 | class ResponseStatisticsModule { |
25 | |
26 | private const MIN_ANSWERS_PER_QUESTION = 10; |
27 | private const MIN_ANSWERS_PER_OPTION = 5; |
28 | |
29 | private ITextFormatter $msgFormatter; |
30 | private ParticipantAnswersStore $answersStore; |
31 | private EventAggregatedAnswersStore $aggregatedAnswersStore; |
32 | private EventQuestionsRegistry $questionsRegistry; |
33 | private ParticipantsStore $participantsStore; |
34 | private FrontendModulesFactory $frontendModulesFactory; |
35 | |
36 | private Language $language; |
37 | private ExistingEventRegistration $event; |
38 | |
39 | /** |
40 | * @param IMessageFormatterFactory $messageFormatterFactory |
41 | * @param ParticipantAnswersStore $answersStore |
42 | * @param EventAggregatedAnswersStore $aggregatedAnswersStore |
43 | * @param EventQuestionsRegistry $questionsRegistry |
44 | * @param ParticipantsStore $participantsStore |
45 | * @param FrontendModulesFactory $frontendModulesFactory |
46 | * @param ExistingEventRegistration $event |
47 | * @param Language $language |
48 | */ |
49 | public function __construct( |
50 | IMessageFormatterFactory $messageFormatterFactory, |
51 | ParticipantAnswersStore $answersStore, |
52 | EventAggregatedAnswersStore $aggregatedAnswersStore, |
53 | EventQuestionsRegistry $questionsRegistry, |
54 | ParticipantsStore $participantsStore, |
55 | FrontendModulesFactory $frontendModulesFactory, |
56 | ExistingEventRegistration $event, |
57 | Language $language |
58 | ) { |
59 | $this->msgFormatter = $messageFormatterFactory->getTextFormatter( $language->getCode() ); |
60 | $this->answersStore = $answersStore; |
61 | $this->aggregatedAnswersStore = $aggregatedAnswersStore; |
62 | $this->questionsRegistry = $questionsRegistry; |
63 | $this->participantsStore = $participantsStore; |
64 | $this->frontendModulesFactory = $frontendModulesFactory; |
65 | $this->language = $language; |
66 | $this->event = $event; |
67 | } |
68 | |
69 | public function createContent( Organizer $organizer, IContextSource $context, string $pageURL ): Tag { |
70 | if ( !$this->event->isPast() ) { |
71 | throw new LogicException( __METHOD__ . ' called for event that has not ended' ); |
72 | } |
73 | |
74 | $eventID = $this->event->getID(); |
75 | $hasAnswers = $this->answersStore->eventHasAnswers( $eventID ); |
76 | $hasAggregates = $this->aggregatedAnswersStore->eventHasAggregates( $eventID ); |
77 | if ( !$hasAnswers && !$hasAggregates ) { |
78 | // No participants ever answered any questions. |
79 | return new MessageWidget( [ |
80 | 'type' => 'notice', |
81 | 'label' => $this->msgFormatter->format( |
82 | MessageValue::new( 'campaignevents-details-stats-no-responses' ) |
83 | ), |
84 | 'inline' => true |
85 | ] ); |
86 | } |
87 | |
88 | if ( $hasAnswers ) { |
89 | // Probably a recently finished event, we still have to aggregate the responses |
90 | return new MessageWidget( [ |
91 | 'type' => 'notice', |
92 | 'label' => $this->msgFormatter->format( |
93 | MessageValue::new( 'campaignevents-details-stats-not-ready' ) |
94 | ), |
95 | 'inline' => true |
96 | ] ); |
97 | } |
98 | |
99 | return $this->makeContentWithAggregates( $organizer, $context, $pageURL ); |
100 | } |
101 | |
102 | private function makeContentWithAggregates( |
103 | Organizer $organizer, |
104 | IContextSource $context, |
105 | string $pageURL |
106 | ): Tag { |
107 | $aggregates = $this->aggregatedAnswersStore->getEventAggregatedAnswers( $this->event->getID() ); |
108 | $totalParticipants = $this->participantsStore->getFullParticipantCountForEvent( $this->event->getID() ); |
109 | $eventQuestions = $this->event->getParticipantQuestions(); |
110 | |
111 | $nonPIIQuestions = $this->questionsRegistry->getNonPIIQuestionIDs( $eventQuestions ); |
112 | $piiQuestions = array_diff( $eventQuestions, $nonPIIQuestions ); |
113 | |
114 | $content = new Tag( 'div' ); |
115 | if ( $nonPIIQuestions ) { |
116 | $nonPIISection = $this->makeQuestionCategorySectionContainer( |
117 | 'campaignevents-details-stats-section-non-pii' |
118 | ); |
119 | $nonPIISection->appendContent( $this->makeQuestionCategorySection( |
120 | $nonPIIQuestions, |
121 | $aggregates, |
122 | $totalParticipants |
123 | ) ); |
124 | $content->appendContent( $nonPIISection ); |
125 | } |
126 | |
127 | if ( $piiQuestions ) { |
128 | $piiSection = $this->makeQuestionCategorySectionContainer( |
129 | 'campaignevents-details-stats-section-pii' |
130 | ); |
131 | $formModule = $this->frontendModulesFactory->newClickwrapFormModule( $this->event, $this->language ); |
132 | $form = $formModule->createContent( $context, $pageURL ); |
133 | if ( $form['isSubmitted'] || $organizer->getClickwrapAcceptance() ) { |
134 | $piiSection->appendContent( $this->makeQuestionCategorySection( |
135 | $piiQuestions, |
136 | $aggregates, |
137 | $totalParticipants |
138 | ) ); |
139 | } else { |
140 | $piiSection->appendContent( $form['content'] ); |
141 | } |
142 | $content->appendContent( $piiSection ); |
143 | } |
144 | return $content; |
145 | } |
146 | |
147 | private function makeQuestionCategorySectionContainer( string $headerMsg ): Tag { |
148 | $section = ( new Tag( 'div' ) )->addClasses( [ 'ext-campaignevents-eventdetails-stats-section' ] ); |
149 | $header = ( new Tag( 'h2' ) ) |
150 | ->appendContent( $this->msgFormatter->format( MessageValue::new( $headerMsg ) ) ) |
151 | ->addClasses( [ 'ext-campaignevents-eventdetails-stats-section-header' ] ); |
152 | return $section->appendContent( $header ); |
153 | } |
154 | |
155 | private function makeQuestionCategorySection( |
156 | array $questionIDs, |
157 | EventAggregatedAnswers $aggregates, |
158 | int $totalParticipants |
159 | ): Tag { |
160 | $container = new Tag( 'div' ); |
161 | foreach ( $questionIDs as $questionID ) { |
162 | $container->appendContent( $this->makeQuestionSection( $questionID, $aggregates, $totalParticipants ) ); |
163 | } |
164 | return $container; |
165 | } |
166 | |
167 | private function makeQuestionSection( |
168 | int $questionID, |
169 | EventAggregatedAnswers $aggregates, |
170 | int $totalParticipants |
171 | ): Tag { |
172 | // TODO: Use accordion component when available (see T145934 and T338184) |
173 | $container = ( new Tag( 'div' ) ) |
174 | ->addClasses( [ |
175 | 'ext-campaignevents-eventdetails-stats-question-container', |
176 | 'mw-collapsible', |
177 | 'mw-collapsed', |
178 | ] ); |
179 | |
180 | $labelMsgKey = $this->questionsRegistry->getQuestionLabelForStats( $questionID ); |
181 | $header = ( new Tag( 'div' ) ) |
182 | ->addClasses( [ 'mw-collapsible-toggle' ] ) |
183 | ->setAttributes( [ 'role' => 'button' ] ) |
184 | ->appendContent( |
185 | new IconWidget( [ |
186 | 'icon' => 'expand', |
187 | 'label' => $this->msgFormatter->format( MessageValue::new( 'collapsible-expand' ) ), |
188 | ] ), |
189 | new IconWidget( [ |
190 | 'icon' => 'collapse', |
191 | 'label' => $this->msgFormatter->format( MessageValue::new( 'collapsible-collapse' ) ), |
192 | ] ), |
193 | ( new Tag( 'h3' ) )->appendContent( $this->msgFormatter->format( MessageValue::new( $labelMsgKey ) ) ) |
194 | ); |
195 | $container->appendContent( $header ); |
196 | |
197 | $questionAggregates = $aggregates->getQuestionData( $questionID ); |
198 | $totalAnswers = array_sum( $questionAggregates ); |
199 | if ( $totalAnswers < self::MIN_ANSWERS_PER_QUESTION ) { |
200 | $notice = new MessageWidget( [ |
201 | 'type' => 'notice', |
202 | 'label' => $this->msgFormatter->format( |
203 | MessageValue::new( 'campaignevents-details-stats-not-enough-answers' ) |
204 | ->numParams( self::MIN_ANSWERS_PER_QUESTION ) |
205 | ), |
206 | 'inline' => true |
207 | ] ); |
208 | $content = $notice; |
209 | } else { |
210 | $content = $this->makeAnswerTable( $questionID, $questionAggregates, $totalParticipants ); |
211 | } |
212 | $content->addClasses( [ 'mw-collapsible-content' ] ); |
213 | $container->appendContent( $content ); |
214 | return $container; |
215 | } |
216 | |
217 | /** |
218 | * @param int $questionID |
219 | * @param array<int,int> $questionAggregates |
220 | * @param int $totalParticipants |
221 | * @return Tag |
222 | */ |
223 | private function makeAnswerTable( |
224 | int $questionID, |
225 | array $questionAggregates, |
226 | int $totalParticipants |
227 | ): Tag { |
228 | $table = ( new Tag( 'table' ) ) |
229 | ->addClasses( [ 'ext-campaignevents-eventdetails-stats-question-table' ] ); |
230 | |
231 | $tableHeaderContents = [ |
232 | $this->msgFormatter->format( MessageValue::new( 'campaignevents-details-stats-header-option' ) ), |
233 | $this->msgFormatter->format( MessageValue::new( 'campaignevents-details-stats-header-percentage' ) ), |
234 | $this->msgFormatter->format( MessageValue::new( 'campaignevents-details-stats-header-number' ) ) |
235 | ]; |
236 | |
237 | // Calculate the percentage corresponding to MIN_ANSWERS_PER_OPTION, used when the percentage is shown |
238 | // as a range. |
239 | $percentageThreshold = round( 100 * ( self::MIN_ANSWERS_PER_OPTION - 1 ) / $totalParticipants, 1 ); |
240 | $allOptions = $this->questionsRegistry->getQuestionOptionsForStats( $questionID ); |
241 | $tableCellContentByRow = []; |
242 | // See https://www.mediawiki.org/wiki/Extension:CampaignEvents/Aggregating_participants%27_responses for |
243 | // the formulas used here. |
244 | $answersBelowThreshold = 0; |
245 | $knownAnswersNum = 0; |
246 | foreach ( $allOptions as $id => $msgKey ) { |
247 | $rowElements = [ |
248 | $this->msgFormatter->format( MessageValue::new( $msgKey ) ) |
249 | ]; |
250 | |
251 | $numAnswers = $questionAggregates[$id] ?? 0; |
252 | if ( $numAnswers < self::MIN_ANSWERS_PER_OPTION ) { |
253 | $answersBelowThreshold++; |
254 | $rowElements[] = $this->msgFormatter->format( |
255 | MessageValue::new( 'campaignevents-details-stats-range-percentage' ) |
256 | ->numParams( 0, $percentageThreshold ) |
257 | ); |
258 | $rowElements[] = $this->msgFormatter->format( |
259 | MessageValue::new( 'campaignevents-details-stats-few-answers-option' ) |
260 | ->numParams( self::MIN_ANSWERS_PER_OPTION ) |
261 | ); |
262 | } else { |
263 | $knownAnswersNum += $numAnswers; |
264 | $percentage = round( $numAnswers / $totalParticipants * 100, 1 ); |
265 | $rowElements[] = $this->msgFormatter->format( |
266 | MessageValue::new( 'percent' )->numParams( $percentage ) |
267 | ); |
268 | $rowElements[] = $this->language->formatNum( $numAnswers ); |
269 | } |
270 | $tableCellContentByRow[] = $rowElements; |
271 | } |
272 | |
273 | $noResponseRowElements = [ |
274 | $this->msgFormatter->format( MessageValue::new( 'campaignevents-details-stats-no-response' ) ) |
275 | ]; |
276 | $noResponseMin = max( |
277 | $totalParticipants - $knownAnswersNum - ( self::MIN_ANSWERS_PER_OPTION - 1 ) * $answersBelowThreshold, |
278 | 0 |
279 | ); |
280 | $noResponseMax = $totalParticipants - $knownAnswersNum; |
281 | if ( $noResponseMin === $noResponseMax ) { |
282 | // No answers below threshold, we can just show this as a number. |
283 | $noResponsePercentage = round( $noResponseMin / $totalParticipants * 100, 1 ); |
284 | $noResponseRowElements[] = $this->msgFormatter->format( |
285 | MessageValue::new( 'percent' )->numParams( $noResponsePercentage ) |
286 | ); |
287 | $noResponseRowElements[] = $this->language->formatNum( $noResponseMin ); |
288 | } else { |
289 | // Show it as a range. |
290 | $noResponsePercentageMin = round( $noResponseMin / $totalParticipants * 100, 1 ); |
291 | $noResponsePercentageMax = round( $noResponseMax / $totalParticipants * 100, 1 ); |
292 | $noResponseRowElements[] = $this->msgFormatter->format( |
293 | MessageValue::new( 'campaignevents-details-stats-range-percentage' ) |
294 | ->numParams( $noResponsePercentageMin, $noResponsePercentageMax ) |
295 | ); |
296 | $noResponseRowElements[] = $this->msgFormatter->format( |
297 | MessageValue::new( 'campaignevents-details-stats-range-number' ) |
298 | ->numParams( $noResponseMin, $noResponseMax ) |
299 | ); |
300 | } |
301 | |
302 | $tableCellContentByRow[] = $noResponseRowElements; |
303 | |
304 | $tableHeader = new Tag( 'thead' ); |
305 | $tableHeaderRow = new Tag( 'tr' ); |
306 | foreach ( $tableHeaderContents as $header ) { |
307 | $tableHeaderRow->appendContent( ( new Tag( 'th' ) )->appendContent( $header ) ); |
308 | } |
309 | $tableHeader->appendContent( $tableHeaderRow ); |
310 | $table->appendContent( $tableHeader ); |
311 | |
312 | foreach ( $tableCellContentByRow as $rowContent ) { |
313 | $row = new Tag( 'tr' ); |
314 | foreach ( $rowContent as $cellContent ) { |
315 | $row->appendContent( ( new Tag( 'td' ) )->appendContent( $cellContent ) ); |
316 | } |
317 | $table->appendContent( $row ); |
318 | } |
319 | return $table; |
320 | } |
321 | } |