Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
32.71% |
87 / 266 |
|
6.67% |
1 / 15 |
CRAP | |
0.00% |
0 / 1 |
SpecialHomepage | |
32.71% |
87 / 266 |
|
6.67% |
1 / 15 |
1083.11 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
88.89% |
56 / 63 |
|
0.00% |
0 / 1 |
9.11 | |||
handleDisabledPreference | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
getDescription | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getModules | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
90 | |||
getModuleGroups | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
12 | |||
generatePageviewToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
renderDesktop | |
0.00% |
0 / 30 |
|
0.00% |
0 / 1 |
30 | |||
recordModuleRenderingTime | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
renderMobileDetails | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
renderMobileSummary | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
12 | |||
getModuleRenderHtmlSafe | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
outputJsData | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
42 | |||
handleNewcomerTask | |
49.06% |
26 / 53 |
|
0.00% |
0 / 1 |
31.04 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\Specials; |
4 | |
5 | use ErrorPageError; |
6 | use GrowthExperiments\DashboardModule\IDashboardModule; |
7 | use GrowthExperiments\EventLogging\SpecialHomepageLogger; |
8 | use GrowthExperiments\ExperimentUserManager; |
9 | use GrowthExperiments\Homepage\HomepageModuleRegistry; |
10 | use GrowthExperiments\HomepageHooks; |
11 | use GrowthExperiments\HomepageModules\BaseModule; |
12 | use GrowthExperiments\HomepageModules\SuggestedEdits; |
13 | use GrowthExperiments\Mentorship\MentorManager; |
14 | use GrowthExperiments\TourHooks; |
15 | use GrowthExperiments\Util; |
16 | use GrowthExperiments\VariantHooks; |
17 | use InvalidArgumentException; |
18 | use MediaWiki\Config\Config; |
19 | use MediaWiki\Config\ConfigException; |
20 | use MediaWiki\Deferred\DeferredUpdates; |
21 | use MediaWiki\Html\Html; |
22 | use MediaWiki\Logger\LoggerFactory; |
23 | use MediaWiki\Registration\ExtensionRegistry; |
24 | use MediaWiki\SpecialPage\SpecialPage; |
25 | use MediaWiki\Title\TitleFactory; |
26 | use MediaWiki\User\Options\UserOptionsManager; |
27 | use MediaWiki\WikiMap\WikiMap; |
28 | use Throwable; |
29 | use UserNotLoggedIn; |
30 | use Wikimedia\Stats\StatsFactory; |
31 | |
32 | class SpecialHomepage extends SpecialPage { |
33 | |
34 | private HomepageModuleRegistry $moduleRegistry; |
35 | private ExperimentUserManager $experimentUserManager; |
36 | private MentorManager $mentorManager; |
37 | private Config $wikiConfig; |
38 | private UserOptionsManager $userOptionsManager; |
39 | |
40 | /** |
41 | * @var string Unique identifier for this specific rendering of Special:Homepage. |
42 | * Used by various EventLogging schemas to correlate events. |
43 | */ |
44 | private $pageviewToken; |
45 | |
46 | /** @var TitleFactory */ |
47 | private $titleFactory; |
48 | private bool $isMobile; |
49 | private StatsFactory $statsFactory; |
50 | |
51 | /** |
52 | * @param HomepageModuleRegistry $moduleRegistry |
53 | * @param StatsFactory $statsFactory |
54 | * @param ExperimentUserManager $experimentUserManager |
55 | * @param MentorManager $mentorManager |
56 | * @param Config $wikiConfig |
57 | * @param UserOptionsManager $userOptionsManager |
58 | * @param TitleFactory $titleFactory |
59 | */ |
60 | public function __construct( |
61 | HomepageModuleRegistry $moduleRegistry, |
62 | StatsFactory $statsFactory, |
63 | ExperimentUserManager $experimentUserManager, |
64 | MentorManager $mentorManager, |
65 | Config $wikiConfig, |
66 | UserOptionsManager $userOptionsManager, |
67 | TitleFactory $titleFactory |
68 | ) { |
69 | parent::__construct( 'Homepage', '', false ); |
70 | $this->moduleRegistry = $moduleRegistry; |
71 | $this->statsFactory = $statsFactory; |
72 | $this->pageviewToken = $this->generatePageviewToken(); |
73 | $this->experimentUserManager = $experimentUserManager; |
74 | $this->mentorManager = $mentorManager; |
75 | $this->wikiConfig = $wikiConfig; |
76 | $this->userOptionsManager = $userOptionsManager; |
77 | $this->titleFactory = $titleFactory; |
78 | } |
79 | |
80 | /** @inheritDoc */ |
81 | protected function getGroupName() { |
82 | return 'growth-tools'; |
83 | } |
84 | |
85 | /** |
86 | * @inheritDoc |
87 | * @param string $par |
88 | * @throws ConfigException |
89 | * @throws ErrorPageError |
90 | * @throws UserNotLoggedIn |
91 | */ |
92 | public function execute( $par = '' ) { |
93 | $startTime = microtime( true ); |
94 | // Use in client-side performance instrumentation; export as milliseconds as that is what mw.now() uses. |
95 | $this->getOutput()->addJsConfigVars( 'GEHomepageStartTime', round( $startTime * 1000 ) ); |
96 | $this->requireNamedUser(); |
97 | parent::execute( $par ); |
98 | $this->handleDisabledPreference(); |
99 | // Redirect the user to the newcomer task if the page ID in $par can be used |
100 | // to construct a Title object. |
101 | if ( $this->handleNewcomerTask( $par ) ) { |
102 | return; |
103 | } |
104 | |
105 | $out = $this->getContext()->getOutput(); |
106 | $this->isMobile = Util::isMobile( $out->getSkin() ); |
107 | $loggingEnabled = $this->getConfig()->get( 'GEHomepageLoggingEnabled' ); |
108 | $userVariant = $this->experimentUserManager->getVariant( $this->getUser() ); |
109 | $out->addJsConfigVars( [ |
110 | 'wgGEHomepagePageviewToken' => $this->pageviewToken, |
111 | 'wgGEHomepageLoggingEnabled' => $loggingEnabled |
112 | ] ); |
113 | $out->addModules( 'ext.growthExperiments.Homepage' ); |
114 | $out->enableOOUI(); |
115 | $out->addModuleStyles( [ 'ext.growthExperiments.Homepage.styles' ] ); |
116 | |
117 | $out->addHTML( Html::openElement( 'div', [ |
118 | 'class' => 'growthexperiments-homepage-container ' . |
119 | 'growthexperiments-homepage-container-user-variant-' . $userVariant |
120 | ] ) ); |
121 | $modules = $this->getModules( $this->isMobile, $par ); |
122 | |
123 | if ( $this->isMobile ) { |
124 | if ( |
125 | array_key_exists( $par, $modules ) && |
126 | $modules[$par]->supports( IDashboardModule::RENDER_MOBILE_DETAILS ) |
127 | ) { |
128 | $mode = IDashboardModule::RENDER_MOBILE_DETAILS; |
129 | $this->renderMobileDetails( $modules[$par] ); |
130 | } else { |
131 | $mode = IDashboardModule::RENDER_MOBILE_SUMMARY; |
132 | $this->renderMobileSummary(); |
133 | } |
134 | } else { |
135 | $mode = IDashboardModule::RENDER_DESKTOP; |
136 | Util::maybeAddGuidedTour( |
137 | $out, |
138 | TourHooks::TOUR_COMPLETED_HOMEPAGE_WELCOME, |
139 | 'ext.guidedTour.tour.homepage_welcome', |
140 | $this->userOptionsManager |
141 | ); |
142 | $this->renderDesktop(); |
143 | } |
144 | |
145 | $out->addHTML( Html::closeElement( 'div' ) ); |
146 | $this->outputJsData( $mode, $modules ); |
147 | $this->getOutput()->addBodyClasses( |
148 | 'growthexperiments-homepage-user-variant-' . |
149 | $this->experimentUserManager->getVariant( $this->getUser() ) |
150 | ); |
151 | $platform = ( $this->isMobile ? 'mobile' : 'desktop' ); |
152 | $this->statsFactory->withComponent( 'GrowthExperiments' ) |
153 | ->getTiming( 'special_homepage_server_side_render_milliseconds' ) |
154 | ->setLabel( 'platform', $platform ) |
155 | ->copyToStatsdAt( "timing.growthExperiments.specialHomepage.serverSideRender." . $platform ) |
156 | ->observe( microtime( true ) - $startTime ); |
157 | |
158 | if ( $loggingEnabled && |
159 | ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ) && |
160 | count( $modules ) ) { |
161 | $logger = new SpecialHomepageLogger( |
162 | $this->pageviewToken, |
163 | $this->getContext()->getUser(), |
164 | $this->getRequest(), |
165 | $this->isMobile, |
166 | $modules |
167 | ); |
168 | DeferredUpdates::addCallableUpdate( static function () use ( $logger ) { |
169 | $logger->log(); |
170 | } ); |
171 | } |
172 | } |
173 | |
174 | /** |
175 | * @throws ConfigException |
176 | * @throws ErrorPageError |
177 | */ |
178 | private function handleDisabledPreference() { |
179 | if ( !HomepageHooks::isHomepageEnabled( $this->getUser() ) ) { |
180 | throw new ErrorPageError( |
181 | 'growthexperiments-homepage-tab', |
182 | 'growthexperiments-homepage-enable-preference' |
183 | ); |
184 | } |
185 | } |
186 | |
187 | /** |
188 | * Overridden in order to inject the current user's name as message parameter |
189 | * |
190 | * @inheritDoc |
191 | */ |
192 | public function getDescription() { |
193 | return $this->msg( 'growthexperiments-homepage-specialpage-title' ) |
194 | ->params( $this->getUser()->getName() ); |
195 | } |
196 | |
197 | /** |
198 | * @param bool $isMobile |
199 | * @param string|null $par Path passed into SpecialHomepage::execute() |
200 | * @return BaseModule[] |
201 | */ |
202 | private function getModules( bool $isMobile, $par = '' ) { |
203 | $mentorshipState = $this->mentorManager->getMentorshipStateForUser( $this->getUser() ); |
204 | $moduleConfig = array_filter( [ |
205 | 'banner' => true, |
206 | 'welcomesurveyreminder' => true, |
207 | 'startemail' => true, |
208 | // Only load start-startediting code (the uninitiated view of suggested edits) for desktop users who |
209 | // haven't activated SE yet. |
210 | 'start-startediting' => SuggestedEdits::isEnabledForAnyone( |
211 | $this->getContext()->getConfig() |
212 | ) && ( !$par && !$isMobile && |
213 | !SuggestedEdits::isActivated( $this->getUser(), $this->userOptionsManager ) |
214 | ), |
215 | 'suggested-edits' => SuggestedEdits::isEnabled( $this->getConfig() ), |
216 | 'community-updates' => Util::useCommunityConfiguration() && |
217 | $this->getConfig()->get( 'GECommunityUpdatesEnabled' ) && |
218 | $this->experimentUserManager->isUserInVariant( $this->getUser(), [ |
219 | VariantHooks::VARIANT_COMMUNITY_UPDATES_MODULE |
220 | ] ), |
221 | 'impact' => true, |
222 | 'mentorship' => $this->wikiConfig->get( 'GEMentorshipEnabled' ) && |
223 | $mentorshipState === MentorManager::MENTORSHIP_ENABLED, |
224 | 'mentorship-optin' => $this->wikiConfig->get( 'GEMentorshipEnabled' ) && |
225 | $mentorshipState === MentorManager::MENTORSHIP_OPTED_OUT, |
226 | 'help' => true |
227 | ] ); |
228 | $modules = []; |
229 | foreach ( $moduleConfig as $moduleId => $_ ) { |
230 | $modules[$moduleId] = $this->moduleRegistry->get( $moduleId, $this->getContext() ); |
231 | } |
232 | return $modules; |
233 | } |
234 | |
235 | /** |
236 | * @return string[][][] |
237 | */ |
238 | private function getModuleGroups(): array { |
239 | $isSuggestedEditsEnabled = SuggestedEdits::isEnabledForAnyone( |
240 | $this->getContext()->getConfig() |
241 | ); |
242 | return [ |
243 | 'main' => [ |
244 | 'primary' => [ 'banner', 'welcomesurveyreminder', 'startemail' ], |
245 | 'secondary' => $isSuggestedEditsEnabled ? |
246 | [ 'start-startediting', 'suggested-edits' ] : |
247 | [ 'impact' ] |
248 | ], |
249 | 'sidebar' => [ |
250 | 'primary' => array_merge( |
251 | [ 'community-updates' ], |
252 | $isSuggestedEditsEnabled ? [ 'impact' ] : [] |
253 | ), |
254 | 'secondary' => [ 'mentorship', 'mentorship-optin', 'help' ] |
255 | ] |
256 | ]; |
257 | } |
258 | |
259 | /** |
260 | * Returns 32-character random string. |
261 | * The token is used for client-side logging and can be retrieved on Special:Homepage via the |
262 | * wgGEHomepagePageviewToken JS variable. |
263 | * @return string |
264 | */ |
265 | private function generatePageviewToken() { |
266 | return \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 ); |
267 | } |
268 | |
269 | private function renderDesktop() { |
270 | $out = $this->getContext()->getOutput(); |
271 | $modules = $this->getModules( false ); |
272 | $out->addBodyClasses( 'growthexperiments-homepage-desktop' ); |
273 | foreach ( $this->getModuleGroups() as $group => $subGroups ) { |
274 | $out->addHTML( Html::openElement( 'div', [ |
275 | 'class' => "growthexperiments-homepage-group-$group " . |
276 | "growthexperiments-homepage-group-$group-user-variant-" . |
277 | $this->experimentUserManager->getVariant( $this->getUser() ), |
278 | ] ) ); |
279 | foreach ( $subGroups as $subGroup => $moduleNames ) { |
280 | $out->addHTML( Html::openElement( 'div', [ |
281 | 'class' => "growthexperiments-homepage-group-$group-subgroup-$subGroup " . |
282 | "growthexperiments-homepage-group-$group-subgroup-$subGroup-user-variant-" . |
283 | $this->experimentUserManager->getVariant( $this->getUser() ) |
284 | ] ) ); |
285 | foreach ( $moduleNames as $moduleName ) { |
286 | /** @var IDashboardModule $module */ |
287 | $module = $modules[$moduleName] ?? null; |
288 | if ( !$module ) { |
289 | continue; |
290 | } |
291 | |
292 | $startTime = microtime( true ); |
293 | |
294 | $module->setPageURL( $this->getPageTitle()->getLinkURL() ); |
295 | $html = $this->getModuleRenderHtmlSafe( $module, IDashboardModule::RENDER_DESKTOP ); |
296 | $out->addHTML( $html ); |
297 | |
298 | $this->recordModuleRenderingTime( |
299 | $moduleName, |
300 | IDashboardModule::RENDER_DESKTOP, |
301 | microtime( true ) - $startTime |
302 | ); |
303 | } |
304 | $out->addHTML( Html::closeElement( 'div' ) ); |
305 | } |
306 | $out->addHTML( Html::closeElement( 'div' ) ); |
307 | } |
308 | } |
309 | |
310 | private function recordModuleRenderingTime( string $moduleName, string $mode, float $timeToRecord ): void { |
311 | $wiki = WikiMap::getCurrentWikiId(); |
312 | $this->statsFactory->withComponent( 'GrowthExperiments' ) |
313 | ->getTiming( 'special_homepage_ssr_milliseconds' ) |
314 | ->setLabel( 'wiki', $wiki ) |
315 | ->setLabel( 'module', $moduleName ) |
316 | ->setLabel( 'mode', $mode ) |
317 | ->copyToStatsdAt( implode( '.', [ |
318 | $wiki, 'timing.growthExperiments.specialHomepage.ssr', $moduleName, $mode |
319 | ] ) ) |
320 | ->observe( $timeToRecord ); |
321 | } |
322 | |
323 | /** |
324 | * @param IDashboardModule $module |
325 | */ |
326 | private function renderMobileDetails( IDashboardModule $module ) { |
327 | $out = $this->getContext()->getOutput(); |
328 | $out->addBodyClasses( 'growthexperiments-homepage-mobile-details' ); |
329 | $html = $this->getModuleRenderHtmlSafe( $module, IDashboardModule::RENDER_MOBILE_DETAILS ); |
330 | $this->getOutput()->addHTML( $html ); |
331 | } |
332 | |
333 | private function renderMobileSummary() { |
334 | $out = $this->getContext()->getOutput(); |
335 | $modules = $this->getModules( true ); |
336 | $isOpeningOverlay = $this->getContext()->getRequest()->getFuzzyBool( 'overlay' ); |
337 | $out->addBodyClasses( [ |
338 | 'growthexperiments-homepage-mobile-summary', |
339 | $isOpeningOverlay ? 'growthexperiments-homepage-mobile-summary--opening-overlay' : '' |
340 | ] ); |
341 | foreach ( $modules as $moduleName => $module ) { |
342 | $startTime = microtime( true ); |
343 | |
344 | $module->setPageURL( $this->getPageTitle()->getLinkURL() ); |
345 | $html = $this->getModuleRenderHtmlSafe( $module, IDashboardModule::RENDER_MOBILE_SUMMARY ); |
346 | $this->getOutput()->addHTML( $html ); |
347 | |
348 | $this->recordModuleRenderingTime( |
349 | $moduleName, |
350 | IDashboardModule::RENDER_MOBILE_SUMMARY, |
351 | microtime( true ) - $startTime |
352 | ); |
353 | } |
354 | } |
355 | |
356 | /** |
357 | * Get the module render HTML for a particular mode, catching exceptions by default. |
358 | * |
359 | * If GEDeveloperSetup is on, then throw the exceptions. |
360 | * @param IDashboardModule $module |
361 | * @param string $mode |
362 | * @throws Throwable |
363 | * @return string |
364 | */ |
365 | private function getModuleRenderHtmlSafe( IDashboardModule $module, string $mode ): string { |
366 | $html = ''; |
367 | try { |
368 | $html = $module->render( $mode ); |
369 | } catch ( Throwable $throwable ) { |
370 | if ( $this->getConfig()->get( 'GEDeveloperSetup' ) ) { |
371 | throw $throwable; |
372 | } |
373 | Util::logException( $throwable, [ 'origin' => __METHOD__ ] ); |
374 | } |
375 | return $html; |
376 | } |
377 | |
378 | /** |
379 | * @param string $mode One of RENDER_DESKTOP, RENDER_MOBILE_SUMMARY, RENDER_MOBILE_DETAILS |
380 | * @param IDashboardModule[] $modules |
381 | */ |
382 | private function outputJsData( $mode, array $modules ) { |
383 | $out = $this->getContext()->getOutput(); |
384 | |
385 | $data = []; |
386 | $html = ''; |
387 | foreach ( $modules as $moduleName => $module ) { |
388 | try { |
389 | $data[$moduleName] = $module->getJsData( $mode ); |
390 | if ( isset( $data[$moduleName]['overlay'] ) ) { |
391 | $html .= $data[$moduleName]['overlay']; |
392 | unset( $data[$moduleName]['overlay'] ); |
393 | } |
394 | } catch ( Throwable $throwable ) { |
395 | if ( $this->getConfig()->get( 'GEDeveloperSetup' ) ) { |
396 | throw $throwable; |
397 | } |
398 | Util::logException( $throwable, [ 'origin' => __METHOD__ ] ); |
399 | } |
400 | } |
401 | $out->addJsConfigVars( 'homepagemodules', $data ); |
402 | |
403 | if ( $mode === IDashboardModule::RENDER_MOBILE_SUMMARY ) { |
404 | $out->addJsConfigVars( 'homepagemobile', true ); |
405 | $out->addModules( 'ext.growthExperiments.Homepage.mobile' ); |
406 | $out->addHTML( Html::rawElement( |
407 | 'div', |
408 | [ 'class' => 'growthexperiments-homepage-overlay-container' ], |
409 | $html |
410 | ) ); |
411 | } |
412 | } |
413 | |
414 | /** |
415 | * @param string|null $par The URL path arguments after Special:Homepage |
416 | * @return bool |
417 | */ |
418 | private function handleNewcomerTask( ?string $par = null ): bool { |
419 | if ( !$par || |
420 | !str_starts_with( $par, 'newcomertask/' ) || |
421 | !SuggestedEdits::isEnabled( $this->getConfig() ) |
422 | ) { |
423 | return false; |
424 | } |
425 | $titleId = (int)explode( '/', $par )[1]; |
426 | if ( !$titleId ) { |
427 | return false; |
428 | } |
429 | $title = $this->titleFactory->newFromID( $titleId ); |
430 | if ( !$title ) { |
431 | // Will bring the user back to Special:Homepage, since we couldn't load a title. |
432 | return false; |
433 | } |
434 | |
435 | $request = $this->getRequest(); |
436 | $clickId = $request->getVal( 'geclickid' ); |
437 | $newcomerTaskToken = $request->getVal( 'genewcomertasktoken' ); |
438 | $taskTypeId = $request->getVal( 'getasktype', '' ); |
439 | $missing = []; |
440 | if ( !$clickId ) { |
441 | $missing[] = 'geclickid'; |
442 | } |
443 | if ( !$newcomerTaskToken ) { |
444 | $missing[] = 'genewcomertasktoken'; |
445 | } |
446 | if ( !$taskTypeId ) { |
447 | $missing[] = 'getasktype'; |
448 | } |
449 | if ( count( $missing ) ) { |
450 | // Something is broken in our client-side code; these params should always be present. |
451 | $errorMessage = sprintf( |
452 | 'Invalid parameters passed to Special:Homepage/newcomertask. Missing params: %s', |
453 | implode( ',', $missing ) |
454 | ); |
455 | LoggerFactory::getInstance( 'GrowthExperiments' )->error( $errorMessage ); |
456 | if ( $this->getConfig()->get( 'GEDeveloperSetup' ) ) { |
457 | // For developer setup wikis (local + beta/CI), throw an exception so we can |
458 | // catch the issue in testing/CI. For production, we should |
459 | // let the user go on to the task, even if we lose analytics for that interaction. |
460 | throw new InvalidArgumentException( $errorMessage ); |
461 | } |
462 | } |
463 | |
464 | $suggestedEdits = $this->getModules( Util::isMobile( $this->getSkin() ) )[ 'suggested-edits' ]; |
465 | $redirectParams = array_merge( |
466 | [ |
467 | 'getasktype' => $request->getVal( 'getasktype' ), |
468 | // This query parameter allows us to load the help panel for the suggested edit session, |
469 | // even if the user has the preference (probably unknowingly) disabled. |
470 | 'gesuggestededit' => 1, |
471 | 'geclickid' => $clickId, |
472 | 'genewcomertasktoken' => $newcomerTaskToken, |
473 | // Query parameter to show the onboarding Vue dialog |
474 | 'new-onboarding' => $request->getVal( 'new-onboarding' ) |
475 | ], |
476 | $suggestedEdits instanceof SuggestedEdits ? $suggestedEdits->getRedirectParams( $taskTypeId ) : [] |
477 | ); |
478 | |
479 | $statsAction = 'Click'; |
480 | $wiki = WikiMap::getCurrentWikiId(); |
481 | $this->statsFactory->withComponent( 'GrowthExperiments' ) |
482 | ->getCounter( 'newcomertask_total' ) |
483 | ->setLabel( 'taskType', $taskTypeId ?? '' ) |
484 | ->setLabel( 'wiki', $wiki ) |
485 | ->setLabel( 'action', $statsAction ) |
486 | ->copyToStatsdAt( "$wiki.GrowthExperiments.NewcomerTask." . $taskTypeId . '.' . $statsAction ) |
487 | ->increment(); |
488 | |
489 | $this->getOutput()->redirect( |
490 | $title->getFullUrlForRedirect( $redirectParams ) |
491 | ); |
492 | return true; |
493 | } |
494 | |
495 | } |