Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 452
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventDetailsParticipantsModule
0.00% covered (danger)
0.00%
0 / 452
0.00% covered (danger)
0.00%
0 / 14
4290
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 createContent
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
240
 getPrimaryHeader
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
30
 getParticipantsTable
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
2
 getEmptyStateElement
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 getSearchBar
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getTableHeaders
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
72
 getParticipantRows
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 getCurUserParticipantRow
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getParticipantRow
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
110
 addNonPIIParticipantAnswers
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 getQuestionAnswer
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getFooter
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
30
 getHeaderControls
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\FrontendModules;
6
7use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
8use MediaWiki\Extension\CampaignEvents\Messaging\CampaignsUserMailer;
9use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
10use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUserNotFoundException;
11use MediaWiki\Extension\CampaignEvents\MWEntity\HiddenCentralUserException;
12use MediaWiki\Extension\CampaignEvents\MWEntity\ICampaignsAuthority;
13use MediaWiki\Extension\CampaignEvents\MWEntity\UserLinker;
14use MediaWiki\Extension\CampaignEvents\MWEntity\UserNotGlobalException;
15use MediaWiki\Extension\CampaignEvents\Participants\Participant;
16use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore;
17use MediaWiki\Extension\CampaignEvents\Participants\UnregisterParticipantCommand;
18use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker;
19use MediaWiki\Extension\CampaignEvents\Questions\Answer;
20use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry;
21use MediaWiki\Language\Language;
22use MediaWiki\Output\OutputPage;
23use MediaWiki\Parser\Sanitizer;
24use MediaWiki\User\UserFactory;
25use MediaWiki\User\UserIdentity;
26use OOUI\ButtonGroupWidget;
27use OOUI\ButtonWidget;
28use OOUI\CheckboxInputWidget;
29use OOUI\FieldLayout;
30use OOUI\HtmlSnippet;
31use OOUI\IconWidget;
32use OOUI\MessageWidget;
33use OOUI\PanelLayout;
34use OOUI\SearchInputWidget;
35use OOUI\Tag;
36use Wikimedia\Message\IMessageFormatterFactory;
37use Wikimedia\Message\ITextFormatter;
38use Wikimedia\Message\MessageValue;
39
40class EventDetailsParticipantsModule {
41
42    private const PARTICIPANTS_LIMIT = 20;
43    public const MODULE_STYLES = [
44        'oojs-ui.styles.icons-moderation',
45        'oojs-ui.styles.icons-user',
46        ...UserLinker::MODULE_STYLES
47    ];
48
49    private UserLinker $userLinker;
50    private ParticipantsStore $participantsStore;
51    private CampaignsCentralUserLookup $centralUserLookup;
52    private PermissionChecker $permissionChecker;
53    private UserFactory $userFactory;
54    private CampaignsUserMailer $userMailer;
55
56    private ITextFormatter $msgFormatter;
57    private EventQuestionsRegistry $eventQuestionsRegistry;
58    private Language $language;
59    private string $statisticsTabUrl;
60    private bool $isPastEvent;
61
62    /**
63     * @param IMessageFormatterFactory $messageFormatterFactory
64     * @param UserLinker $userLinker
65     * @param ParticipantsStore $participantsStore
66     * @param CampaignsCentralUserLookup $centralUserLookup
67     * @param PermissionChecker $permissionChecker
68     * @param UserFactory $userFactory
69     * @param CampaignsUserMailer $userMailer
70     * @param EventQuestionsRegistry $eventQuestionsRegistry
71     * @param Language $language
72     * @param string $statisticsTabUrl
73     */
74    public function __construct(
75        IMessageFormatterFactory $messageFormatterFactory,
76        UserLinker $userLinker,
77        ParticipantsStore $participantsStore,
78        CampaignsCentralUserLookup $centralUserLookup,
79        PermissionChecker $permissionChecker,
80        UserFactory $userFactory,
81        CampaignsUserMailer $userMailer,
82        EventQuestionsRegistry $eventQuestionsRegistry,
83        Language $language,
84        string $statisticsTabUrl
85    ) {
86        $this->userLinker = $userLinker;
87        $this->participantsStore = $participantsStore;
88        $this->centralUserLookup = $centralUserLookup;
89        $this->permissionChecker = $permissionChecker;
90        $this->userFactory = $userFactory;
91        $this->userMailer = $userMailer;
92
93        $this->msgFormatter = $messageFormatterFactory->getTextFormatter( $language->getCode() );
94        $this->eventQuestionsRegistry = $eventQuestionsRegistry;
95        $this->language = $language;
96        $this->statisticsTabUrl = $statisticsTabUrl;
97        $this->isPastEvent = false;
98    }
99
100    /**
101     * @param ExistingEventRegistration $event
102     * @param UserIdentity $viewingUser
103     * @param ICampaignsAuthority $authority
104     * @param bool $isOrganizer
105     * @param bool $canEmailParticipants
106     * @param bool $isLocalWiki
107     * @param OutputPage $out
108     * @return Tag
109     *
110     * @note Ideally, this wouldn't use MW-specific classes for l10n, but it's hard-ish to avoid and
111     * probably not worth doing.
112     */
113    public function createContent(
114        ExistingEventRegistration $event,
115        UserIdentity $viewingUser,
116        ICampaignsAuthority $authority,
117        bool $isOrganizer,
118        bool $canEmailParticipants,
119        bool $isLocalWiki,
120        OutputPage $out
121    ): Tag {
122        $eventID = $event->getID();
123        $this->isPastEvent = $event->isPast();
124        $totalParticipants = $this->participantsStore->getFullParticipantCountForEvent( $eventID );
125
126        $centralUser = null;
127        $curUserParticipant = null;
128        try {
129            $centralUser = $this->centralUserLookup->newFromAuthority( $authority );
130            $curUserParticipant = $this->participantsStore->getEventParticipant( $eventID, $centralUser, true );
131        } catch ( UserNotGlobalException $_ ) {
132        }
133
134        $showPrivateParticipants = $isLocalWiki &&
135            $this->permissionChecker->userCanViewPrivateParticipants( $authority, $event );
136        $otherParticipantsNum = $curUserParticipant ? self::PARTICIPANTS_LIMIT - 1 : self::PARTICIPANTS_LIMIT;
137        $otherParticipants = $this->participantsStore->getEventParticipants(
138            $eventID,
139            $otherParticipantsNum,
140            null,
141            null,
142            null,
143            $showPrivateParticipants,
144            $centralUser ? [ $centralUser->getCentralID() ] : null
145        );
146        $lastParticipant = $otherParticipants ? end( $otherParticipants ) : $curUserParticipant;
147        $lastParticipantID = $lastParticipant ? $lastParticipant->getParticipantID() : null;
148        $canRemoveParticipants = false;
149        if ( $isOrganizer && $isLocalWiki ) {
150            $canRemoveParticipants = UnregisterParticipantCommand::checkIsUnregistrationAllowed( $event )->isGood();
151        }
152
153        $canViewNonPIIParticipantsData = false;
154        if ( $isOrganizer && $isLocalWiki ) {
155            $canViewNonPIIParticipantsData = $this->permissionChecker->userCanViewNonPIIParticipantsData(
156                $authority, $event
157            );
158        }
159
160        $nonPIIQuestionIDs = $this->eventQuestionsRegistry->getNonPIIQuestionIDs(
161            $event->getParticipantQuestions()
162        );
163
164        $items = [];
165        $items[] = $this->getPrimaryHeader(
166            $event,
167            $totalParticipants,
168            $canRemoveParticipants,
169            $canEmailParticipants,
170            $canViewNonPIIParticipantsData
171        );
172        if ( $totalParticipants ) {
173            $items[] = $this->getParticipantsTable(
174                $viewingUser,
175                $canRemoveParticipants,
176                $canEmailParticipants,
177                $canViewNonPIIParticipantsData,
178                $curUserParticipant,
179                $otherParticipants,
180                $authority,
181                $event,
182                $nonPIIQuestionIDs
183            );
184        }
185        // This is added even if there are participants, because they might be removed from this page.
186        $items[] = $this->getEmptyStateElement( $totalParticipants );
187
188        $out->addJsConfigVars( [
189            'wgCampaignEventsShowParticipantCheckboxes' => $canRemoveParticipants || $canEmailParticipants,
190            'wgCampaignEventsShowPrivateParticipants' => $showPrivateParticipants,
191            'wgCampaignEventsEventDetailsParticipantsTotal' => $totalParticipants,
192            'wgCampaignEventsLastParticipantID' => $lastParticipantID,
193            'wgCampaignEventsCurUserCentralID' => $centralUser ? $centralUser->getCentralID() : null,
194            'wgCampaignEventsViewerHasEmail' =>
195                $this->userFactory->newFromUserIdentity( $viewingUser )->isEmailConfirmed(),
196            'wgCampaignEventsNonPIIQuestionIDs' => $nonPIIQuestionIDs,
197        ] );
198
199        $layout = new PanelLayout( [
200            'content' => $items,
201            'padded' => false,
202            'framed' => true,
203            'expanded' => false,
204        ] );
205
206        $content = ( new Tag( 'div' ) )
207            ->addClasses( [ 'ext-campaignevents-eventdetails-participants-panel' ] )
208            ->appendContent( $layout );
209
210        $footer = $this->getFooter( $eventID, $canViewNonPIIParticipantsData, $event, $out );
211        if ( $footer ) {
212            $content->appendContent( $footer );
213        }
214
215        return $content;
216    }
217
218    /**
219     * @param ExistingEventRegistration $event
220     * @param int $totalParticipants
221     * @param bool $canRemoveParticipants
222     * @param bool $canEmailParticipants
223     * @param bool $canViewNonPIIParticipantsData
224     * @return Tag
225     */
226    private function getPrimaryHeader(
227        ExistingEventRegistration $event,
228        int $totalParticipants,
229        bool $canRemoveParticipants,
230        bool $canEmailParticipants,
231        bool $canViewNonPIIParticipantsData
232    ): Tag {
233        $participantCountText = $this->msgFormatter->format(
234            MessageValue::new( 'campaignevents-event-details-header-participants' )
235                ->numParams( $totalParticipants )
236        );
237        $participantsCountElement = ( new Tag( 'span' ) )
238            ->appendContent( $participantCountText )
239            ->addClasses( [ 'ext-campaignevents-eventdetails-participants-header-participant-count' ] );
240        $participantsElement = ( new Tag( 'div' ) )
241            ->appendContent( $participantsCountElement )
242            ->addClasses( [ 'ext-campaignevents-eventdetails-participants-header-participants' ] );
243        if (
244            $canViewNonPIIParticipantsData &&
245            !$this->isPastEvent &&
246            $event->getParticipantQuestions()
247        ) {
248            $questionsHelp = new ButtonWidget( [
249                'framed' => false,
250                'icon' => 'info',
251                'label' => $this->msgFormatter->format(
252                    MessageValue::new( 'campaignevents-event-details-header-questions-help' )
253                ),
254                'invisibleLabel' => true,
255                'classes' => [ 'ext-campaignevents-eventdetails-participants-header-questions-help' ]
256            ] );
257            $participantsElement->appendContent( $questionsHelp );
258        }
259        $headerTitle = ( new Tag( 'div' ) )
260            ->appendContent( $participantsElement )
261            ->addClasses( [ 'ext-campaignevents-eventdetails-participants-header-title' ] );
262        $header = ( new Tag( 'div' ) )->addClasses( [ 'ext-campaignevents-eventdetails-participants-header' ] );
263
264        if ( $totalParticipants ) {
265            $headerTitle->appendContent( $this->getSearchBar() );
266            $header->appendContent( $headerTitle );
267            $header->appendContent( $this->getHeaderControls( $canRemoveParticipants, $canEmailParticipants ) );
268        } else {
269            $header->appendContent( $headerTitle );
270        }
271
272        return $header;
273    }
274
275    /**
276     * @param UserIdentity $viewingUser
277     * @param bool $canRemoveParticipants
278     * @param bool $canEmailParticipants
279     * @param bool $canViewNonPIIParticipantsData
280     * @param Participant|null $curUserParticipant
281     * @param Participant[] $otherParticipants
282     * @param ICampaignsAuthority $authority
283     * @param ExistingEventRegistration $event
284     * @param int[] $nonPIIQuestionIDs
285     * @return Tag
286     */
287    private function getParticipantsTable(
288        UserIdentity $viewingUser,
289        bool $canRemoveParticipants,
290        bool $canEmailParticipants,
291        bool $canViewNonPIIParticipantsData,
292        ?Participant $curUserParticipant,
293        array $otherParticipants,
294        ICampaignsAuthority $authority,
295        ExistingEventRegistration $event,
296        array $nonPIIQuestionIDs
297    ): Tag {
298        // Use an outer container for the infinite scrolling
299        $container = ( new Tag( 'div' ) )
300            ->addClasses( [ 'ext-campaignevents-eventdetails-participants-container' ] );
301        $table = ( new Tag( 'table' ) )
302            ->addClasses( [ 'ext-campaignevents-eventdetails-participants-table' ] );
303
304        $table->appendContent( $this->getTableHeaders(
305                $canRemoveParticipants,
306                $canEmailParticipants,
307                $event,
308                $authority,
309                $nonPIIQuestionIDs,
310                $canViewNonPIIParticipantsData
311            )
312        );
313        $table->appendContent( $this->getParticipantRows(
314            $curUserParticipant,
315            $otherParticipants,
316            $canRemoveParticipants,
317            $canEmailParticipants,
318            $viewingUser,
319            $nonPIIQuestionIDs,
320            $canViewNonPIIParticipantsData
321        ) );
322        $container->appendContent( $table );
323        return $container;
324    }
325
326    /**
327     * @param int $totalParticipants
328     * @return Tag
329     */
330    private function getEmptyStateElement( int $totalParticipants ): Tag {
331        $noParticipantsIcon = new IconWidget( [
332            'icon' => 'userGroup',
333            'classes' => [ 'ext-campaignevents-eventdetails-no-participants-icon' ]
334        ] );
335
336        $noParticipantsClasses = [ 'ext-campaignevents-eventdetails-no-participants-state' ];
337        if ( $totalParticipants > 0 ) {
338            $noParticipantsClasses[] = 'ext-campaignevents-eventdetails-hide-element';
339        }
340        return ( new Tag() )->appendContent(
341            $noParticipantsIcon,
342            ( new Tag() )->appendContent(
343                $this->msgFormatter->format(
344                    MessageValue::new( 'campaignevents-event-details-no-participants-state' )
345                )
346            )->addClasses( [ 'ext-campaignevents-eventdetails-no-participants-description' ] )
347        )->addClasses( $noParticipantsClasses );
348    }
349
350    /**
351     * @return Tag
352     */
353    private function getSearchBar(): Tag {
354            return new SearchInputWidget( [
355                'placeholder' => $this->msgFormatter->format(
356                    MessageValue::new( 'campaignevents-event-details-search-participants-placeholder' )
357                ),
358                'infusable' => true,
359                'classes' => [ 'ext-campaignevents-eventdetails-participants-search' ]
360            ] );
361    }
362
363    /**
364     * @param bool $canRemoveParticipants
365     * @param bool $canEmailParticipants
366     * @param ExistingEventRegistration $event
367     * @param ICampaignsAuthority $authority
368     * @param array $nonPIIQuestionIDs
369     * @param bool $userCanViewNonPIIParticipantsData
370     * @return Tag
371     */
372    private function getTableHeaders(
373        bool $canRemoveParticipants,
374        bool $canEmailParticipants,
375        ExistingEventRegistration $event,
376        ICampaignsAuthority $authority,
377        array $nonPIIQuestionIDs,
378        bool $userCanViewNonPIIParticipantsData
379    ): Tag {
380        $container = ( new Tag( 'thead' ) )
381            ->addClasses( [ 'ext-campaignevents-eventdetails-participants-table-header' ] );
382        $row = ( new Tag( 'tr' ) )
383            ->addClasses( [ 'ext-campaignevents-details-user-actions-row' ] );
384
385        if ( $canRemoveParticipants ) {
386            $selectAllCheckBoxField = new FieldLayout(
387                new CheckboxInputWidget( [
388                    'name' => 'event-details-select-all-participants',
389                ] ),
390                [
391                    'align' => 'inline',
392                    'classes' => [ 'ext-campaignevents-event-details-select-all-participant-checkbox-field' ],
393                    'infusable' => true,
394                    'label' => $this->msgFormatter->format(
395                        MessageValue::new( 'campaignevents-event-details-select-all' )
396                    ),
397                    'invisibleLabel' => true
398                ]
399            );
400
401            $selectAllCell = ( new Tag( 'th' ) )
402                ->addClasses( [ 'ext-campaignevents-eventdetails-participants-selectall-checkbox-cell' ] )
403                ->appendContent( $selectAllCheckBoxField );
404            $row->appendContent( $selectAllCell );
405        }
406
407        $headings = [
408            [
409                'message' => $this->msgFormatter->format(
410                    MessageValue::new( 'campaignevents-event-details-participants' )
411                ),
412                'cssClasses' => [ 'ext-campaignevents-eventdetails-participants-username-cell' ],
413            ],
414            [
415                'message' => $this->msgFormatter->format(
416                    MessageValue::new( 'campaignevents-event-details-time-registered' )
417                ),
418                'cssClasses' => [ 'ext-campaignevents-eventdetails-participants-time-registered-cell' ],
419            ],
420        ];
421        if ( $canEmailParticipants ) {
422            $headings[] = [
423                'message' => $this->msgFormatter->format(
424                    MessageValue::new( 'campaignevents-event-details-can-receive-email' )
425                ),
426                'cssClasses' => [ 'ext-campaignevents-eventdetails-participants-can-receive-email-cell' ],
427            ];
428        }
429
430        if ( !$this->isPastEvent && $userCanViewNonPIIParticipantsData ) {
431            $nonPIIQuestionLabels = $this->eventQuestionsRegistry->getNonPIIQuestionLabels(
432                $nonPIIQuestionIDs
433            );
434            if ( $nonPIIQuestionLabels ) {
435                foreach ( $nonPIIQuestionLabels as $nonPIIQuestionLabel ) {
436                    $headings[] = [
437                        'message' => $this->msgFormatter->format(
438                            MessageValue::new( $nonPIIQuestionLabel )
439                        ),
440                        'cssClasses' => [ 'ext-campaignevents-eventdetails-participants-non-pii-question-cells' ],
441                    ];
442                }
443            }
444        }
445
446        foreach ( $headings as $heading ) {
447            $row->appendContent(
448                ( new Tag( 'th' ) )->appendContent( $heading[ 'message' ] )->addClasses( $heading[ 'cssClasses' ] )
449            );
450        }
451        $container->appendContent( $row );
452
453        return $container;
454    }
455
456    /**
457     * @param Participant|null $curUserParticipant
458     * @param Participant[] $otherParticipants
459     * @param bool $canRemoveParticipants
460     * @param bool $canEmailParticipants
461     * @param UserIdentity $viewingUser
462     * @param array $nonPIIQuestionIDs
463     * @param bool $userCanViewNonPIIParticipantsData
464     * @return Tag
465     */
466    private function getParticipantRows(
467        ?Participant $curUserParticipant,
468        array $otherParticipants,
469        bool $canRemoveParticipants,
470        bool $canEmailParticipants,
471        UserIdentity $viewingUser,
472        array $nonPIIQuestionIDs,
473        bool $userCanViewNonPIIParticipantsData
474    ): Tag {
475        $body = new Tag( 'tbody' );
476        if ( $curUserParticipant ) {
477            $body->appendContent( $this->getCurUserParticipantRow(
478                $curUserParticipant,
479                $canRemoveParticipants,
480                $canEmailParticipants,
481                $viewingUser,
482                $nonPIIQuestionIDs,
483                $userCanViewNonPIIParticipantsData
484            ) );
485        }
486
487        foreach ( $otherParticipants as $participant ) {
488            $body->appendContent(
489                $this->getParticipantRow(
490                    $participant,
491                    $canRemoveParticipants,
492                    $canEmailParticipants,
493                    $viewingUser,
494                    $nonPIIQuestionIDs,
495                    $userCanViewNonPIIParticipantsData
496                )
497            );
498        }
499        return $body;
500    }
501
502    /**
503     * @param Participant $participant
504     * @param bool $canRemoveParticipants
505     * @param bool $canEmailParticipants
506     * @param UserIdentity $viewingUser
507     * @param array $nonPIIQuestionIDs
508     * @param bool $userCanViewNonPIIParticipantsData
509     * @return Tag
510     */
511    private function getCurUserParticipantRow(
512        Participant $participant,
513        bool $canRemoveParticipants,
514        bool $canEmailParticipants,
515        UserIdentity $viewingUser,
516        array $nonPIIQuestionIDs,
517        bool $userCanViewNonPIIParticipantsData
518    ): Tag {
519        $row = $this->getParticipantRow(
520            $participant,
521            $canRemoveParticipants,
522            $canEmailParticipants,
523            $viewingUser,
524            $nonPIIQuestionIDs,
525            $userCanViewNonPIIParticipantsData
526        );
527        $row->addClasses( [ 'ext-campaignevents-details-current-user-row' ] );
528        return $row;
529    }
530
531    /**
532     * @param Participant $participant
533     * @param bool $canRemoveParticipants
534     * @param bool $canEmailParticipants
535     * @param UserIdentity $viewingUser
536     * @param array $nonPIIQuestionIDs
537     * @param bool $userCanViewNonPIIParticipantsData
538     * @return Tag
539     */
540    private function getParticipantRow(
541        Participant $participant,
542        bool $canRemoveParticipants,
543        bool $canEmailParticipants,
544        UserIdentity $viewingUser,
545        array $nonPIIQuestionIDs,
546        bool $userCanViewNonPIIParticipantsData
547    ): Tag {
548        $row = new Tag( 'tr' );
549        $performer = $this->userFactory->newFromId( $viewingUser->getId() );
550        try {
551            $userName = $this->centralUserLookup->getUserName( $participant->getUser() );
552            $genderUserName = $userName;
553            $user = $this->userFactory->newFromName( $userName );
554            $userLinkComponents = $this->userLinker->getUserPagePath( $participant->getUser() );
555        } catch ( CentralUserNotFoundException | HiddenCentralUserException $_ ) {
556            $user = null;
557            $userName = null;
558            $genderUserName = '@';
559        }
560        $recipientIsValid = $user !== null && $canEmailParticipants &&
561            $this->userMailer->validateTarget( $user, $performer ) === null;
562        $userLink = $this->userLinker->generateUserLinkWithFallback(
563            $participant->getUser(),
564            $this->language->getCode()
565        );
566
567        if ( $canRemoveParticipants ) {
568            $checkboxCell = new Tag( 'td' );
569            $checkboxCell->addClasses( [ 'ext-campaignevents-eventdetails-user-row-checkbox' ] );
570            $userId = $participant->getUser()->getCentralID();
571            $checkbox = new CheckboxInputWidget( [
572                'name' => 'event-details-participants-checkboxes',
573                'infusable' => true,
574                'value' => $userId,
575                'classes' => [ 'ext-campaignevents-event-details-participants-checkboxes' ],
576                'data' => [
577                    'canReceiveEmail' => $recipientIsValid,
578                    'username' => $userName,
579                    'userId' => $userId,
580                    'userPageLink' => $userLinkComponents ?? ""
581                ],
582            ] );
583            $checkboxField = new FieldLayout(
584                $checkbox,
585                [
586                    'label' => Sanitizer::stripAllTags( $userLink ),
587                    'invisibleLabel' => true,
588                ]
589            );
590            $checkboxCell->appendContent( $checkboxField );
591            $row->appendContent( $checkboxCell );
592        }
593
594        $usernameElement = new HtmlSnippet( $userLink );
595        $usernameCell = ( new Tag( 'td' ) )
596            ->appendContent( $usernameElement );
597
598        if ( $participant->isPrivateRegistration() ) {
599            $labelText = $this->msgFormatter->format(
600                MessageValue::new( 'campaignevents-event-details-private-participant-label', [ $genderUserName ] )
601            );
602            $privateIcon = new IconWidget( [
603                'icon' => 'lock',
604                'label' => $labelText,
605                'title' => $labelText,
606                'classes' => [ 'ext-campaignevents-eventdetails-participants-private-icon' ]
607            ] );
608            $usernameCell->appendContent( $privateIcon );
609        }
610        $row->appendContent( $usernameCell );
611
612        $registrationDateCell = new Tag( 'td' );
613        $registrationDateCell->appendContent(
614            $this->language->userTimeAndDate(
615                $participant->getRegisteredAt(),
616                $viewingUser
617            )
618        );
619        $row->appendContent( $registrationDateCell );
620
621        if ( $canEmailParticipants ) {
622            $row->appendContent( ( new Tag( 'td' ) )->appendContent(
623                $recipientIsValid
624                    ? $this->msgFormatter->format( MessageValue::new( 'campaignevents-email-participants-yes' ) )
625                    : $this->msgFormatter->format( MessageValue::new( 'campaignevents-email-participants-no' ) )
626            ) );
627        }
628
629        if ( !$this->isPastEvent && $userCanViewNonPIIParticipantsData ) {
630            $row = $this->addNonPIIParticipantAnswers( $row, $participant, $nonPIIQuestionIDs, $genderUserName );
631        }
632        return $row
633            ->addClasses( [ 'ext-campaignevents-details-user-row' ] );
634    }
635
636    /**
637     * @param Tag $row
638     * @param Participant $participant
639     * @param array $nonPIIQuestionIDs
640     * @param string $genderUserName
641     * @return Tag
642     */
643    private function addNonPIIParticipantAnswers(
644        Tag $row,
645        Participant $participant,
646        array $nonPIIQuestionIDs,
647        string $genderUserName
648    ): Tag {
649        if ( !$nonPIIQuestionIDs ) {
650            return $row;
651        }
652
653        if ( $participant->getAggregationTimestamp() ) {
654            $aggregatedMessage = $this->msgFormatter->format(
655                MessageValue::new( 'campaignevents-participant-question-have-been-aggregated', [ $genderUserName ] )
656            );
657            $td = ( new Tag( 'td' ) )->setAttributes( [ 'colspan' => count( $nonPIIQuestionIDs ) ] )
658                ->appendContent( $aggregatedMessage )
659                ->addClasses( [ 'ext-campaignevents-eventdetails-participants-responses-aggregated-notice' ] );
660            $row->appendContent( $td );
661            return $row;
662        } else {
663            $answeredQuestions = [];
664            foreach ( $participant->getAnswers() as $answer ) {
665                $answeredQuestions[ $answer->getQuestionDBID() ] = $answer;
666            }
667
668            $noResponseMessage = $this->msgFormatter->format(
669                MessageValue::new( 'campaignevents-participant-question-no-response' )
670            );
671            foreach ( $nonPIIQuestionIDs as $nonPIIQuestionID ) {
672                if ( array_key_exists( $nonPIIQuestionID, $answeredQuestions ) ) {
673                    $nonPIIAnswer = $this->getQuestionAnswer( $answeredQuestions[ $nonPIIQuestionID ] );
674                    $row->appendContent( ( new Tag( 'td' ) )->appendContent( $nonPIIAnswer ) );
675                } else {
676                    $row->appendContent( ( new Tag( 'td' ) )->appendContent( $noResponseMessage ) );
677                }
678            }
679        }
680        return $row;
681    }
682
683    /**
684     * @param Answer $answer
685     * @return string
686     */
687    private function getQuestionAnswer( Answer $answer ) {
688        $option = $answer->getOption();
689        if ( $option === null ) {
690            return $this->msgFormatter->format(
691                MessageValue::new( 'campaignevents-participant-question-no-response' )
692            );
693        }
694        $optionMessageKey = $this->eventQuestionsRegistry->getQuestionOptionMessageByID(
695            $answer->getQuestionDBID(),
696            $option
697        );
698        $participantAnswer = $this->msgFormatter->format( MessageValue::new( $optionMessageKey ) );
699        if ( $answer->getText() ) {
700            $participantAnswer .= $this->msgFormatter->format(
701                MessageValue::new( 'colon-separator' )
702            ) . $answer->getText();
703        }
704        return $participantAnswer;
705    }
706
707    /**
708     * @param int $eventID
709     * @param bool $userCanViewNonPIIParticipantsData
710     * @param ExistingEventRegistration $event
711     * @param OutputPage $out
712     * @return Tag|null
713     */
714    private function getFooter(
715        int $eventID,
716        bool $userCanViewNonPIIParticipantsData,
717        ExistingEventRegistration $event,
718        OutputPage $out
719    ): ?Tag {
720        $privateParticipantsCount = $this->participantsStore->getPrivateParticipantCountForEvent( $eventID );
721
722        $footer = ( new Tag( 'div' ) )->addClasses( [ 'ext-campaignevents-eventdetails-participants-footer' ] );
723        if ( $privateParticipantsCount > 0 ) {
724            $icon = new IconWidget( [ 'icon' => 'lock' ] );
725            $text = $this->msgFormatter->format(
726                MessageValue::new( 'campaignevents-event-details-participants-private' )
727                    ->numParams( $privateParticipantsCount )
728            );
729            $textElement = ( new Tag( 'span' ) )
730                ->addClasses( [ 'ext-campaignevents-eventdetails-participants-private-count-msg' ] )
731                ->setAttributes( [ 'data-mw-count' => $privateParticipantsCount ] )
732                ->appendContent( $text );
733            $privateParticipants = ( new Tag( 'div' ) )
734                ->addClasses( [ 'ext-campaignevents-eventdetails-participants-private-count-footer' ] )
735                ->appendContent( $icon, $textElement );
736            $footer->appendContent( $privateParticipants );
737        }
738
739        if (
740            $event->getParticipantQuestions() &&
741            $this->isPastEvent &&
742            $userCanViewNonPIIParticipantsData
743        ) {
744            $deletedNonPiiInfoNoticeElement = new MessageWidget( [
745                'type' => 'notice',
746                'label' => new HtmlSnippet(
747                    $out->msg( 'campaignevents-event-details-participants-individual-data-deleted' )
748                        ->params( $this->statisticsTabUrl )->parse()
749                ),
750                'inline' => true
751            ] );
752            $deletedNonPiiInfoNoticeElement->addClasses(
753                [ 'ext-campaignevents-eventdetails-participants-individual-data-deleted-notice' ]
754            );
755
756            $footer->appendContent( $deletedNonPiiInfoNoticeElement );
757        }
758        return $footer;
759    }
760
761    /**
762     * @param bool $viewerCanRemoveParticipants
763     * @param bool $viewerCanEmailParticipants
764     * @return Tag
765     */
766    private function getHeaderControls(
767        bool $viewerCanRemoveParticipants,
768        bool $viewerCanEmailParticipants
769    ): Tag {
770        $container = ( new Tag( 'div' ) )->addClasses( [ 'ext-campaignevents-eventdetails-participants-controls' ] );
771        $deselectButton = new ButtonWidget( [
772            'icon' => 'close',
773            'title' => $this->msgFormatter->format(
774                MessageValue::new( 'campaignevents-event-details-participants-deselect' )
775            ),
776            'framed' => false,
777            'flags' => [ 'progressive' ],
778            'infusable' => true,
779            'label' => $this->msgFormatter->format(
780                MessageValue::new( 'campaignevents-event-details-participants-checkboxes-selected', [ 0, 0 ] )
781            ),
782            'classes' => [ 'ext-campaignevents-eventdetails-participants-count-button' ]
783        ] );
784        $container->appendContent( [ $deselectButton ] );
785
786        $extraButtons = [];
787        if ( $viewerCanRemoveParticipants ) {
788            $extraButtons[] = new ButtonWidget( [
789                'infusable' => true,
790                'framed' => true,
791                'flags' => [
792                    'destructive'
793                ],
794                'label' => $this->msgFormatter->format(
795                    MessageValue::new( 'campaignevents-event-details-remove-participant-remove-btn' )
796                ),
797                'id' => 'ext-campaignevents-event-details-remove-participant-button',
798                'classes' => [
799                    'ext-campaignevents-event-details-remove-participant-button',
800                    'ext-campaignevents-eventdetails-hide-element'
801                ],
802            ] );
803        }
804        if ( $viewerCanEmailParticipants ) {
805            $extraButtons[] = new ButtonWidget( [
806                'infusable' => true,
807                'framed' => true,
808                'label' => $this->msgFormatter->format(
809                    MessageValue::new( 'campaignevents-event-details-message-all' )
810                ),
811                'flags' => [ 'progressive' ],
812                'classes' => [ 'ext-campaignevents-eventdetails-message-all-participants-button' ],
813            ] );
814        }
815
816        if ( $extraButtons ) {
817            $container->appendContent( new ButtonGroupWidget( [
818                'items' => $extraButtons,
819                'classes' => [ 'ext-campaignevents-eventdetails-extra-buttons' ],
820            ] ) );
821        }
822        return $container;
823    }
824}