Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 287
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventDetailsModule
0.00% covered (danger)
0.00%
0 / 287
0.00% covered (danger)
0.00%
0 / 9
1332
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 createContent
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
2
 getHeader
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 getInfoColumn
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
72
 getOrganizersColumn
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
12
 getLocationSection
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
132
 getTrackingToolsSection
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
56
 getFooter
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 makeSection
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\FrontendModules;
6
7use Language;
8use LogicException;
9use MediaWiki\DAO\WikiAwareEntity;
10use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
11use MediaWiki\Extension\CampaignEvents\Hooks\CampaignEventsHookRunner;
12use MediaWiki\Extension\CampaignEvents\MWEntity\PageURLResolver;
13use MediaWiki\Extension\CampaignEvents\MWEntity\UserLinker;
14use MediaWiki\Extension\CampaignEvents\Organizers\OrganizersStore;
15use MediaWiki\Extension\CampaignEvents\Special\SpecialEditEventRegistration;
16use MediaWiki\Extension\CampaignEvents\Special\SpecialEventDetails;
17use MediaWiki\Extension\CampaignEvents\Time\EventTimeFormatter;
18use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolAssociation;
19use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolRegistry;
20use MediaWiki\Extension\CampaignEvents\Utils;
21use MediaWiki\Html\Html;
22use MediaWiki\Linker\Linker;
23use MediaWiki\Output\OutputPage;
24use MediaWiki\SpecialPage\SpecialPage;
25use MediaWiki\User\UserIdentity;
26use MediaWiki\WikiMap\WikiMap;
27use OOUI\ButtonWidget;
28use OOUI\HtmlSnippet;
29use OOUI\IconWidget;
30use OOUI\MessageWidget;
31use OOUI\PanelLayout;
32use OOUI\Tag;
33use Wikimedia\Message\IMessageFormatterFactory;
34use Wikimedia\Message\ITextFormatter;
35use Wikimedia\Message\MessageValue;
36
37class EventDetailsModule {
38    private const ORGANIZERS_LIMIT = 10;
39
40    public const MODULE_STYLES = [
41        'oojs-ui.styles.icons-location',
42        'oojs-ui.styles.icons-interactions',
43        'oojs-ui.styles.icons-editing-core',
44        'oojs-ui.styles.icons-alerts',
45        'oojs-ui.styles.icons-user',
46        'oojs-ui.styles.icons-media',
47    ];
48
49    private OrganizersStore $organizersStore;
50    private PageURLResolver $pageURLResolver;
51    private UserLinker $userLinker;
52    private EventTimeFormatter $eventTimeFormatter;
53    private TrackingToolRegistry $trackingToolRegistry;
54
55    private ExistingEventRegistration $registration;
56    private Language $language;
57    private ITextFormatter $msgFormatter;
58    private CampaignEventsHookRunner $hookRunner;
59
60    /**
61     * @param IMessageFormatterFactory $messageFormatterFactory
62     * @param OrganizersStore $organizersStore
63     * @param PageURLResolver $pageURLResolver
64     * @param UserLinker $userLinker
65     * @param EventTimeFormatter $eventTimeFormatter
66     * @param TrackingToolRegistry $trackingToolRegistry
67     * @param CampaignEventsHookRunner $hookRunner
68     * @param ExistingEventRegistration $registration
69     * @param Language $language
70     */
71    public function __construct(
72        IMessageFormatterFactory $messageFormatterFactory,
73        OrganizersStore $organizersStore,
74        PageURLResolver $pageURLResolver,
75        UserLinker $userLinker,
76        EventTimeFormatter $eventTimeFormatter,
77        TrackingToolRegistry $trackingToolRegistry,
78        CampaignEventsHookRunner $hookRunner,
79        ExistingEventRegistration $registration,
80        Language $language
81    ) {
82        $this->organizersStore = $organizersStore;
83        $this->pageURLResolver = $pageURLResolver;
84        $this->userLinker = $userLinker;
85        $this->eventTimeFormatter = $eventTimeFormatter;
86        $this->trackingToolRegistry = $trackingToolRegistry;
87
88        $this->hookRunner = $hookRunner;
89        $this->registration = $registration;
90        $this->language = $language;
91        $this->msgFormatter = $messageFormatterFactory->getTextFormatter( $language->getCode() );
92    }
93
94    /**
95     * @param UserIdentity $viewingUser
96     * @param bool $isOrganizer
97     * @param bool $isParticipant
98     * @param string|false $wikiID
99     * @param OutputPage $out
100     * @return PanelLayout
101     *
102     * @note Ideally, this wouldn't use MW-specific classes for l10n, but it's hard-ish to avoid and
103     * probably not worth doing.
104     */
105    public function createContent(
106        UserIdentity $viewingUser,
107        bool $isOrganizer,
108        bool $isParticipant,
109        $wikiID,
110        OutputPage $out
111    ): PanelLayout {
112        $eventID = $this->registration->getID();
113        $isLocalWiki = $wikiID === WikiAwareEntity::LOCAL;
114        $organizersCount = $this->organizersStore->getOrganizerCountForEvent( $eventID );
115
116        $header = $this->getHeader( $isOrganizer, $isLocalWiki );
117
118        $contentWrapper = ( new Tag( 'div' ) )
119            ->addClasses( [ 'ext-campaignevents-eventdetails-content-wrapper' ] );
120
121        $infoColumn = $this->getInfoColumn(
122            $viewingUser,
123            $out,
124            $isOrganizer,
125            $isParticipant,
126            $isLocalWiki,
127            $wikiID,
128            $organizersCount
129        );
130        $organizersColumn = $this->getOrganizersColumn( $out, $organizersCount );
131
132        $this->hookRunner->onCampaignEventsGetEventDetails(
133            $infoColumn,
134            $organizersColumn,
135            $eventID,
136            $isOrganizer,
137            $out,
138            $isLocalWiki
139        );
140
141        $contentWrapper->appendContent( $infoColumn, $organizersColumn );
142
143        $footer = $this->getFooter();
144        return new PanelLayout( [
145            'content' => [ $header, $contentWrapper, $footer ],
146            'padded' => true,
147            'framed' => true,
148            'expanded' => false,
149            'classes' => [ 'ext-campaignevents-eventdetails-panel' ],
150        ] );
151    }
152
153    /**
154     * @param bool $isOrganizer
155     * @param bool $isLocalWiki
156     * @return Tag
157     */
158    private function getHeader( bool $isOrganizer, bool $isLocalWiki ): Tag {
159        $headerItems = [];
160        $headerItems[] = ( new Tag( 'h2' ) )->appendContent(
161            $this->msgFormatter->format(
162                MessageValue::new( 'campaignevents-event-details-label' )
163            )
164        );
165
166        if ( $isOrganizer && $isLocalWiki ) {
167            $headerItems[] = new ButtonWidget( [
168                'label' => $this->msgFormatter->format(
169                    MessageValue::new( 'campaignevents-event-details-edit-button' )
170                ),
171                'href' => SpecialPage::getTitleFor(
172                    SpecialEditEventRegistration::PAGE_NAME,
173                    (string)$this->registration->getID()
174                )->getLocalURL(),
175                'icon' => 'edit'
176            ] );
177        }
178
179        return ( new Tag( 'div' ) )
180            ->appendContent( $headerItems )
181            ->addClasses( [ 'ext-campaignevents-event-details-info-topbar' ] );
182    }
183
184    /**
185     * @param UserIdentity $viewingUser
186     * @param OutputPage $out
187     * @param bool $isOrganizer
188     * @param bool $isParticipant
189     * @param bool $isLocalWiki
190     * @param string|false $wikiID
191     * @param int $organizersCount
192     * @return Tag
193     */
194    private function getInfoColumn(
195        UserIdentity $viewingUser,
196        OutputPage $out,
197        bool $isOrganizer,
198        bool $isParticipant,
199        bool $isLocalWiki,
200        $wikiID,
201        int $organizersCount
202    ): Tag {
203        $items = [];
204
205        $formattedStart = $this->eventTimeFormatter->formatStart( $this->registration, $this->language, $viewingUser );
206        $formattedEnd = $this->eventTimeFormatter->formatEnd( $this->registration, $this->language, $viewingUser );
207        $datesMsg = $this->msgFormatter->format(
208            MessageValue::new( 'campaignevents-event-details-dates' )->params(
209                $formattedStart->getTimeAndDate(),
210                $formattedStart->getDate(),
211                $formattedStart->getTime(),
212                $formattedEnd->getTimeAndDate(),
213                $formattedEnd->getDate(),
214                $formattedEnd->getTime()
215            )
216        );
217        $formattedTimezone = $this->eventTimeFormatter->formatTimezone( $this->registration, $viewingUser );
218        // XXX Can't use $this->msgFormatter due to parse()
219        $timezoneMsg = $out->msg( 'campaignevents-event-details-timezone' )->params( $formattedTimezone )->parse();
220        $items[] = self::makeSection(
221            'clock',
222            [
223                $datesMsg,
224                ( new Tag( 'div' ) )->appendContent( new HtmlSnippet( $timezoneMsg ) )
225            ],
226            $this->msgFormatter->format( MessageValue::new( 'campaignevents-event-details-dates-label' ) )
227        );
228
229        $needToRegisterMsg = $this->msgFormatter->format(
230            MessageValue::new( 'campaignevents-event-details-register-prompt' )
231        );
232
233        $wikiName = WikiMap::getWikiName( Utils::getWikiIDString( $wikiID ) );
234        $foreignDetailsURL = WikiMap::getForeignURL(
235            $wikiID, 'Special:' . SpecialEventDetails::PAGE_NAME . "/{$this->registration->getID()}"
236        );
237        $needToBeOnLocalWikiMessage = new HtmlSnippet(
238            $out->msg( 'campaignevents-event-details-not-local-wiki-prompt' )
239                ->params( [
240                    $foreignDetailsURL, $wikiName
241                ] )->parse()
242        );
243        $items[] = $this->getLocationSection(
244            $isOrganizer,
245            $isParticipant,
246            $isLocalWiki,
247            $organizersCount,
248            $needToRegisterMsg,
249            $needToBeOnLocalWikiMessage
250        );
251
252        $trackingToolsSection = $this->getTrackingToolsSection();
253        if ( $trackingToolsSection ) {
254            $items[] = $trackingToolsSection;
255        }
256
257        $chatURL = $this->registration->getChatURL();
258        if ( $chatURL ) {
259            if ( ( $isOrganizer || $isParticipant ) && !$isLocalWiki ) {
260                $chatSectionContent = $needToBeOnLocalWikiMessage;
261            } elseif ( $isOrganizer || $isParticipant ) {
262                $chatSectionContent = new HtmlSnippet( Linker::makeExternalLink( $chatURL, $chatURL ) );
263            } else {
264                $chatSectionContent = $needToRegisterMsg;
265            }
266        } else {
267            $chatSectionContent = $this->msgFormatter->format(
268                MessageValue::new( 'campaignevents-event-details-chat-link-not-available' )
269            );
270        }
271
272        $items[] = self::makeSection(
273            'speechBubbles',
274            $chatSectionContent,
275            $this->msgFormatter->format( MessageValue::new( 'campaignevents-event-details-chat-link' ) )
276        );
277
278        return ( new Tag( 'div' ) )
279            ->appendContent( $items );
280    }
281
282    /**
283     * @param OutputPage $out
284     * @param int $organizersCount
285     * @return Tag
286     */
287    private function getOrganizersColumn( OutputPage $out, int $organizersCount ): Tag {
288        $ret = [];
289
290        $partialOrganizers = $this->organizersStore->getEventOrganizers(
291            $this->registration->getID(),
292            self::ORGANIZERS_LIMIT
293        );
294        $organizerListItems = '';
295        $lastOrganizerID = null;
296        foreach ( $partialOrganizers as $organizer ) {
297            $organizerListItems .= Html::rawElement(
298                'li',
299                [],
300                $this->userLinker->generateUserLinkWithFallback( $organizer->getUser(), $this->language->getCode() )
301            );
302            $lastOrganizerID = $organizer->getOrganizerID();
303        }
304        $out->addJsConfigVars( [
305            'wgCampaignEventsLastOrganizerID' => $lastOrganizerID,
306        ] );
307        $organizersList = Html::rawElement(
308            'ul',
309            [ 'class' => 'ext-campaignevents-event-details-organizers-list' ],
310            $organizerListItems
311        );
312        $ret[] = new HtmlSnippet( $organizersList );
313
314        if ( count( $partialOrganizers ) < $organizersCount ) {
315            $viewMoreBtn = new ButtonWidget( [
316                'label' => $this->msgFormatter->format(
317                    MessageValue::new( 'campaignevents-event-details-organizers-view-more' )
318                ),
319                'classes' => [ 'ext-campaignevents-event-details-load-organizers-link' ],
320                'framed' => false,
321                'flags' => [ 'progressive' ]
322            ] );
323            $viewMoreNoscript = ( new Tag( 'noscript' ) )
324                ->appendContent(
325                    $this->msgFormatter->format(
326                        MessageValue::new( 'campaignevents-event-details-organizers-noscript' )
327                    )
328                );
329            $ret[] = ( new Tag( 'p' ) )->appendContent( $viewMoreBtn, $viewMoreNoscript );
330        }
331
332        $organizersSection = self::makeSection(
333            'userRights',
334            $ret,
335            $this->msgFormatter->format( MessageValue::new( 'campaignevents-event-details-organizers-header' ) )
336        );
337
338        return ( new Tag( 'div' ) )->appendContent( $organizersSection );
339    }
340
341    /**
342     * @param bool $isOrganizer
343     * @param bool $isParticipant
344     * @param bool $isLocalWiki
345     * @param int $organizersCount
346     * @param string $needToRegisterMsg
347     * @param HtmlSnippet $needToBeOnLocalWikiMessage
348     * @return Tag
349     */
350    private function getLocationSection(
351        bool $isOrganizer,
352        bool $isParticipant,
353        bool $isLocalWiki,
354        int $organizersCount,
355        string $needToRegisterMsg,
356        HtmlSnippet $needToBeOnLocalWikiMessage
357    ): Tag {
358        $meetingType = $this->registration->getMeetingType();
359        $items = [];
360        if ( $meetingType & ExistingEventRegistration::MEETING_TYPE_IN_PERSON ) {
361            $items[] = ( new Tag( 'h4' ) )
362                ->appendContent( $this->msgFormatter->format(
363                    MessageValue::new( 'campaignevents-event-details-in-person-event-label' )
364                ) );
365
366            $rawAddress = $this->registration->getMeetingAddress();
367            $rawCountry = $this->registration->getMeetingCountry();
368            if ( $rawAddress || $rawCountry ) {
369                // NOTE: This is not pretty if exactly one of address and country is specified, but
370                // that's going to be fixed when we switch to using an actual geocoding service (T309325)
371                $address = $rawAddress . "\n" . $rawCountry;
372                $items[] = ( new Tag( 'div' ) )
373                    ->appendContent( $address )
374                    ->setAttributes( [ 'dir' => Utils::guessStringDirection( $address ) ] );
375            } else {
376                $items[] = ( new Tag( 'div' ) )
377                    ->appendContent(
378                        $this->msgFormatter->format(
379                            MessageValue::new( 'campaignevents-event-details-venue-not-available' )
380                                ->numParams( $organizersCount )
381                        )
382                    );
383            }
384        }
385
386        if ( $meetingType & ExistingEventRegistration::MEETING_TYPE_ONLINE ) {
387            $items[] = ( new Tag( 'h4' ) )
388                ->appendContent( $this->msgFormatter->format(
389                    MessageValue::new( 'campaignevents-event-details-online-label' )
390                ) );
391
392            $meetingURL = $this->registration->getMeetingURL();
393            if ( $meetingURL ) {
394                if ( ( $isOrganizer || $isParticipant ) && !$isLocalWiki ) {
395                    $items[] = $needToBeOnLocalWikiMessage;
396                } elseif ( $isOrganizer || $isParticipant ) {
397                    $items[] = new HtmlSnippet( Linker::makeExternalLink( $meetingURL, $meetingURL ) );
398                } else {
399                    $items[] = $needToRegisterMsg;
400                }
401            } else {
402                $items[] = $this->msgFormatter->format(
403                    MessageValue::new( 'campaignevents-event-details-online-link-not-available' )
404                        ->numParams( $organizersCount )
405                );
406            }
407        }
408
409        return self::makeSection(
410            'mapPin',
411            $items,
412            $this->msgFormatter->format( MessageValue::new( 'campaignevents-event-details-location-header' ) )
413        );
414    }
415
416    /**
417     * @return Tag|null
418     */
419    private function getTrackingToolsSection(): ?Tag {
420        $trackingTools = $this->registration->getTrackingTools();
421
422        if ( !$trackingTools ) {
423            return null;
424        }
425
426        if ( count( $trackingTools ) > 1 ) {
427            throw new LogicException( "Not expecting more than one tool" );
428        }
429        $toolAssoc = $trackingTools[0];
430        $toolUserInfo = $this->trackingToolRegistry->getUserInfo(
431            $toolAssoc->getToolID(),
432            $toolAssoc->getToolEventID()
433        );
434        if ( $toolUserInfo['user-id'] !== 'wikimedia-pe-dashboard' ) {
435            throw new LogicException( "Only the P&E Dashboard should be available as a tool for now" );
436        }
437
438        $syncStatus = $toolAssoc->getSyncStatus();
439        $lastSyncTS = $toolAssoc->getLastSyncTimestamp();
440        if ( $syncStatus === TrackingToolAssociation::SYNC_STATUS_UNKNOWN || $lastSyncTS === null ) {
441            // Maybe the tool is being added right now. But this shouldn't even happen, as
442            // UNKNOWN should currently only be used as a temporary placeholder within a single
443            // request. At any rate, skip.
444            return null;
445        }
446
447        $sectionItems = [];
448
449        $courseURL = $toolUserInfo['tool-event-url'];
450        $sectionItems[] = new HtmlSnippet( Linker::makeExternalLink( $courseURL, $courseURL ) );
451
452        if ( $syncStatus === TrackingToolAssociation::SYNC_STATUS_SYNCED ) {
453            $msgType = 'success';
454            $msgStatus = $this->msgFormatter->format(
455                MessageValue::new( 'campaignevents-event-details-tracking-tool-p&e-dashboard-synced' )
456            );
457            $msgLastSync = $this->msgFormatter->format(
458                MessageValue::new( 'campaignevents-event-details-tracking-tool-last-update' )
459                    ->dateTimeParams( $lastSyncTS )
460                    ->dateParams( $lastSyncTS )
461                    ->timeParams( $lastSyncTS )
462            );
463
464        } else {
465            $msgType = 'error';
466            $msgStatus = $this->msgFormatter->format(
467                MessageValue::new( 'campaignevents-event-details-tracking-tool-p&e-dashboard-desynced' )
468            );
469            $msgLastSync = $this->msgFormatter->format(
470                MessageValue::new( 'campaignevents-event-details-tracking-tool-last-successful-update' )
471                    ->dateTimeParams( $lastSyncTS )
472                    ->dateParams( $lastSyncTS )
473                    ->timeParams( $lastSyncTS )
474            );
475        }
476        $syncDetailsRawParagraph = ( new Tag( 'p' ) )
477            ->addClasses( [ 'ext-campaignevents-event-details-tracking-tool-sync-details' ] )
478            ->appendContent( htmlspecialchars( $msgLastSync ) );
479        $sectionItems[] = new MessageWidget( [
480            'type' => $msgType,
481            'label' => new HtmlSnippet( htmlspecialchars( $msgStatus ) . $syncDetailsRawParagraph ),
482            'classes' => [ 'ext-campaignevents-event-details-tracking-tool-sync' ],
483            'inline' => true,
484        ] );
485
486        return self::makeSection(
487            'chart',
488            $sectionItems,
489            $this->msgFormatter->format( MessageValue::new( $toolUserInfo['display-name-msg'] ) )
490        );
491    }
492
493    private function getFooter(): Tag {
494        return new ButtonWidget( [
495            'flags' => [ 'progressive' ],
496            'label' => $this->msgFormatter->format(
497                MessageValue::new( 'campaignevents-event-details-view-event-page' )
498            ),
499            'classes' => [ 'ext-campaignevents-event-details-view-event-page-button' ],
500            'href' => $this->pageURLResolver->getUrl( $this->registration->getPage() )
501        ] );
502    }
503
504    /**
505     * Builds a section for the info panel. This method might be called in handlers of the
506     * CampaignEventsGetEventDetails hook.
507     *
508     * @param string $icon
509     * @param string|Tag|array $content
510     * @param string $label
511     * @return Tag
512     */
513    public static function makeSection( string $icon, $content, string $label ): Tag {
514        $iconWidget = new IconWidget( [
515            'icon' => $icon,
516            'classes' => [ 'ext-campaignevents-event-details-icon' ]
517        ] );
518        $header = ( new Tag( 'h3' ) )
519            ->appendContent( $iconWidget, $label )
520            ->addClasses( [ 'ext-campaignevents-event-details-section-header' ] );
521
522        $contentTag = ( new Tag( 'div' ) )
523            ->appendContent( $content )
524            ->addClasses( [ 'ext-campaignevents-event-details-section-content' ] );
525
526        return ( new Tag( 'div' ) )
527            ->appendContent( $header, $contentTag );
528    }
529}