Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 179
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ResponseStatisticsModule
0.00% covered (danger)
0.00%
0 / 179
0.00% covered (danger)
0.00%
0 / 7
552
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 createContent
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
30
 makeContentWithAggregates
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 makeQuestionCategorySectionContainer
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 makeQuestionCategorySection
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 makeQuestionSection
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
6
 makeAnswerTable
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\FrontendModules;
6
7use LogicException;
8use MediaWiki\Context\IContextSource;
9use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
10use MediaWiki\Extension\CampaignEvents\Organizers\Organizer;
11use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore;
12use MediaWiki\Extension\CampaignEvents\Questions\EventAggregatedAnswers;
13use MediaWiki\Extension\CampaignEvents\Questions\EventAggregatedAnswersStore;
14use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry;
15use MediaWiki\Extension\CampaignEvents\Questions\ParticipantAnswersStore;
16use MediaWiki\Language\Language;
17use OOUI\IconWidget;
18use OOUI\MessageWidget;
19use OOUI\Tag;
20use Wikimedia\Message\IMessageFormatterFactory;
21use Wikimedia\Message\ITextFormatter;
22use Wikimedia\Message\MessageValue;
23
24class 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}