blob: 997082747c1271af3e2f4560f27477feb4af9046 [file] [log] [blame]
<?php
namespace GrowthExperiments;
use ExtensionRegistry;
use FormatJson;
use IContextSource;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\Logger\LoggerFactory;
use MWTimestamp;
use SpecialPage;
class WelcomeSurvey {
private const BLOB_SIZE = 65535;
public const SURVEY_PROP = 'welcomesurvey-responses';
/**
* @var IContextSource
*/
private $context;
/**
* @var bool
*/
private $allowFreetext;
/**
* @var LanguageNameUtils
*/
private $languageNameUtils;
/**
* @param IContextSource $context
* @param LanguageNameUtils $languageNameUtils
*/
public function __construct( IContextSource $context, LanguageNameUtils $languageNameUtils ) {
$this->context = $context;
$this->allowFreetext =
$this->context->getConfig()->get( 'WelcomeSurveyAllowFreetextResponses' );
$this->languageNameUtils = $languageNameUtils;
}
/**
* Get the name of the experimental group for the current user or
* false they are not part of any experiment.
*
* @param bool $useDefault Use default group from WelcomeSurveyDefaultGroup, if it is defined
* @return bool|string
* @throws \ConfigException
*/
public function getGroup( $useDefault = false ) {
$groups = $this->context->getConfig()->get( 'WelcomeSurveyExperimentalGroups' );
// The group is specified in the URL
$request = $this->context->getRequest();
// The parameter name ('_group') must match the name of the hidden form field in
// SpecialWelcomeSurvey::alterForm()
$groupParam = $request->getText( '_group' );
if ( isset( $groups[ $groupParam ] ) ) {
return $groupParam;
}
// The user was already assigned a group
$groupFromProp = FormatJson::decode(
$this->context->getUser()->getOption( self::SURVEY_PROP, '' )
)->_group ?? false;
if ( isset( $groups[ $groupFromProp ] ) ) {
return $groupFromProp;
}
if ( $useDefault ) {
// Fallback to default group if directly visiting Special:WelcomeSurvey
return 'exp2_target_specialpage';
}
// Randomly selecting a group
$js = $this->context->getRequest()->getBool( 'client-runs-javascript' );
$rand = rand( 0, 9 );
foreach ( $groups as $name => $groupConfig ) {
$range = explode( '-', $groupConfig[ 'range' ] );
if (
( count( $range ) === 1 && $range[0] === $rand ) ||
( count( $range ) === 2 && $range[0] <= $rand && $range[1] >= $rand )
) {
if ( !$js && isset( $groupConfig[ 'nojs-fallback' ] ) ) {
return $groupConfig[ 'nojs-fallback' ];
}
return $name;
}
}
return false;
}
/**
* Get the questions' configuration for the specified group
*
* @param string $group
* @param bool $asKeyedArray True to use the question name as key, false to use a numerical index
* @return array Questions configuration
* @throws \ConfigException
*/
public function getQuestions( $group, $asKeyedArray = true ) {
$groups = $this->context->getConfig()->get( 'WelcomeSurveyExperimentalGroups' );
if ( !isset( $groups[ $group ] ) ) {
return [];
}
$questionNames = $groups[ $group ][ 'questions' ];
if ( in_array( 'email', $questionNames ) &&
!Util::canSetEmail( $this->context->getUser(), '', false )
) {
$questionNames = array_diff( $questionNames, [ 'email' ] );
}
if ( $this->allowFreetext && in_array( 'reason', $questionNames ) ) {
// Insert reason-other after reason
array_splice( $questionNames, array_search( 'reason', $questionNames ) + 1, 0,
'reason-other' );
}
$questions = [];
$questionBank = $this->getQuestionBank();
foreach ( $questionNames as $questionName ) {
if ( $questionBank[$questionName]['disabled'] ?? false ) {
continue;
}
if ( $asKeyedArray ) {
$questions[ $questionName ] = $questionBank[ $questionName ];
} else {
$questions[] = [ 'name' => $questionName ] + $questionBank[ $questionName ];
}
}
return $questions;
}
/**
* Bank of questions that can be used on the Welcome survey.
* Format is HTMLForm configuration, with two special keys 'placeholder-message'
* and 'other-message' for type=select (see SpecialWelcomeSurvey::getFormFields()).
* @return array
*/
protected function getQuestionBank() : array {
// When free text is enabled, add other-* settings and the reason-other question
$reasonOtherSettings = $this->allowFreetext ? [
'other-message' => 'welcomesurvey-question-reason-option-other-label',
] : [];
$reasonOtherQuestion = $this->allowFreetext ? [
'reason-other' => [
'type' => 'text',
'placeholder-message' => [ 'welcomesurvey-question-reason-other-placeholder',
$this->context->getUser()->getName() ],
'size' => 255,
'hide-if' => [ '!==', 'reason', 'other' ],
'group' => 'reason'
]
] : [];
// When free text is disabled, add an "Other" option to the reason question
$reasonOtherOption = $this->allowFreetext ? [] : [
"welcomesurvey-question-reason-option-other-no-freetext-label" => "other",
];
$questions = [
"reason" => [
"type" => "select",
"label-message" => "welcomesurvey-question-reason-label",
"options-messages" => [
"welcomesurvey-question-reason-option-edit-typo-label" => "edit-typo",
"welcomesurvey-question-reason-option-edit-info-add-change-label" => "edit-info-add-change",
"welcomesurvey-question-reason-option-add-image-label" => "add-image",
"welcomesurvey-question-reason-option-new-page-label" => "new-page",
"welcomesurvey-question-reason-option-program-participant-label" => "program-participant",
"welcomesurvey-question-reason-option-read-label" => "read",
] + $reasonOtherOption,
"placeholder-message" => "welcomesurvey-dropdown-option-select-label",
"name" => "reason",
"group" => "reason",
] + $reasonOtherSettings
] + $reasonOtherQuestion + [
"edited" => [
"type" => "select",
"label-message" => "welcomesurvey-question-edited-label",
"options-messages" => [
"welcomesurvey-question-edited-option-yes-many-label" => "yes-many",
"welcomesurvey-question-edited-option-yes-few-label" => "yes-few",
"welcomesurvey-question-edited-option-no-dunno-label" => "dunno",
"welcomesurvey-question-edited-option-no-other-label" => "no-other",
"welcomesurvey-question-edited-option-dont-remember-label" => "dont-remember",
],
"placeholder-message" => "welcomesurvey-dropdown-option-select-label",
"group" => "edited",
],
"languages" => [
"type" => "multiselect",
"options" => array_flip( $this->languageNameUtils->getLanguageNames() ),
"cssclass" => "welcomesurvey-languages",
"label-message" => "welcomesurvey-question-languages-label",
"dependencies" => [
"modules" => [
"ext.growthExperiments.welcomeSurveyLanguage",
"ext.uls.mediawiki"
]
],
"disabled" => false
],
"mentor-info" => [
"type" => "info",
"label-message" => [ "welcomesurvey-question-mentor-info",
$this->context->getUser()->getName() ],
"cssclass" => "welcomesurvey-mentor-info",
"group" => "email",
],
"mentor" => [
"type" => "check",
"label-message" => [ "welcomesurvey-question-mentor-label",
$this->context->getUser()->getName() ],
"cssclass" => "welcomesurvey-mentor-check",
"group" => "email",
],
"email" => [
"type" => "email",
"label-message" => "welcomesurvey-question-email-label",
"placeholder-message" => "welcomesurvey-question-email-placeholder",
"help-message" => "welcomesurvey-question-email-help",
"group" => "email",
]
];
if ( !ExtensionRegistry::getInstance()->isLoaded( 'UniversalLanguageSelector' ) ) {
$questions['languages']['disabled'] = true;
}
return $questions;
}
/**
* Save the responses data and/or metadata as appropriate
*
* @param array $data Responses of the survey questions, keyed by questions' names
* @param bool $save True if the user selected to submit their responses,
* false if they chose to skip
* @param string $group Name of the group this form is for
* @param string $renderDate Timestamp in MW format of when the form was shown
*/
public function handleResponses( $data, $save, $group, $renderDate ) {
$user = $this->context->getUser()->getInstanceForUpdate();
$submitDate = MWTimestamp::now();
$userUpdated = false;
if ( $save ) {
// set email
$newEmail = $data[ 'email' ] ?? '';
if ( $newEmail ) {
$data[ 'email' ] = '[redacted]';
if ( Util::canSetEmail( $user, $newEmail ) ) {
$user->setEmailWithConfirmation( $newEmail );
$userUpdated = true;
}
}
$results = $data;
} else {
$results = [ '_skip' => true ];
}
$counter = ( FormatJson::decode(
$user->getOption( self::SURVEY_PROP, '' )
)->_counter ?? 0 ) + 1;
$results = array_merge(
$results,
[
'_group' => $group,
'_render_date' => $renderDate,
'_submit_date' => $submitDate,
'_counter' => $counter,
]
);
$encodedData = FormatJson::encode( $results );
if ( strlen( $encodedData ) <= self::BLOB_SIZE ) {
$user->setOption( self::SURVEY_PROP, $encodedData );
$userUpdated = true;
} else {
LoggerFactory::getInstance( 'GrowthExperiments' )->warning(
'Unable to save Welcome survey responses for user {userId} because it is too big.',
[ 'userId' => $user->getId() ]
);
}
if ( $userUpdated ) {
$user->saveSettings();
}
}
/**
* This is called right after user account creation and before the user is redirected
* to the welcome survey. It ensures that we keep track of which group a user
* is part of if they never submit any responses or don't even get the survey.
*
* @param string $group
*/
public function saveGroup( $group ) {
$group = $group ?: 'NONE';
$user = $this->context->getUser();
$data = [
'_group' => $group,
'_render_date' => MWTimestamp::now(),
];
$user->setOption(
self::SURVEY_PROP,
FormatJson::encode( $data )
);
$user->saveSettings();
}
/**
* Build the redirect URL for a group and its display format
*
* @param string $group
* @return bool|string
*/
public function getRedirectUrl( $group ) {
$questions = $this->getQuestions( $group );
if ( !$questions ) {
return false;
}
$request = $this->context->getRequest();
$returnTo = $request->getVal( 'returnto' );
$returnToQuery = $request->getVal( 'returntoquery' );
$welcomeSurvey = SpecialPage::getTitleFor( 'WelcomeSurvey' );
$query = wfArrayToCgi( [
'returnto' => $returnTo,
'returntoquery' => $returnToQuery,
'group' => $group,
] );
return $welcomeSurvey->getFullUrlForRedirect( $query );
}
}