Merge "Add scroll event + init A/B test logging to sticky header, AB js"

This commit is contained in:
jenkins-bot 2021-11-09 23:26:17 +00:00 committed by Gerrit Code Review
commit e95e48ce51
8 changed files with 374 additions and 52 deletions

View File

@ -206,6 +206,14 @@ final class Constants {
*/
public const FEATURE_STICKY_HEADER_EDIT = 'StickyHeaderEdit';
/**
* Defines whether the Sticky Header A/B test is running. See
* https://phabricator.wikimedia.org/T292587 for additional detail about the test.
*
* @var string
*/
public const CONFIG_STICKY_HEADER_TREATMENT_AB_TEST_ENROLLMENT = 'VectorWebABTestEnrollment';
/**
* The `mediawiki.searchSuggest` protocol piece of the SearchSatisfaction instrumention reads
* the value of an element with the "data-search-loc" attribute and set the event's

View File

@ -35,6 +35,9 @@ class Hooks {
) {
return [
'wgVectorSearchHost' => $config->get( 'VectorSearchHost' ),
'wgVectorWebABTestEnrollment' => $config->get(
Constants::CONFIG_STICKY_HEADER_TREATMENT_AB_TEST_ENROLLMENT
),
];
}

View File

@ -23,6 +23,7 @@
"Event": "https://developer.mozilla.org/docs/Web/API/Event",
"EventTarget": "https://developer.mozilla.org/docs/Web/API/EventTarget",
"HTMLElement": "https://developer.mozilla.org/docs/Web/API/HTMLElement",
"IntersectionObserver": "https://developer.mozilla.org/docs/Web/API/IntersectionObserver",
"Node": "https://developer.mozilla.org/docs/Web/API/Node",
"NodeList": "https://developer.mozilla.org/docs/Web/API/NodeList",
"HTMLInputElement": "https://developer.mozilla.org/docs/Web/API/HTMLInputElement",

View File

@ -0,0 +1,108 @@
/**
* Example A/B test configuration for sticky header:
*
* $wgVectorABTestEnrollment = [
* 'name' => 'vector.sticky_header_2021_11',
* 'enabled' => true,
* 'buckets' => [
* 'unsampled' => [
* 'samplingRate' => 0.1,
* ],
* 'control' => [
* 'samplingRate' => 0.3,
* ],
* 'stickyHeaderDisabled' => [
* 'samplingRate' => 0.3,
* ],
* 'stickyHeaderEnabled' => [
* 'samplingRate' => 0.3,
* ],
* ],
* ];
*/
/**
* Functions and variables to implement A/B testing.
*/
const ABTestConfig = require( /** @type {string} */ ( './config.json' ) ).wgVectorWebABTestEnrollment || {};
/**
* Get the name of the bucket the user is assigned to for A/B testing.
*
* @return {string} the name of the bucket the user is assigned.
*/
function getBucketName() {
/**
* Provided config should contain the keys:
* name: the name of the experiment prefixed with the skin name.
* enabled: must be true or all users are assigned to control.
* buckets: dict with bucket name as key and test config as value.
*
* Bucket test config can contain the keys:
* samplingRate: sampling rates will be summed up and each bucket will receive a proportion
* equal to its value.
*/
return mw.experiments.getBucket( {
name: ABTestConfig.name,
enabled: ABTestConfig.enabled,
buckets: {
// @ts-ignore
unsampled: ABTestConfig.buckets.unsampled.samplingRate,
control: ABTestConfig.buckets.control.samplingRate,
stickyHeaderDisabled: ABTestConfig.buckets.stickyHeaderDisabled.samplingRate,
stickyHeaderEnabled: ABTestConfig.buckets.stickyHeaderEnabled.samplingRate
}
}, mw.user.getId().toString() );
}
/**
* Get the group and experiment name for an A/B test.
*
* @return {Object} data to pass to event logging
*/
function getABTestGroupExperimentName() {
return {
group: getBucketName(),
experimentName: ABTestConfig.name
};
}
/**
* Provides A/B test config for the current user.
*
* @return {Object} A/B test config data
*/
function getEnabledExperiment() {
const mergedConfig = {};
if ( ABTestConfig.enabled ) {
// Merge all the A/B config to return.
Object.assign( mergedConfig, getABTestGroupExperimentName(), ABTestConfig );
}
return mergedConfig;
}
/**
* Fire hook to register A/B test enrollment.
*
* @param {string} bucket the bucket user is assigned to
*/
function initAB( bucket ) {
// Send data to WikimediaEvents to log A/B test initialization if experiment is enabled
// and if the user is logged in.
if ( ABTestConfig.enabled && !mw.user.isAnon() ) {
// @ts-ignore
mw.hook( 'mediawiki.web_AB_test_enrollment' ).fire( getABTestGroupExperimentName() );
// Remove class if present on the html element so that scroll padding isn't undesirably
// applied to users who don't experience the new treatment.
if ( bucket !== 'stickyHeaderEnabled' ) {
document.documentElement.classList.remove( 'vector-sticky-header-enabled' );
}
}
}
module.exports = {
getEnabledExperiment,
initAB
};

View File

@ -1,6 +1,9 @@
// Enable Vector features limited to ES6 browse
const stickyHeader = require( './stickyHeader.js' ),
searchToggle = require( './searchToggle.js' );
const
searchToggle = require( './searchToggle.js' ),
stickyHeader = require( './stickyHeader.js' ),
scrollObserver = require( './scrollObserver.js' ),
AB = require( './AB.js' );
/**
* @return {void}
@ -12,7 +15,45 @@ const main = () => {
if ( searchToggleElement ) {
searchToggle( searchToggleElement );
}
stickyHeader();
// Get the A/B test config for sticky header if enabled.
const
testConfig = AB.getEnabledExperiment(),
stickyConfig = testConfig &&
// @ts-ignore
testConfig.experimentName === stickyHeader.STICKY_HEADER_EXPERIMENT_NAME ?
testConfig : null,
// Note that the default test group is set to experience the feature by default.
// @ts-ignore
testGroup = stickyConfig ? stickyConfig.group : scrollObserver.FEATURE_TEST_GROUP,
targetElement = stickyHeader.header;
// Check for target html, sticky header conditionals, and test group to continue.
if ( !( targetElement &&
stickyHeader.isStickyHeaderAllowed() &&
testGroup !== 'unsampled' )
) {
return;
}
// Fire the A/B test enrollment hook.
AB.initAB( testGroup );
// Set up intersection observer for sticky header functionality and firing scroll event hooks
// for event logging if AB test is enabled.
const observer = scrollObserver.initScrollObserver(
() => {
scrollObserver.onShowFeature( targetElement, testGroup );
scrollObserver.logScrollEvent( 'down' );
},
() => {
scrollObserver.onHideFeature( targetElement, testGroup );
scrollObserver.logScrollEvent( 'up' );
}
);
stickyHeader.initStickyHeader( observer );
};
module.exports = {

View File

@ -0,0 +1,90 @@
const
FEATURE_VISIBLE_CLASS = 'vector-sticky-header-visible',
FEATURE_TEST_GROUP = 'stickyHeaderEnabled',
SCROLL_HOOK = 'vector.page_title_scroll',
SCROLL_CONTEXT_ABOVE = 'scrolled-above-page-title',
SCROLL_CONTEXT_BELOW = 'scrolled-below-page-title',
SCROLL_ACTION = 'scroll-to-top';
/**
* Determine if user is in test group to experience feature.
*
* @param {string} bucket the bucket name the user is assigned
* @param {string} targetGroup the target test group to experience feature
* @return {boolean} true if the user should experience feature
*/
function isInTestGroup( bucket, targetGroup ) {
return bucket === targetGroup;
}
/**
* Show the feature based on test group.
*
* @param {HTMLElement} element target feature
* @param {string} group A/B test bucket of the user
*/
function onShowFeature( element, group ) {
if ( isInTestGroup( group, FEATURE_TEST_GROUP ) ) {
// eslint-disable-next-line mediawiki/class-doc
element.classList.add( FEATURE_VISIBLE_CLASS );
}
}
/**
* Hide the feature based on test group.
*
* @param {HTMLElement} element target feature
* @param {string} group A/B test bucket of the user
*/
function onHideFeature( element, group ) {
if ( isInTestGroup( group, FEATURE_TEST_GROUP ) ) {
// eslint-disable-next-line mediawiki/class-doc
element.classList.remove( FEATURE_VISIBLE_CLASS );
}
}
/**
* Fire a hook to be captured by WikimediaEvents for scroll event logging.
*
* @param {string} direction the scroll direction
*/
function logScrollEvent( direction ) {
if ( direction === 'down' ) {
// @ts-ignore
mw.hook( SCROLL_HOOK ).fire( { context: SCROLL_CONTEXT_BELOW } );
} else {
// @ts-ignore
mw.hook( SCROLL_HOOK ).fire( {
context: SCROLL_CONTEXT_ABOVE,
action: SCROLL_ACTION
} );
}
}
/**
* Create an observer for showing/hiding feature and for firing scroll event hooks.
*
* @param {Function} show functionality for when feature is visible
* @param {Function} hide functionality for when feature is hidden
* @return {IntersectionObserver}
*/
function initScrollObserver( show, hide ) {
/* eslint-disable-next-line compat/compat */
return new IntersectionObserver( function ( entries ) {
if ( !entries[ 0 ].isIntersecting && entries[ 0 ].boundingClientRect.top < 0 ) {
// Viewport has crossed the bottom edge of the target element.
show();
} else {
// Viewport is above the bottom edge of the target element.
hide();
}
} );
}
module.exports = {
initScrollObserver,
onShowFeature,
onHideFeature,
logScrollEvent,
FEATURE_TEST_GROUP
};

View File

@ -1,3 +1,6 @@
/**
* Functions and variables to implement sticky header.
*/
const
STICKY_HEADER_ID = 'vector-sticky-header',
initSearchToggle = require( './searchToggle.js' ),
@ -7,7 +10,8 @@ const
FIRST_HEADING_ID = 'firstHeading',
USER_MENU_ID = 'p-personal',
VECTOR_USER_LINKS_SELECTOR = '.vector-user-links',
SEARCH_TOGGLE_SELECTOR = '.vector-sticky-header-search-toggle';
SEARCH_TOGGLE_SELECTOR = '.vector-sticky-header-search-toggle',
STICKY_HEADER_EXPERIMENT_NAME = 'vector.sticky_header_2021_11';
/**
* Copies attribute from an element to another.
@ -222,33 +226,54 @@ function isInViewport( element ) {
);
}
/**
* Add hooks for sticky header when Visual Editor is used.
*
* @param {HTMLElement} element target feature
* @param {HTMLElement} targetIntersection intersection element
* @param {IntersectionObserver} observer
*/
function addVisualEditorHooks( element, targetIntersection, observer ) {
// When Visual Editor is activated, hide the sticky header.
mw.hook( 've.activate' ).add( () => {
// eslint-disable-next-line mediawiki/class-doc
element.classList.remove( STICKY_HEADER_VISIBLE_CLASS );
observer.unobserve( targetIntersection );
} );
// When Visual Editor is deactivated (by clicking "Read" tab at top of page), show sticky header
// by re-triggering the observer.
mw.hook( 've.deactivationComplete' ).add( () => {
observer.observe( targetIntersection );
} );
// After saving edits, re-apply the sticky header if the target is not in the viewport.
mw.hook( 'postEdit.afterRemoval' ).add( () => {
if ( !isInViewport( targetIntersection ) ) {
// eslint-disable-next-line mediawiki/class-doc
element.classList.add( STICKY_HEADER_VISIBLE_CLASS );
observer.observe( targetIntersection );
}
} );
}
/**
* Makes sticky header functional for modern Vector.
*
* @param {HTMLElement} header
* @param {HTMLElement} stickyIntersection
* @param {HTMLElement} userMenu
* @param {Element} userMenuStickyContainer
* @param {IntersectionObserver} stickyObserver
* @param {HTMLElement} stickyIntersection
*/
function makeStickyHeaderFunctional(
header,
stickyIntersection,
userMenu,
userMenuStickyContainer
userMenuStickyContainer,
stickyObserver,
stickyIntersection
) {
const
/* eslint-disable-next-line compat/compat */
stickyObserver = new IntersectionObserver( function ( entries ) {
if ( !entries[ 0 ].isIntersecting && entries[ 0 ].boundingClientRect.top < 0 ) {
// Viewport has crossed the bottom edge of firstHeading so show sticky header.
// eslint-disable-next-line mediawiki/class-doc
header.classList.add( STICKY_HEADER_VISIBLE_CLASS );
} else {
// Viewport is above the bottom edge of firstHeading so hide sticky header.
// eslint-disable-next-line mediawiki/class-doc
header.classList.remove( STICKY_HEADER_VISIBLE_CLASS );
}
} ),
// Type declaration needed because of https://github.com/Microsoft/TypeScript/issues/3734#issuecomment-118934518
userMenuClone = /** @type {HTMLElement} */( userMenu.cloneNode( true ) ),
userMenuStickyElementsWithIds = userMenuClone.querySelectorAll( '[ id ], [ data-event-name ]' ),
@ -306,23 +331,6 @@ function makeStickyHeaderFunctional(
);
stickyObserver.observe( stickyIntersection );
// When Visual Editor is activated, hide sticky header.
mw.hook( 've.activate' ).add( disableStickyHeader );
// When Visual Editor is deactivated, by cliking "read" tab at top of page, show sticky header.
mw.hook( 've.deactivationComplete' ).add( () => {
stickyObserver.observe( stickyIntersection );
} );
// After saving edits, re-apply the sticky header if the target is not in the viewport.
mw.hook( 'postEdit.afterRemoval' ).add( () => {
if ( !isInViewport( stickyIntersection ) ) {
// eslint-disable-next-line mediawiki/class-doc
header.classList.add( STICKY_HEADER_VISIBLE_CLASS );
stickyObserver.observe( stickyIntersection );
}
} );
}
/**
@ -365,29 +373,59 @@ function isAllowedAction( action ) {
return disallowedActions.indexOf( action ) < 0 && !hasDiffId;
}
module.exports = function initStickyHeader() {
const header = document.getElementById( STICKY_HEADER_ID ),
stickyIntersection = document.getElementById(
FIRST_HEADING_ID
),
userMenu = document.getElementById( USER_MENU_ID ),
userMenuStickyContainer = document.getElementsByClassName(
STICKY_HEADER_USER_MENU_CONTAINER_CLASS
)[ 0 ],
allowedNamespace = isAllowedNamespace( mw.config.get( 'wgNamespaceNumber' ) ),
allowedAction = isAllowedAction( mw.config.get( 'wgAction' ) );
const
header = document.getElementById( STICKY_HEADER_ID ),
stickyIntersection = document.getElementById(
FIRST_HEADING_ID
),
userMenu = document.getElementById( USER_MENU_ID ),
userMenuStickyContainer = document.getElementsByClassName(
STICKY_HEADER_USER_MENU_CONTAINER_CLASS
)[ 0 ],
allowedNamespace = isAllowedNamespace( mw.config.get( 'wgNamespaceNumber' ) ),
allowedAction = isAllowedAction( mw.config.get( 'wgAction' ) );
if ( !(
header &&
/**
* Check if all conditions are met to enable sticky header
*
* @return {boolean}
*/
function isStickyHeaderAllowed() {
// @ts-ignore
return header &&
stickyIntersection &&
userMenu &&
userMenuStickyContainer &&
allowedNamespace &&
allowedAction &&
'IntersectionObserver' in window ) ) {
'IntersectionObserver' in window;
}
/**
* @param {IntersectionObserver} observer
*/
function initStickyHeader( observer ) {
if ( !isStickyHeaderAllowed() ) {
return;
}
makeStickyHeaderFunctional( header, stickyIntersection, userMenu, userMenuStickyContainer );
makeStickyHeaderFunctional(
// @ts-ignore
header,
userMenu,
userMenuStickyContainer,
observer,
stickyIntersection
);
// @ts-ignore
setupSearchIfNeeded( header );
// @ts-ignore
addVisualEditorHooks( header, stickyIntersection, observer );
}
module.exports = {
initStickyHeader,
isStickyHeaderAllowed,
header,
STICKY_HEADER_EXPERIMENT_NAME
};

