core - client side preferences inline script

Changes:
- Add/Modify client preference classes to documentElement,
- Only uses existing classes, for maintainability. For new feature,
the new class must be added a couple of weeks before the feature is deployed,
so that the feature class is already in the cache when the feature is deployed
- Add mw.user.clientPrefs as an API to manage Preferences. This exposes set and get methods
- When set method is called it saves to a cookie which saves key value pairs of
feature and its value. The value corresponds with a suffix on the class.
- get method always uses HTML to obtain result.
- Tests are defined for set and get methods to ensure that the API does not have unexpected
side effects and caters for the case where two tabs are open and the cookie may not reflect
the classes on the HTML element
- A prefix "-clientpref-" is required on any class managed by this capability.
The purpose of this is to aid discoverability and to act as an allow list for which classes
can be modified by the API
- Using '~' instead of '=' and '!' instead of ',' for separators as they require no url encoding
and do not interfere with the delimiters and special characters
- This is a breaking change for Vector 2022's existing limited width feature. For this
reason this patch should be merged around the same time as the Vector 2022 patch to ensure
backwards compatibility.
- No backwards compatibility is provided by core given the previous capability was Vector
specific. Backwards compatibility will be handled in the skin. Please see:
I120f8f7114b33d2cfbd1c3c57ebf41f8b2d7fec4

Note:
This patch doesn't provide tests for the inline script because
* It is not possible to add this to this patch at the current time since we need to determine
whether or not it is okay to call file_get_contents at such a critical time
* the tests are written in Jest due to limitations with our QUnit test framework.

For development purposes a standalone test patch was added I6ad496bb25a4bd523fa69f2e6d936d7eb643793e
A task will be created on merge to explore incorporating these tests in the codebase.

Bug: T339268
Bug: T341720
Change-Id: I1e635f843ac9b2f248b1f7618134598e80291b38
This commit is contained in:
Moh'd Khier Abualruz 2023-08-03 14:23:04 +03:00 committed by Jdlrobson
parent cafaa7b712
commit a9045f21f7
3 changed files with 248 additions and 10 deletions

View file

