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