Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 188
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Impact
0.00% covered (danger)
0.00%
0 / 188
0.00% covered (danger)
0.00%
0 / 24
1406
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getJsConfigVars
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getUnactivatedModuleCssClass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCssClasses
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isOwnData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldShowForOtherUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getHeaderText
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getBody
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getMobileSummaryBody
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 getScoreCardMarkup
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getScoreCardsMarkup
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getRecentActivityMarkup
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
2
 getArticlesListMarkup
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
2
 getBaseMarkup
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderIconName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setUserDataIsFor
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getState
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 isUnactivated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getJsData
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getActionData
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getUserImpact
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getFormattedUserImpact
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 hasMainspaceEdits
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace GrowthExperiments\HomepageModules;
4
5use Exception;
6use GrowthExperiments\ExperimentUserManager;
7use GrowthExperiments\UserDatabaseHelper;
8use GrowthExperiments\UserImpact\ComputedUserImpactLookup;
9use GrowthExperiments\UserImpact\ExpensiveUserImpact;
10use GrowthExperiments\UserImpact\UserImpactFormatter;
11use GrowthExperiments\UserImpact\UserImpactStore;
12use MediaWiki\Config\Config;
13use MediaWiki\Context\IContextSource;
14use MediaWiki\Html\Html;
15use MediaWiki\User\UserIdentity;
16
17/**
18 * Class for the Impact module.
19 */
20class Impact extends BaseModule {
21
22    private UserIdentity $userIdentity;
23    private UserImpactStore $userImpactStore;
24    private UserImpactFormatter $userImpactFormatter;
25    private UserDatabaseHelper $userDatabaseHelper;
26    private bool $isSuggestedEditsEnabledForUser;
27    private bool $isSuggestedEditsActivatedForUser;
28
29    /** @var ExpensiveUserImpact|null|false Lazy-loaded if false */
30    private $userImpact = false;
31    /** @var array|null|false Lazy-loaded if false */
32    private $formattedUserImpact = false;
33    private bool $forceShowingForOther = false;
34    private ?array $hasMainspaceEditsCache = null;
35
36    /**
37     * @param IContextSource $ctx
38     * @param Config $wikiConfig
39     * @param ExperimentUserManager $experimentUserManager
40     * @param UserIdentity $userIdentity
41     * @param UserImpactStore $userImpactStore
42     * @param UserImpactFormatter $userImpactFormatter
43     * @param UserDatabaseHelper $userDatabaseHelper
44     * @param bool $isSuggestedEditsEnabled
45     * @param bool $isSuggestedEditsActivated
46     */
47    public function __construct(
48        IContextSource $ctx,
49        Config $wikiConfig,
50        ExperimentUserManager $experimentUserManager,
51        UserIdentity $userIdentity,
52        UserImpactStore $userImpactStore,
53        UserImpactFormatter $userImpactFormatter,
54        UserDatabaseHelper $userDatabaseHelper,
55        bool $isSuggestedEditsEnabled,
56        bool $isSuggestedEditsActivated
57    ) {
58        parent::__construct( 'impact', $ctx, $wikiConfig, $experimentUserManager );
59        $this->userIdentity = $userIdentity;
60        $this->userImpactStore = $userImpactStore;
61        $this->userImpactFormatter = $userImpactFormatter;
62        $this->userDatabaseHelper = $userDatabaseHelper;
63        $this->isSuggestedEditsEnabledForUser = $isSuggestedEditsEnabled;
64        $this->isSuggestedEditsActivatedForUser = $isSuggestedEditsActivated;
65    }
66
67    /** @inheritDoc */
68    protected function getJsConfigVars() {
69        return [
70            'GEImpactRelevantUserName' => $this->userIdentity->getName(),
71            'GEImpactRelevantUserId' => $this->userIdentity->getId(),
72            'GEImpactRelevantUserUnactivated' => $this->isUnactivated(),
73            'GEImpactThirdPersonRender' => $this->shouldShowForOtherUser(),
74            'GEImpactIsSuggestedEditsEnabledForUser' => $this->isSuggestedEditsEnabledForUser,
75            'GEImpactIsSuggestedEditsActivatedForUser' => $this->isSuggestedEditsActivatedForUser,
76        ];
77    }
78
79    /**
80     * @return string
81     */
82    private function getUnactivatedModuleCssClass() {
83        // The following classes are used here:
84        // * growthexperiments-homepage-module-impact-unactivated-desktop
85        // * growthexperiments-homepage-module-impact-unactivated-mobile-details
86        // * growthexperiments-homepage-module-impact-unactivated-mobile-overlay
87        // * growthexperiments-homepage-module-impact-unactivated-mobile-summary
88        return 'growthexperiments-homepage-module-impact-unactivated-' . $this->getMode();
89    }
90
91    /**
92     * @inheritDoc
93     */
94    protected function getCssClasses() {
95        $unactivatedClasses = [];
96        if ( $this->isUnactivated() ) {
97            $unactivatedClasses[] = $this->getUnactivatedModuleCssClass();
98        }
99        return array_merge( parent::getCssClasses(), $unactivatedClasses );
100    }
101
102    /**
103     * Check if the user requesting the data matches the user data is requested
104     *
105     * @return bool
106     */
107    private function isOwnData(): bool {
108        return $this->getContext()->getUser()->equals( $this->userIdentity );
109    }
110
111    /**
112     * Check if texts should show first person or third person
113     *
114     * @return bool
115     */
116    private function shouldShowForOtherUser(): bool {
117        return !$this->isOwnData() || $this->forceShowingForOther;
118    }
119
120    /** @inheritDoc */
121    protected function getHeaderText() {
122        $headerText = 'growthexperiments-homepage-impact-header';
123        if ( $this->shouldShowForOtherUser() ) {
124            $headerText = 'growthexperiments-specialimpact-showing-for-other-user';
125        }
126        return $this->getContext()
127            ->msg( $headerText )
128            ->params( $this->userIdentity->getName() )
129            ->text();
130    }
131
132    /** @inheritDoc */
133    protected function getBody() {
134        return Html::rawElement( 'div',
135                [
136                    'id' => 'impact-vue-root',
137                    'class' => 'ext-growthExperiments-impact-app-root'
138                ],
139                $this->getBaseMarkup()
140            ) .
141            Html::element( 'p',
142                [ 'class' => 'growthexperiments-homepage-impact-no-js-fallback' ],
143                $this->msg( 'growthexperiments-homepage-impact-no-js-fallback' )->text()
144            );
145    }
146
147    /** @inheritDoc */
148    protected function getMobileSummaryBody() {
149        return Html::rawElement( 'div',
150                [
151                    'id' => 'impact-vue-root--mobile',
152                    'class' => [
153                        'ext-growthExperiments-impact-app-root',
154                        'ext-growthExperiments-impact-app-root--mobile'
155                    ]
156                ],
157                $this->getRecentActivityMarkup()
158            ) .
159            Html::element( 'p',
160                [ 'class' => 'growthexperiments-homepage-impact-no-js-fallback' ],
161                $this->msg( 'growthexperiments-homepage-impact-no-js-fallback' )->text()
162            );
163    }
164
165    /**
166     * ScoreCard server markup. A wrapper using only top-level styles from ScoreCard.less.
167     *
168     * @param int $index Card index, not relevant at the moment.
169     * @return string HTML content of a scorecard
170     * @see modules/vue-components/CScoreCard.{less,vue}
171     */
172    private function getScoreCardMarkup( int $index ): string {
173        return Html::rawElement( 'div', [
174            'class' => 'ext-growthExperiments-ScoreCard'
175        ] );
176    }
177
178    /**
179     * ScoreCards server markup. A wrapper using only top-level styles from ScoreCards.less.
180     *
181     * @see modules/vue-components/CScoreCards.{less,vue}
182     * @return string HTML content of the scorecards section
183     */
184    private function getScoreCardsMarkup(): string {
185        return Html::rawElement( 'div',
186            [
187                'class' => 'ext-growthExperiments-ScoreCards'
188            ],
189            implode( '', array_map( [ $this, 'getScoreCardMarkup' ], [ 1, 2, 3, 4 ] ) )
190        );
191    }
192
193    /**
194     * RecentActivity server markup. Uses only styles from Skeleton.less, mimics
195     * RecentActivity.vue content
196     *
197     * @see modules/ext.growthExperiments.Homepage.Impact/components/RecentActivity.vue
198     * @return string HTML content of the recent activity section
199     */
200    private function getRecentActivityMarkup(): string {
201        return Html::rawElement( 'div', [],
202            Html::rawElement( 'div', [
203                'class' => [
204                    'ext-growthExperiments-Skeleton',
205                    'ext-growthExperiments-Skeleton--darken'
206                ]
207            ] ) .
208            Html::rawElement( 'div', [
209                'class' => [
210                    'ext-growthExperiments-Skeleton',
211                    'ext-growthExperiments-Skeleton--double'
212                ]
213            ] ) .
214            Html::rawElement( 'div', [
215                'class' => [
216                    'ext-growthExperiments-Skeleton',
217                    'ext-growthExperiments-Skeleton--triple'
218                ]
219            ] )
220        );
221    }
222
223    /**
224     * ArticlesList server markup. Uses only styles from Skeleton.less, Impact.less and App.less.
225     *
226     * @param int $numberOfArticles The number of article skeletons to render
227     * @return string HTML content of the articles list section
228     * @see modules/ext.growthExperiments.Homepage.Impact/components/{App,Impact}.less
229     */
230    private function getArticlesListMarkup( int $numberOfArticles = 5 ): string {
231        // Articles list
232        return Html::rawElement( 'div', [],
233            Html::rawElement( 'div', [
234                'class' => [
235                    'ext-growthExperiments-ArticleListHeading',
236                    'ext-growthExperiments-Skeleton',
237                    'ext-growthExperiments-Skeleton--darken'
238                ]
239            ] ) .
240            implode( "\n", array_map(
241                    static function ( $index ) {
242                        // Article animation delay starting at 400ms and increased 200ms for each article
243                        $delay = 400 + ( $index * 200 );
244                        return Html::rawElement( 'div', [
245                            'class' => [
246                                'ext-growthExperiments-ArticleLoading'
247                            ]
248                        ],
249                            Html::rawElement( 'div', [
250                                'class' => [
251                                    'ext-growthExperiments-ArticleLoading__image',
252                                    'ext-growthExperiments-Skeleton',
253                                    'ext-growthExperiments-Skeleton--delay-' . $delay
254                                ]
255                            ] ) .
256                            Html::rawElement( 'div', [
257                                'class' => [
258                                    'ext-growthExperiments-ArticleLoading__text',
259                                    'ext-growthExperiments-Skeleton',
260                                    'ext-growthExperiments-Skeleton--darken',
261                                    'ext-growthExperiments-Skeleton--delay-' . $delay
262                                ]
263                            ] )
264                        );
265                    }, array_keys( array_fill( 0, $numberOfArticles, 1 ) ) )
266            )
267        );
268    }
269
270    /**
271     * Impact application server markup. Does not use any styles from the CSS classes added.
272     * Should be kept in sync with Vue application component tree (App.vue > Layout.vue > Impact.vue).
273     *
274     * @see modules/ext.growthExperiments.Homepage.Impact/components/Impact.less
275     * @return string HTML content of the recent activity section
276     */
277    private function getBaseMarkup(): string {
278        return Html::rawElement( 'div',
279            [
280                'class' => 'ext-growthExperiments-App--UserImpact'
281            ],
282            Html::rawElement( 'div',
283                [
284                    // The following classes are generated here:
285                    // * ext-growthExperiments-Layout--desktop
286                    // * ext-growthExperiments-Layout--mobile-details
287                    // * ext-growthExperiments-Layout--mobile-overlay
288                    // * ext-growthExperiments-Layout--mobile-summary
289                    'class' => 'ext-growthExperiments-Layout--' . $this->getMode()
290                ],
291                Html::rawElement( 'div',
292                    [
293                        'class' => 'ext-growthExperiments-Impact'
294                    ],
295                    Html::rawElement( 'div',
296                        [],
297                        $this->getScoreCardsMarkup() .
298                        $this->getRecentActivityMarkup() .
299                        $this->getArticlesListMarkup()
300                    )
301                )
302            )
303        );
304    }
305
306    /** @inheritDoc */
307    protected function getHeaderIconName() {
308        return 'chart';
309    }
310
311    /** @inheritDoc */
312    protected function getModules() {
313        return [ 'ext.growthExperiments.Homepage.Impact' ];
314    }
315
316    /**
317     * Set the relevant user to return data for. This will also force the module to display third person texts even if
318     * the user requesting it is the same the data is requested
319     *
320     * @param UserIdentity $user
321     */
322    public function setUserDataIsFor( UserIdentity $user ) {
323        $this->userIdentity = $user;
324        $this->forceShowingForOther = true;
325    }
326
327    /**
328     * @inheritDoc
329     */
330    public function getState() {
331        if ( ( $this->canRender()
332            // On null (first 1000 edits are non-mainspace) assume rest are non-mainspace as well
333            // (chances are it's some kind of bot or role account).
334            && $this->hasMainspaceEdits() )
335            // Always show the module activated when a user is looking to another user data
336            || $this->shouldShowForOtherUser()
337        ) {
338            return self::MODULE_STATE_ACTIVATED;
339        }
340        return self::MODULE_STATE_UNACTIVATED;
341    }
342
343    /**
344     * Check if impact module is unactivated.
345     *
346     * @return bool
347     */
348    private function isUnactivated(): bool {
349        return $this->getState() === self::MODULE_STATE_UNACTIVATED;
350    }
351
352    /** @inheritDoc */
353    public function getJsData( $mode ) {
354        $data = parent::getJsData( $mode );
355        $userImpact = $this->getUserImpact();
356        $formattedUserImpact = $this->getFormattedUserImpact();
357        // If the impact data's page view information is considered to be stale, then don't export
358        // it here. The client-side app's request will be able to get a fresh data generation, and
359        // it's ok for that to take longer. We wouldn't want to have the user wait here, though, as
360        // this blocks page render.
361        if ( !$userImpact || $userImpact->isPageViewDataStale() ) {
362            $data['impact'] = null;
363        } else {
364            $data['impact'] = $formattedUserImpact;
365        }
366        return $data;
367    }
368
369    /**
370     * @inheritDoc
371     */
372    public function getActionData(): array {
373        $userImpact = $this->getUserImpact();
374        $data = [
375            'no_cached_user_impact' => !$userImpact
376        ];
377        if ( $userImpact ) {
378            $formattedUserImpact = $this->getFormattedUserImpact();
379            $data = [
380                'timeframe_in_days' => ComputedUserImpactLookup::PAGEVIEW_DAYS,
381                'timeframe_edits_count' => $userImpact->getTotalEditsCount(),
382                'thanks_count' => $userImpact->getReceivedThanksCount(),
383                'last_edit_timestamp' => $userImpact->getLastEditTimestamp(),
384                'longest_streak_days_count' => $userImpact->getLongestEditingStreakCount(),
385                'top_articles_views_count' => $formattedUserImpact['topViewedArticlesCount'],
386                'total_pageviews_count' => $formattedUserImpact['totalPageviewsCount'],
387            ];
388        }
389        return array_merge( parent::getActionData(), $data );
390    }
391
392    /**
393     * Get user impact, with an in-process cache.
394     *
395     * @return ExpensiveUserImpact|null
396     */
397    private function getUserImpact(): ?ExpensiveUserImpact {
398        if ( $this->userImpact !== false ) {
399            return $this->userImpact;
400        }
401        $this->userImpact = $this->userImpactStore->getExpensiveUserImpact( $this->userIdentity );
402        return $this->userImpact;
403    }
404
405    /**
406     * Get the output of UserImpactFormatter::format(), with an in-process cache.
407     * @return array
408     * @throws Exception
409     */
410    private function getFormattedUserImpact(): array {
411        if ( $this->formattedUserImpact !== false ) {
412            return $this->formattedUserImpact;
413        }
414        $userImpact = $this->getUserImpact();
415        $this->formattedUserImpact = $userImpact ?
416            $this->userImpactFormatter->format( $userImpact, $this->getContext()->getLanguage()->getCode() ) :
417            [];
418        return $this->formattedUserImpact;
419    }
420
421    /** @return bool|null */
422    private function hasMainspaceEdits(): ?bool {
423        // The cache has four states: true/false/null (valid hasMainspaceEdits() return values)
424        // and uninitialized. Use an array hack to differentiate.
425        if ( !$this->hasMainspaceEditsCache ) {
426            $this->hasMainspaceEditsCache = [
427                $this->userDatabaseHelper->hasMainspaceEdits( $this->userIdentity ),
428            ];
429        }
430        return $this->hasMainspaceEditsCache[0];
431    }
432}