@ -264,19 +264,40 @@ document.documentElement.className = {$jsClassJson};
if ( $this->options['clientPrefEnabled'] ) {
$cookiePrefix = $this->options['clientPrefCookiePrefix'];
// ( T339268 ) Add/Modify client preference classes to documentElement from cookie
// this is done to have preferences available before the DOM, so it renders as intended
$script .= <<<JS
( function () {
var cookie = document.cookie.match( /(?:^|; ){$cookiePrefix}mwclientprefs=([^;]+)/ );
// For now, only support disabling a feature
// Only supports a single feature (modifying a single class) at this stage.
// In future this may be expanded to multiple once this has been proven as viable.
if ( cookie ) {
var featureName = cookie[1];
document.documentElement.className = document.documentElement.className.replace(
featureName + '-enabled',
featureName + '-disabled'
);
var cookie = document.cookie.match( /(?:^|; ){$cookiePrefix}mwclientpreferences=([^;]+)/ );
// cookie match is a ! separated pairs with the pair separator ~ "key1~value1!key2~value2"
var prefArray = cookie && cookie[ 1 ] ? cookie[ 1 ].split( '!' ) : [];
var clientPreferences = {};
for ( var i = 0; i < prefArray.length; i++ ) {
var pair = prefArray[i].split( '~' );
clientPreferences[ pair[ 0 ] ] = pair[ 1 ];
}
// Only uses existing classes, for maintainability
// for new feature, add the new class a couple of weeks before the feature is deployed
// so that the feature class is already in the cache when the feature is deployed
// regex explanation:
// (^| ) = start of string or space
// ([^ ]+) = one or more non-space characters
// -clientpref- = literal string
// [a-zA-Z0-9]+ = one or more alphanumeric characters
// ( |$) = end of string or space
// Regex replace examples:
// "vector-feature-foo-clientpref-0" -> "vector-feature-foo-clientpref-1"
// "mw-pref-bar-clientpref-2" -> "mw-pref-bar-clientpref-4"
// "mw-pref-bar-display-clientpref-dark" -> "mw-pref-bar-display-clientpref-light"
document.documentElement.className = document.documentElement.className.replace(
/(^| )([^ ]+)-clientpref-[a-zA-Z0-9]+( |$)/g,
function ( match, before, key, after ) {
if ( clientPreferences.hasOwnProperty( key ) ) {
return before + key + '-clientpref-' + clientPreferences[ key ] + after;
}
return match;
}
);
} () );
JS;
}

View file

@ -4,6 +4,10 @@
*/
( function () {
var userInfoPromise, tempUserNamePromise, pageviewRandomId, sessionId;
var COOKIE_NAME = 'mwclientpreferences',
FEATURE_SUFFIX = '-clientpref-',
FEATURE_DELIMITER = '!',
KEY_VALUE_DELIMITER = '~';
/**
* Get the current user's groups or rights
@ -18,6 +22,50 @@
return userInfoPromise;
}
/**
* Updates the existing client preferences stored in cookies along with a newly set
* feature/value pair
*
* @param {string} feature that was just modified.
* @param {string} value of newly modified feature
*/
function syncHTMLWithCookie( feature, value ) {
var existingCookie = mw.cookie.get( COOKIE_NAME );
var storeFromCookie = {};
if ( existingCookie ) {
existingCookie.split( FEATURE_DELIMITER ).forEach( function ( keyValuePair ) {
var kV = keyValuePair.split( KEY_VALUE_DELIMITER );
storeFromCookie[ kV[ 0 ] ] = kV[ 1 ];
} );
}
storeFromCookie[ feature ] = value;
var cookieValue = Object.keys( storeFromCookie ).map( function ( key ) {
return key + KEY_VALUE_DELIMITER + storeFromCookie[ key ];
} ).join( FEATURE_DELIMITER );
mw.cookie.set( COOKIE_NAME, cookieValue );
}
/**
* Checks if the feature is composed of valid characters.
* A valid feature name can contain letters, numbers of "-" character.
*
* @param {string} value
* @return {boolean}
*/
function isValidFeatureName( value ) {
return value.match( /^[a-zA-Z0-9-]+$/ ) !== null;
}
/**
* Checks if the value is composed of valid characters.
*
* @param {string} value
* @return {boolean}
*/
function isValidFeatureValue( value ) {
return value.match( /^[a-zA-Z0-9]+$/ ) !== null;
}
// mw.user with the properties options and tokens gets defined in mediawiki.base.js.
Object.assign( mw.user, {
@ -261,6 +309,63 @@
function ( userInfo ) { return userInfo.rights; },
function () { return []; }
).then( callback );
},
/**
* Client preferences store's management
*/
clientPrefs: {
/**
* Change the class of the document element, and set feature value in clientPreferencesStore
*
* @param {string} feature
* @param {string} value
* @return {boolean} true if feature was stored successfully, false if the value
* uses a forbidden character or the feature is not recognised
* e.g. an appropriate class has not been defined on the body.
*/
set: function ( feature, value ) {
if ( !isValidFeatureName( feature ) || !isValidFeatureValue( value ) ) {
return false;
}
var currentValue = this.get( feature );
// the feature is not recognized
if ( !currentValue ) {
return false;
}
var oldFeatureClass = feature + FEATURE_SUFFIX + currentValue;
var newFeatureClass = feature + FEATURE_SUFFIX + value;
// The following classes are removed here:
// * feature-name-clientpref-<old-feature-value>
// * e.g. vector-font-size--clientpref-small
document.documentElement.classList.remove( oldFeatureClass );
// The following classes are added here:
// * feature-name-clientpref-<feature-value>
// * e.g. vector-font-size--clientpref-xlarge
document.documentElement.classList.add( newFeatureClass );
syncHTMLWithCookie( feature, value );
return true;
},
/**
* Retrieve the current value of the feature from the HTML element
*
* @param {string} feature
* @return {string|boolean} returns boolean if the feature is not recognized
* returns string if a feature was found.
*/
get: function ( feature ) {
var featurePrefix = feature + FEATURE_SUFFIX;
var docClass = document.documentElement.classList.toString();
var featureRegEx = new RegExp(
'(^| )' + mw.util.escapeRegExp( featurePrefix ) + '([a-zA-Z0-9]+)( |$)'
);
var match = docClass.match( featureRegEx );
// check no further matches if we replaced this occurance.
var isAmbiguous = docClass.replace( featureRegEx, '$1$3' ).match( featureRegEx ) !== null;
return !isAmbiguous && match ? match[ 2 ] : false;
}
}
} );

View file

@ -1,6 +1,12 @@
( function () {
var CLIENT_PREF_COOKIE_NAME = 'mwclientpreferences';
var docClass;
QUnit.module( 'mediawiki.user', QUnit.newMwEnvironment( {
beforeEach: function () {
docClass = document.documentElement.getAttribute( 'class' );
document.documentElement.setAttribute( 'class', '' );
// reset any cookies
mw.cookie.set( CLIENT_PREF_COOKIE_NAME, null );
this.server = this.sandbox.useFakeServer();
this.server.respondImmediately = true;
// Cannot stub by simple assignment because read-only.
@ -11,6 +17,8 @@
this.msCrypto = Object.getOwnPropertyDescriptor( window, 'msCrypto' );
},
afterEach: function () {
mw.cookie.set( CLIENT_PREF_COOKIE_NAME, null );
document.documentElement.setAttribute( 'class', docClass );
if ( this.crypto ) {
Object.defineProperty( window, 'crypto', this.crypto );
}
@ -139,4 +147,108 @@
assert.strictEqual( result.trim(), result, 'no leading or trailing whitespace' );
assert.strictEqual( result2, result, 'retained' );
} );
QUnit.test( 'clientPrefs.get: client preferences are always obtained from HTML element the one source of truth', function ( assert ) {
document.documentElement.setAttribute( 'class', 'client-js font-size-clientpref-1 font-size-clientpref-unrelated-class invalid-clientpref-bad-value ambiguous-clientpref-off ambiguous-clientpref-on' );
var result = mw.user.clientPrefs.get( 'font-size' );
var badValue = mw.user.clientPrefs.get( 'invalid' );
var ambiguousValue = mw.user.clientPrefs.get( 'ambiguous' );
assert.strictEqual( result, '1', 'client preferences are read from HTML element' );
assert.strictEqual( badValue, false, 'classes in the wrong format are ignored.' );
assert.strictEqual( ambiguousValue, false, 'ambiguous values are resolved to false' );
} );
QUnit.test( 'clientPrefs.get: client preferences never read from cookie', function ( assert ) {
mw.cookie.set( CLIENT_PREF_COOKIE_NAME, 'unknown~500' );
var resultUnknown = mw.user.clientPrefs.get( 'unknown' );
assert.false( resultUnknown,
'if an appropriate class is not on the HTML element it returns false even if there is a value in the cookie' );
} );
QUnit.test( 'clientPrefs.set: can set client valid preferences', function ( assert ) {
document.documentElement.classList.add( 'limited-width-clientpref-1', 'font-size-clientpref-100' );
var resultLimitedWidth = mw.user.clientPrefs.set( 'limited-width', '0' );
var resultFontSize = mw.user.clientPrefs.set( 'font-size', '10' );
assert.true( resultLimitedWidth, 'the client preference limited width was set correctly' );
assert.true( resultFontSize, 'the client preference font size was set correctly' );
assert.true( document.documentElement.classList.contains( 'limited-width-clientpref-0' ),
'the limited width class on the document was updated' );
assert.true( document.documentElement.classList.contains( 'font-size-clientpref-10' ),
'the font size classes on the document was updated correctly' );
const mwclientpreferences = mw.cookie.get( CLIENT_PREF_COOKIE_NAME );
assert.true(
mwclientpreferences &&
mwclientpreferences.includes( 'limited-width~0' ) &&
mwclientpreferences.includes( 'font-size~10' ) &&
mwclientpreferences.includes( '!' ),
'cookie was set correctly'
);
} );
QUnit.test( 'clientPrefs.set: cannot set invalid valid preferences', function ( assert ) {
document.documentElement.classList.add( 'client-js' );
var result = mw.user.clientPrefs.set( 'client', 'nojs' );
assert.false( result, 'the client preference was rejected (lacking -clientpref- suffix)' );
assert.true( document.documentElement.classList.contains( 'client-js' ), 'the classes on the document were not changed' );
} );
QUnit.test( 'clientPrefs.set: cannot set preferences with invalid characters', function ( assert ) {
document.documentElement.setAttribute( 'class', 'client-js bar-clientpref-1' );
[
[ 'client-js bar', 'nojs' ],
[ 'bar-clientpref', '50' ],
[ 'bar', ' client-nojs' ],
[ 'bar', 'client-nojs' ],
[ '', 'nothing' ],
[ 'feature', '' ],
[ 'foo!client~no-js!bar', 'hi' ]
].forEach( function ( test, i ) {
var result = mw.user.clientPrefs.set( test[ 0 ], test[ 1 ] );
assert.false( result, 'the client preference was rejected (invalid characters in name) (test case ' + i + ')' );
} );
assert.strictEqual( document.documentElement.getAttribute( 'class' ), 'client-js bar-clientpref-1' );
} );
QUnit.test( 'clientPrefs.set: always set cookie when manipulating preferences', function ( assert ) {
document.documentElement.setAttribute( 'class', 'dark-mode-clientpref-enabled' );
var result = mw.user.clientPrefs.set( 'dark-mode', 'disabled' );
assert.true( result, 'the client preference was stored successfully' );
assert.strictEqual(
document.documentElement.getAttribute( 'class' ),
'dark-mode-clientpref-disabled',
'the class was modified'
);
let mwclientpreferences = mw.cookie.get( CLIENT_PREF_COOKIE_NAME );
assert.true(
mwclientpreferences &&
mwclientpreferences.includes( 'dark-mode~disabled' ),
'it was stored to a cookie'
);
result = mw.user.clientPrefs.set( 'dark-mode', 'enabled' );
assert.true( result, 'the 2nd client preference was also stored successfully' );
assert.strictEqual(
document.documentElement.getAttribute( 'class' ),
'dark-mode-clientpref-enabled',
'the class was also modified again'
);
mwclientpreferences = mw.cookie.get( CLIENT_PREF_COOKIE_NAME );
assert.strictEqual(
mwclientpreferences,
'dark-mode~enabled',
'always store even if it matches default as we have no knowledge of what the default could be'
);
} );
QUnit.test( 'clientPrefs.set: Only store values that are explicitly set', function ( assert ) {
// set cookie and body classes
document.documentElement.setAttribute( 'class', 'client-js not-a-feature-clientpref-bad-value font-clientpref-32 dark-mode-clientpref-32 limited-width-clientpref-enabled' );
mw.user.clientPrefs.set( 'dark-mode', 'enabled' );
const mwclientpreferences = mw.cookie.get( CLIENT_PREF_COOKIE_NAME );
assert.strictEqual(
mwclientpreferences,
'dark-mode~enabled',
'always store even if it matches default as we have no knowledge of what the default could be'
);
} );
}() );