└── EmbeddedSurvey.js /EmbeddedSurvey.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Create an injectable element where you want the survey to appear, and include survey configuration with 3 | * an `injectInto` selecting this element. The element will be replaced when the survey is displayed. 4 | * 5 | *
6 | * {{int:wmde-tw-template-survey-nojs-notice}} 7 | *
8 | * 9 | * Use the URL query parameter "?forcesurvey=true" to show the survey regardless of your Do Not Track setting and 10 | * any previously submitted responses. 11 | * 12 | * Responses are submitted to the QuickSurveysResponses EventLogging schema, with "answer" set to a comma-separated 13 | * list of the answer messages the user selected. 14 | * 15 | * TODO: 16 | * - Move EmbeddedSurvey into module file. 17 | * - Provide surveys in mediawiki-config or on-wiki. 18 | */ 19 | ( function () { 20 | var surveyConfig = { 21 | surveys: [ 22 | { 23 | injectInto: '#survey-inject-1', 24 | name: "wmde-tw-template-survey-prototype-1", 25 | question: 'wmde-tw-template-survey-prototype1-question', 26 | description: 'wmde-tw-template-survey-prototype1-description-message', 27 | answers: [ 28 | 'wmde-tw-template-survey-prototype1-answer-1a', 29 | 'wmde-tw-template-survey-prototype1-answer-1b', 30 | 'wmde-tw-template-survey-prototype1-answer-1c' 31 | ] 32 | } 33 | ] 34 | }; 35 | 36 | /** 37 | * Extend an object with extra properties. 38 | * 39 | * @ignore 40 | * @param {Object} target Object to extend. 41 | * @param {Object} mixin Properties to incorporate into the target. 42 | */ 43 | function extend( target, mixin ) { 44 | var key; 45 | for ( key in mixin ) { 46 | target[ key ] = mixin[ key ]; 47 | } 48 | } 49 | 50 | /** 51 | * Get edit count bucket name, based on the number of edits made. 52 | * 53 | * @param {number|null} editCount 54 | * @return {string} 55 | */ 56 | function getEditCountBucket( editCount ) { 57 | if ( editCount >= 1000 ) { 58 | return '1000+ edits'; 59 | } 60 | if ( editCount >= 100 ) { 61 | return '100-999 edits'; 62 | } 63 | if ( editCount >= 5 ) { 64 | return '5-99 edits'; 65 | } 66 | if ( editCount >= 1 ) { 67 | return '1-4 edits'; 68 | } 69 | return '0 edits'; 70 | } 71 | 72 | function getSurveyStorageKey( name ) { 73 | return 'wmde-tw-survey-' + name.replace( / /g, '-' ); 74 | } 75 | 76 | function getSurveyToken( name ) { 77 | return mw.storage.get( getSurveyStorageKey( name ) ); 78 | } 79 | 80 | function setSurveyToken( name, token ) { 81 | var storageId = getSurveyStorageKey( name ); 82 | mw.storage.set( storageId, token ); 83 | } 84 | 85 | function show() { 86 | /** 87 | * @class EmbeddedSurvey 88 | * @extends OO.ui.StackLayout 89 | * 90 | * @constructor 91 | * @param {Object} config 92 | */ 93 | function EmbeddedSurvey( config ) { 94 | this.initialize( config ); 95 | } 96 | 97 | OO.inheritClass( EmbeddedSurvey, OO.ui.StackLayout ); 98 | 99 | extend( EmbeddedSurvey.prototype, { 100 | /** 101 | * Specifies partials (sub-templates) for use by the widget 102 | * @property {Object} 103 | */ 104 | templatePartials: { 105 | // eslint-disable-next-line no-jquery/no-parse-html-literal 106 | initialPanel: $( 107 | '
' + 108 | '' + 109 | '

' + 110 | '
' + 111 | '' + 112 | '
' 113 | ), 114 | // eslint-disable-next-line no-jquery/no-parse-html-literal 115 | finalPanel: $( 116 | '
' + 117 | '' + 118 | '' + 119 | '
' 120 | ) 121 | }, 122 | /** 123 | * A set of default options that are merged with config passed into the initialize function. 124 | * This is likely to change so currently no options are documented. 125 | * @cfg {Object} defaults Default options hash. 126 | */ 127 | defaults: { 128 | templateData: { 129 | finalHeading: mw.msg( 'wmde-tw-survey-thank-you-notice' ), 130 | footer: mw.message( 'ext-quicksurveys-survey-privacy-policy-default-text' ).parse() 131 | }, 132 | PanelLayout: { 133 | expanded: false, 134 | framed: false, 135 | padded: true, 136 | classes: [ 'message content' ] 137 | }, 138 | scrollable: false, 139 | expanded: false, 140 | classes: [ 'panel panel-inline visible ext-quick-survey-panel' ] 141 | }, 142 | /** 143 | * Initialise a widget. 144 | * 145 | * @param {Object} config 146 | */ 147 | initialize: function ( config ) { 148 | var event; 149 | 150 | this.config = config || {}; 151 | $.extend( true, this.config, this.defaults ); 152 | 153 | if ( config.survey.privacyPolicy ) { 154 | // eslint-disable-next-line mediawiki/msg-doc 155 | this.config.templateData.footer = mw.message( config.survey.privacyPolicy ).parse(); 156 | } 157 | 158 | // setup initial panel 159 | this.initialPanel = this.widget( 'PanelLayout', 'initialPanel' ); 160 | 161 | // setup final panel 162 | this.finalPanel = this.widget( 'PanelLayout', 'finalPanel' ); 163 | 164 | // Set the buttons 165 | this.renderButtons(); 166 | 167 | // setup stack 168 | EmbeddedSurvey.super.call( this, $.extend( {}, config, { 169 | items: [ this.initialPanel, this.finalPanel ] 170 | } ) ); 171 | 172 | event = { 173 | beaconCapable: !!navigator.sendBeacon, 174 | surveySessionToken: this.config.surveySessionToken, 175 | pageviewToken: this.config.pageviewToken, 176 | surveyCodeName: this.config.survey.name, 177 | eventName: 'eligible' 178 | }; 179 | 180 | if ( window.performance && performance.now ) { 181 | event.performanceNow = Math.round( performance.now() ); 182 | } 183 | 184 | mw.eventLog.logEvent( 'QuickSurveyInitiation', event ); 185 | }, 186 | /** 187 | * Render and append buttons (and a freeform input if set) to 188 | * the initial panel 189 | */ 190 | renderButtons: function () { 191 | var $btnContainer = this.initialPanel.$element.find( '.survey-button-container' ), 192 | answers = this.config.survey.answers, 193 | answerCheckboxes, 194 | answerOptions, 195 | submitButton; 196 | 197 | answerOptions = answers.map( function ( answer ) { 198 | return { 199 | data: answer, 200 | label: mw.msg( answer ) 201 | }; 202 | } ); 203 | answerCheckboxes = new OO.ui.CheckboxMultiselectInputWidget( { 204 | // eslint-disable-next-line mediawiki/msg-doc 205 | options: answerOptions 206 | } ); 207 | 208 | answerCheckboxes.$element.appendTo( $btnContainer ); 209 | 210 | submitButton = new OO.ui.ButtonWidget( { 211 | label: mw.msg( 'ext-quicksurveys-internal-freeform-survey-submit-button' ), 212 | flags: 'progressive' 213 | } ); 214 | submitButton.$element.appendTo( $btnContainer ); 215 | 216 | submitButton.connect( this, { 217 | click: [ 'onClickSubmitButton', answerCheckboxes ] 218 | } ); 219 | }, 220 | /** 221 | * Make a brand spanking new OOUI widget from a template partial 222 | * 223 | * @param {string} widgetName a valid OOUI widget 224 | * @param {string} [templatePartialName] name of a registered template partial 225 | * @param {Object} [options] further options to be passed to the widget 226 | * @return {*} OOUI widget instance 227 | */ 228 | widget: function ( widgetName, templatePartialName, options ) { 229 | var templateClone, 230 | template = this.templatePartials[ templatePartialName ], 231 | config = $.extend( {}, this.config[ widgetName ], options ), 232 | templateData = this.config.templateData; 233 | 234 | if ( template ) { 235 | templateClone = template.clone(); 236 | templateClone.find( '[data-question]' ).text( templateData.question ); 237 | templateClone.find( '[data-description]' ).text( templateData.description ); 238 | templateClone.find( '[data-footer]' ).html( templateData.footer ); 239 | templateClone.find( '[data-finalHeading]' ).text( templateData.finalHeading ); 240 | config.$content = templateClone; 241 | } 242 | 243 | return new OO.ui[ widgetName ]( config ); 244 | }, 245 | /** 246 | * Log the answer to Schema:QuickSurveysResponses 247 | * See {@link https://meta.wikimedia.org/wiki/Schema:QuickSurveysResponses} 248 | * 249 | * @param {string} answer 250 | * @return {jQuery.Deferred} 251 | */ 252 | log: function ( answer ) { 253 | var survey = this.config.survey, 254 | skin = mw.config.get( 'skin' ), 255 | // FIXME: remove this when SkinMinervaBeta is renamed to 'minerva-beta'. 256 | mobileMode = mw.config.get( 'wgMFMode' ); 257 | 258 | // On mobile differentiate between minerva stable and beta 259 | // by appending 'beta' to 'minerva' 260 | if ( skin === 'minerva' && mobileMode === 'beta' ) { 261 | skin += mobileMode; 262 | } 263 | 264 | return mw.eventLog.logEvent( 'QuickSurveysResponses', { 265 | namespaceId: mw.config.get( 'wgNamespaceNumber' ), 266 | surveySessionToken: this.config.surveySessionToken, 267 | pageviewToken: this.config.pageviewToken, 268 | pageId: mw.config.get( 'wgArticleId' ), 269 | pageTitle: mw.config.get( 'wgPageName' ), 270 | surveyCodeName: survey.name, 271 | surveyResponseValue: answer, 272 | platform: 'web', 273 | skin: skin, 274 | isTablet: !this.config.isMobileLayout, 275 | userLanguage: mw.config.get( 'wgContentLanguage' ), 276 | isLoggedIn: !mw.user.isAnon(), 277 | editCountBucket: getEditCountBucket( mw.config.get( 'wgUserEditCount' ) ), 278 | countryCode: 'Unknown' 279 | } ); 280 | }, 281 | 282 | /** 283 | * @param {OO.ui.CheckboxMultiselectInputWidget} checkboxes 284 | * @private 285 | */ 286 | onClickSubmitButton: function ( checkboxes ) { 287 | var selections = checkboxes.getValue(); 288 | 289 | this.submit( selections.join(',') ); 290 | }, 291 | 292 | /** 293 | * Submit user's answer to the backend and show the next panel 294 | * 295 | * @param {string} answer 296 | */ 297 | submit: function ( answer ) { 298 | this.log( answer ); 299 | /** 300 | * @event dismiss fired when any of the buttons in the survey are selected. 301 | */ 302 | this.emit( 'dismiss' ); 303 | this.setItem( this.finalPanel ); 304 | } 305 | } ); 306 | 307 | for ( var survey of surveyConfig.surveys ) { 308 | var root = $( survey.injectInto ), 309 | isMobileLayout = window.innerWidth <= 768, 310 | userToken = getSurveyToken( survey.name ); 311 | 312 | if ( !root.length ) { 313 | continue; 314 | } 315 | 316 | if ( userToken === '~' && 317 | !mw.util.getParamValue( 'forcesurvey' ) 318 | ) { 319 | root.html( mw.msg( 'wmde-tw-survey-thank-you-notice' ) ); 320 | continue; 321 | } 322 | 323 | if ( !userToken ) { 324 | // Generate a new token for each survey 325 | userToken = mw.user.generateRandomSessionId(); 326 | setSurveyToken( survey.name, userToken ); 327 | } 328 | 329 | var panel = new EmbeddedSurvey( { 330 | survey: survey, 331 | templateData: { 332 | // eslint-disable-next-line mediawiki/msg-doc 333 | question: mw.msg( survey.question ), 334 | // eslint-disable-next-line mediawiki/msg-doc 335 | description: survey.description ? mw.msg( survey.description ) : '' 336 | }, 337 | surveySessionToken: mw.user.sessionId() + '-quicksurveys', 338 | pageviewToken: mw.user.getPageviewToken(), 339 | isMobileLayout: isMobileLayout 340 | } ); 341 | 342 | panel.on( 'dismiss', function () { 343 | setSurveyToken( survey.name, '~' ); 344 | } ); 345 | // TODO: Move inline CSS to an external stylesheet. 346 | root.replaceWith( panel.$element.css( 'float', 'none' ) ); 347 | } 348 | } 349 | 350 | function shouldDisplay() { 351 | if ( mw.util.getParamValue( 'forcesurvey' ) ) { 352 | return true; 353 | } 354 | 355 | if ( 356 | /1|yes/.test( navigator.doNotTrack ) || 357 | window.doNotTrack === '1' 358 | ) { 359 | return 'wmde-tw-template-survey-dnt-notice'; 360 | } 361 | 362 | return true; 363 | } 364 | 365 | function init() { 366 | mw.loader.using( 367 | [ 368 | 'ext.quicksurveys.lib', 369 | 'mediawiki.cookie', 370 | 'mediawiki.storage', 371 | 'mediawiki.viewport', 372 | 'mediawiki.experiments', 373 | 'oojs-ui-core', 374 | 'oojs-ui-widgets', 375 | 'mediawiki.user', 376 | 'mediawiki.Uri', 377 | 'ext.eventLogging', 378 | 'mediawiki.util' 379 | ], 380 | show 381 | ); 382 | } 383 | 384 | function showNotice( message ) { 385 | $( '.wmde-tw-template-survey-container' ) 386 | .html( mw.msg( message ) ); 387 | } 388 | 389 | $( function () { 390 | var isDisplayed = shouldDisplay(); 391 | 392 | if ( isDisplayed === true ) { 393 | init(); 394 | } else { 395 | showNotice( isDisplayed ); 396 | } 397 | // TODO: else, show placeholder and optional reason. 398 | } ); 399 | } )(); 400 | --------------------------------------------------------------------------------