mirror of
https://gerrit.wikimedia.org/r/mediawiki/skins/Vector.git
synced 2024-09-23 10:21:40 +00:00
Add scroll event + init A/B test logging to sticky header, AB js
- Pull IntersectionObserver into new file to share observer with different callbacks: - Wrap show/hide functionality of sticky header in conditionals based on user test group or by default. - Fire hooks for scroll event tracking in WME. - Add new js for A/B test functions and variables: - Fire hook to send data for A/B test initialization. - Update main js to include scrollObserver, A/B test init functionality. - Add A/B test config. - Update ResourceLoader package dependencies for sticky header. - Though not a strict dependency, see I42e3e7c2084c1e88363d5d1662630ed23a28c4d2 in WME repo which uses these hooks to log scroll events. - This patch includes changes from I56f40e706f8706fde1c0891a0561dd32c5e02bfc which were consolidated here for simplicity and ease of review - related to T292587 which calls for logging an init event for bucketing of users during A/B testing. Bug: T292586 Change-Id: If6446e1e84cea3649905808c4f0e9f6862255fa3
This commit is contained in:
parent
768a07ec6c
commit
457dcfc472
|
@ -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
|
||||
|
|
|
@ -35,6 +35,9 @@ class Hooks {
|
|||
) {
|
||||
return [
|
||||
'wgVectorSearchHost' => $config->get( 'VectorSearchHost' ),
|
||||
'wgVectorWebABTestEnrollment' => $config->get(
|
||||
Constants::CONFIG_STICKY_HEADER_TREATMENT_AB_TEST_ENROLLMENT
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
108
resources/skins.vector.es6/AB.js
Normal file
108
resources/skins.vector.es6/AB.js
Normal 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
|
||||
};
|
|
@ -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 );
|
||||
};
|
||||
|
||||
if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
|
||||
|
|
90
resources/skins.vector.es6/scrollObserver.js
Normal file
90
resources/skins.vector.es6/scrollObserver.js
Normal 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
|
||||
};
|
|
@ -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
|
||||
};
|
||||
|
|
35
skin.json
35
skin.json
|
@ -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."
|
||||
|
|
Loading…
Reference in a new issue