Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 449
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 / 449
0.00% covered (danger)
0.00%
0 / 14
4160
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
210
 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 / 61
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 / 29
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 Language;
8use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
9use MediaWiki\Extension\CampaignEvents\Messaging\CampaignsUserMailer;
10use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
11use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUserNotFoundException;
12use MediaWiki\Extension\CampaignEvents\MWEntity\HiddenCentralUserException;
13use MediaWiki\Extension\CampaignEvents\MWEntity\ICampaignsAuthority;
14use MediaWiki\Extension\CampaignEvents\MWEntity\UserLinker;
15use MediaWiki\Extension\CampaignEvents\MWEntity\UserNotGlobalException;
16use MediaWiki\Extension\CampaignEvents\Participants\Participant;
17use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore;
18use MediaWiki\Extension\CampaignEvents\Participants\UnregisterParticipantCommand;
19use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker;
20use MediaWiki\Extension\CampaignEvents\Questions\Answer;
21use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry;
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        try {
127            $centralUser = $this->centralUserLookup->newFromAuthority( $authority );
128            $curUserParticipant = $this->participantsStore->getEventParticipant( $eventID, $centralUser, true );
129        } catch ( UserNotGlobalException $_ ) {
130            $curUserParticipant = null;
131        }
132
133        $showPrivateParticipants = $isLocalWiki &&
134            $this->permissionChecker->userCanViewPrivateParticipants( $authority, $eventID );
135        $otherParticipantsNum = $curUserParticipant ? self::PARTICIPANTS_LIMIT - 1 : self::PARTICIPANTS_LIMIT;
136        $otherParticipants = $this->participantsStore->getEventParticipants(
137            $eventID,
138            $otherParticipantsNum,
139            null,
140            null,
141            null,
142            $showPrivateParticipants,
143            isset( $centralUser ) ? [ $centralUser->getCentralID() ] : null
144        );
145        $lastParticipant = $otherParticipants ? end( $otherParticipants ) : $curUserParticipant;
146        $lastParticipantID = $lastParticipant ? $lastParticipant->getParticipantID() : null;
147        $canRemoveParticipants = false;
148        if ( $isOrganizer && $isLocalWiki ) {
149            $canRemoveParticipants = UnregisterParticipantCommand::checkIsUnregistrationAllowed( $event ) ===
150                UnregisterParticipantCommand::CAN_UNREGISTER;
151        }
152
153        $canViewNonPIIParticipantsData = false;
154        if ( $isOrganizer && $isLocalWiki ) {
155            $canViewNonPIIParticipantsData = $this->permissionChecker->userCanViewNonPIIParticipantsData(
156                $authority, $event->getID()
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            // TODO This may change when we add the feature to send messages
190            'wgCampaignEventsShowParticipantCheckboxes' => $canRemoveParticipants,
191            'wgCampaignEventsShowPrivateParticipants' => $showPrivateParticipants,
192            'wgCampaignEventsEventDetailsParticipantsTotal' => $totalParticipants,
193            'wgCampaignEventsLastParticipantID' => $lastParticipantID,
194            'wgCampaignEventsCurUserCentralID' => isset( $centralUser ) ? $centralUser->getCentralID() : null,
195            'wgCampaignEventsViewerHasEmail' =>
196                $this->userFactory->newFromUserIdentity( $viewingUser )->isEmailConfirmed(),
197            'wgCampaignEventsNonPIIQuestionIDs' => $nonPIIQuestionIDs,
198        ] );
199
200        $layout = new PanelLayout( [
201            'content' => $items,
202            'padded' => false,
203            'framed' => true,
204            'expanded' => false,
205        ] );
206
207        $content = ( new Tag( 'div' ) )
208            ->addClasses( [ 'ext-campaignevents-event-details-participants-panel' ] )
209            ->appendContent( $layout );
210
211        $footer = $this->getFooter( $eventID, $canViewNonPIIParticipantsData, $event, $out );
212        if ( $footer ) {
213            $content->appendContent( $footer );
214        }
215
216        return $content;
217    }
218
219    /**
220     * @param ExistingEventRegistration $event
221     * @param int $totalParticipants
222     * @param bool $canRemoveParticipants
223     * @param bool $canEmailParticipants
224     * @param bool $canViewNonPIIParticipantsData
225     * @return Tag
226     */
227    private function getPrimaryHeader(
228        ExistingEventRegistration $event,
229        int $totalParticipants,
230        bool $canRemoveParticipants,
231        bool $canEmailParticipants,
232        bool $canViewNonPIIParticipantsData
233    ): Tag {
234        $participantCountText = $this->msgFormatter->format(
235            MessageValue::new( 'campaignevents-event-details-header-participants' )
236                ->numParams( $totalParticipants )
237        );
238        $participantsCountElement = ( new Tag( 'span' ) )
239            ->appendContent( $participantCountText )
240            ->addClasses( [ 'ext-campaignevents-details-participants-header-participant-count' ] );
241        $participantsElement = ( new Tag( 'div' ) )
242            ->appendContent( $participantsCountElement )
243            ->addClasses( [ 'ext-campaignevents-details-participants-header-participants' ] );
244        if (
245            $canViewNonPIIParticipantsData &&
246            !$this->isPastEvent &&
247            $event->getParticipantQuestions()
248        ) {
249            $questionsHelp = new ButtonWidget( [
250                'framed' => false,
251                'icon' => 'info',
252                'label' => $this->msgFormatter->format(
253                    MessageValue::new( 'campaignevents-event-details-header-questions-help' )
254                ),
255                'invisibleLabel' => true,
256                'classes' => [ 'ext-campaignevents-details-participants-header-questions-help' ]
257            ] );
258            $participantsElement->appendContent( $questionsHelp );
259        }
260        $headerTitle = ( new Tag( 'div' ) )
261            ->appendContent( $participantsElement )
262            ->addClasses( [ 'ext-campaignevents-details-participants-header-title' ] );
263        $header = ( new Tag( 'div' ) )->addClasses( [ 'ext-campaignevents-details-participants-header' ] );
264
265        if ( $totalParticipants ) {
266            $headerTitle->appendContent( $this->getSearchBar() );
267            $header->appendContent( $headerTitle );
268            $header->appendContent( $this->getHeaderControls( $canRemoveParticipants, $canEmailParticipants ) );
269        } else {
270            $header->appendContent( $headerTitle );
271        }
272
273        return $header;
274    }
275
276    /**
277     * @param UserIdentity $viewingUser
278     * @param bool $canRemoveParticipants
279     * @param bool $canEmailParticipants
280     * @param bool $canViewNonPIIParticipantsData
281     * @param Participant|null $curUserParticipant
282     * @param Participant[] $otherParticipants
283     * @param ICampaignsAuthority $authority
284     * @param ExistingEventRegistration $event
285     * @param int[] $nonPIIQuestionIDs
286     * @return Tag
287     */
288    private function getParticipantsTable(
289        UserIdentity $viewingUser,
290        bool $canRemoveParticipants,
291        bool $canEmailParticipants,
292        bool $canViewNonPIIParticipantsData,
293        ?Participant $curUserParticipant,
294        array $otherParticipants,
295        ICampaignsAuthority $authority,
296        ExistingEventRegistration $event,
297        array $nonPIIQuestionIDs
298    ): Tag {
299        // Use an outer container for the infinite scrolling
300        $container = ( new Tag( 'div' ) )
301            ->addClasses( [ 'ext-campaignevents-details-participants-container' ] );
302        $table = ( new Tag( 'table' ) )
303            ->addClasses( [ 'ext-campaignevents-details-participants-table' ] );
304
305        $table->appendContent( $this->getTableHeaders(
306                $canRemoveParticipants,
307                $canEmailParticipants,
308                $event,
309                $authority,
310                $nonPIIQuestionIDs,
311                $canViewNonPIIParticipantsData
312            )
313        );
314        $table->appendContent( $this->getParticipantRows(
315            $curUserParticipant,
316            $otherParticipants,
317            $canRemoveParticipants,
318            $canEmailParticipants,
319            $viewingUser,
320            $nonPIIQuestionIDs,
321            $canViewNonPIIParticipantsData
322        ) );
323        $container->appendContent( $table );
324        return $container;
325    }
326
327    /**
328     * @param int $totalParticipants
329     * @return Tag
330     */
331    private function getEmptyStateElement( int $totalParticipants ): Tag {
332        $noParticipantsIcon = new IconWidget( [
333            'icon' => 'userGroup',
334            'classes' => [ 'ext-campaignevents-event-details-no-participants-icon' ]
335        ] );
336
337        $noParticipantsClasses = [ 'ext-campaignevents-details-no-participants-state' ];
338        if ( $totalParticipants > 0 ) {
339            $noParticipantsClasses[] = 'ext-campaignevents-details-hide-element';
340        }
341        return ( new Tag() )->appendContent(
342            $noParticipantsIcon,
343            ( new Tag() )->appendContent(
344                $this->msgFormatter->format(
345                    MessageValue::new( 'campaignevents-event-details-no-participants-state' )
346                )
347            )->addClasses( [ 'ext-campaignevents-details-no-participants-description' ] )
348        )->addClasses( $noParticipantsClasses );
349    }
350
351    /**
352     * @return Tag
353     */
354    private function getSearchBar(): Tag {
355            return new SearchInputWidget( [
356                'placeholder' => $this->msgFormatter->format(
357                    MessageValue::new( 'campaignevents-event-details-search-participants-placeholder' )
358                ),
359                'infusable' => true,
360                'classes' => [ 'ext-campaignevents-details-participants-search' ]
361            ] );
362    }
363
364    /**
365     * @param bool $canRemoveParticipants
366     * @param bool $canEmailParticipants
367     * @param ExistingEventRegistration $event
368     * @param ICampaignsAuthority $authority
369     * @param array $nonPIIQuestionIDs
370     * @param bool $userCanViewNonPIIParticipantsData
371     * @return Tag
372     */
373    private function getTableHeaders(
374        bool $canRemoveParticipants,
375        bool $canEmailParticipants,
376        ExistingEventRegistration $event,
377        ICampaignsAuthority $authority,
378        array $nonPIIQuestionIDs,
379        bool $userCanViewNonPIIParticipantsData
380    ): Tag {
381        $container = ( new Tag( 'thead' ) )->addClasses( [ 'ext-campaignevents-details-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-details-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-details-participants-username-cell' ],
413            ],
414            [
415                'message' => $this->msgFormatter->format(
416                    MessageValue::new( 'campaignevents-event-details-time-registered' )
417                ),
418                'cssClasses' => [ 'ext-campaignevents-details-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-details-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-details-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-details-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-event-details-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-details-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-event-details-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                ->appendContent( $text );
731            $privateParticipants = ( new Tag( 'div' ) )
732                ->addClasses( [ 'ext-campaignevents-event-details-participants-private-count-footer' ] )
733                ->appendContent( $icon, $textElement );
734            // TODO The number should be updated dynamically when (private) participants are removed, see T322275.
735            $footer->appendContent( $privateParticipants );
736        }
737
738        if (
739            $event->getParticipantQuestions() &&
740            $this->isPastEvent &&
741            $userCanViewNonPIIParticipantsData
742        ) {
743            $deletedNonPiiInfoNoticeElement = new MessageWidget( [
744                'type' => 'notice',
745                'label' => new HtmlSnippet(
746                    $out->msg( 'campaignevents-event-details-participants-individual-data-deleted' )
747                        ->params( $this->statisticsTabUrl )->parse()
748                ),
749                'inline' => true
750            ] );
751            $deletedNonPiiInfoNoticeElement->addClasses(
752                [ 'ext-campaignevents-event-details-participants-individual-data-deleted-notice' ]
753            );
754
755            $footer->appendContent( $deletedNonPiiInfoNoticeElement );
756        }
757        return $footer;
758    }
759
760    /**
761     * @param bool $viewerCanRemoveParticipants
762     * @param bool $viewerCanEmailParticipants
763     * @return Tag
764     */
765    private function getHeaderControls(
766        bool $viewerCanRemoveParticipants,
767        bool $viewerCanEmailParticipants
768    ): Tag {
769        $container = ( new Tag( 'div' ) )->addClasses( [ 'ext-campaignevents-details-participants-controls' ] );
770        $deselectButton = new ButtonWidget( [
771            'icon' => 'close',
772            'title' => $this->msgFormatter->format(
773                MessageValue::new( 'campaignevents-event-details-participants-deselect' )
774            ),
775            'framed' => false,
776            'flags' => [ 'progressive' ],
777            'infusable' => true,
778            'label' => $this->msgFormatter->format(
779                MessageValue::new( 'campaignevents-event-details-participants-checkboxes-selected', [ 0, 0 ] )
780            ),
781            'classes' => [ 'ext-campaignevents-details-participants-count-button' ]
782        ] );
783        $container->appendContent( [ $deselectButton ] );
784
785        $extraButtons = [];
786        if ( $viewerCanRemoveParticipants ) {
787            $extraButtons[] = new ButtonWidget( [
788                'infusable' => true,
789                'framed' => true,
790                'flags' => [
791                    'destructive'
792                ],
793                'label' => $this->msgFormatter->format(
794                    MessageValue::new( 'campaignevents-event-details-remove-participant-remove-btn' )
795                ),
796                'id' => 'ext-campaignevents-event-details-remove-participant-button',
797                'classes' => [
798                    'ext-campaignevents-event-details-remove-participant-button',
799                    'ext-campaignevents-details-hide-element'
800                ],
801            ] );
802        }
803        if ( $viewerCanEmailParticipants ) {
804            $extraButtons[] = new ButtonWidget( [
805                'infusable' => true,
806                'framed' => true,
807                'label' => $this->msgFormatter->format(
808                    MessageValue::new( 'campaignevents-event-details-message-all' )
809                ),
810                'flags' => [ 'progressive' ],
811                'classes' => [ 'ext-campaignevents-event-details-message-all-participants-button' ],
812            ] );
813        }
814
815        if ( $extraButtons ) {
816            $container->appendContent( new ButtonGroupWidget( [
817                'items' => $extraButtons,
818                'classes' => [ 'ext-campaignevents-event-details-extra-buttons' ],
819            ] ) );
820        }
821        return $container;
822    }
823}