View File

@ -206,7 +206,19 @@
"packageFiles": [
"resources/skins.vector.es6/main.js",
"resources/skins.vector.es6/searchToggle.js",
"resources/skins.vector.es6/stickyHeader.js"
"resources/skins.vector.es6/stickyHeader.js",
"resources/skins.vector.es6/scrollObserver.js",
"resources/skins.vector.es6/AB.js",
{
"name": "resources/skins.vector.es6/config.json",
"callback": "Vector\\Hooks::getVectorResourceLoaderConfig"
}
],
"dependencies": [
"skins.vector.icons.js",
"mediawiki.page.ready",
"mediawiki.util",
"mediawiki.experiments"
]
},
"skins.vector.js": {
@ -364,6 +376,27 @@
},
"description": "@var array Enables the edit icons if $wgVectorStickyHeader is true."
},
"VectorWebABTestEnrollment": {
"value": {
"name": "vector.sticky_header_2021_11",
"enabled": false,
"buckets": {
"unsampled": {
"samplingRate": "0.1"
},
"control": {
"samplingRate": "0.3"
},
"stickyHeaderDisabled": {
"samplingRate": "0.3"
},
"stickyHeaderEnabled": {
"samplingRate": "0.3"
}
}
},
"description": "An associative array of A/B test configs keyed by parameters noted in mediawiki.experiments.js."
},
"VectorDisableSidebarPersistence": {
"value": false,
"description": "@var boolean Temporary feature flag that disables saving the sidebar expanded/collapsed state as a user-preference (triggered via clicking the main menu icon). This is intended as a temporary kill-switch in the event that the DB is overloaded with writes to the user_options table."