diff --git a/README.md b/README.md index 652f05c0..7e2b5922 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,11 @@ Vector Skin Installation ------------ -See +See . + +### Configuration options + +See [skin.json](skin.json). Development ----------- @@ -19,3 +23,26 @@ Additions and deviations from those conventions that are more tailored to this project are noted at: + +URL query parameters +-------------------- + +- `useskinversion`: Like `useskin` but for overriding the Vector skin version + user preference and configuration. + +Skin preferences +---------------- + +Vector defines skin-specific user preferences. These are exposed on +Special:Preferences when the `VectorShowSkinPreferences` configuration is +enabled. The user's preference state for skin preferences is used for skin +previews and any other operation unless specified otherwise. + +### Version + +Vector defines a "version" preference to enable users who prefer the December +2019 version of Vector to continue to do so without any visible changes. This +version is called "Legacy Vector." The related preference defaults are +configurable via the configurations prefixed with `VectorDefaultSkinVersion`. +Version preference and configuration may be overridden by the `useskinversion` +URL query parameter. diff --git a/i18n/en.json b/i18n/en.json index b4d2769e..62814dfd 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -4,6 +4,9 @@ }, "skinname-vector": "Vector", "vector-skin-desc": "Modern version of MonoBook with fresh look and many usability improvements", + "prefs-skin-prefs": "Vector skin preferences", + "prefs-vector-enable-vector-1-label": "Use Legacy Vector", + "prefs-vector-enable-vector-1-help": "Over the next few years, we will be gradually updating the Vector skin. Legacy Vector will allow you to view the old version of Vector (as of December 2019). To learn more about the updates, go to our [[mw:Reading/Web/Desktop_Improvements|project page]].", "vector.css": "/* All CSS here will be loaded for users of the Vector skin */", "vector.js": "/* All JavaScript here will be loaded for users of the Vector skin */", "vector-action-addsection": "Add topic", diff --git a/i18n/qqq.json b/i18n/qqq.json index 209edddb..345bbbd5 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -15,6 +15,9 @@ }, "skinname-vector": "{{name}}", "vector-skin-desc": "{{desc|what=skin|name=Vector|url=https://www.mediawiki.org/wiki/Skin:Vector}}", + "prefs-skin-prefs": "Title for Vector-specific user preferences shown on Special:Preferences.", + "prefs-vector-enable-vector-1-label": "Label for the checkbox to force Legacy Vector operation accessible via Special:Preferences. When this checkbox is enabled, the December 2019 of Vector is used. When this checkbox is disabled, the actively developed version of Vector is used instead.", + "prefs-vector-enable-vector-1-help": "Detail explaining the operation of the prefs-vector-enable-vector-1-label checkbox.", "vector.css": "{{optional}}", "vector.js": "{{optional}}", "vector-action-addsection": "Used in the Vector skin. See for example {{canonicalurl:Talk:Main_Page|useskin=vector}}\n{{Identical|Add topic}}", diff --git a/includes/Constants.php b/includes/Constants.php new file mode 100644 index 00000000..10bb7ccc --- /dev/null +++ b/includes/Constants.php @@ -0,0 +1,70 @@ +() @@ -28,4 +31,127 @@ class Hooks { $sk->enableResponsiveMode(); } } + + /** + * Add Vector preferences to the user's Special:Preferences page directly underneath skins. + * + * @param User $user User whose preferences are being modified. + * @param array[] &$prefs Preferences description array, to be fed to a HTMLForm object. + */ + public static function onGetPreferences( User $user, array &$prefs ) { + if ( !self::getConfig( Constants::CONFIG_KEY_SHOW_SKIN_PREFERENCES ) ) { + // Do not add Vector skin specific preferences. + return; + } + + // Preferences to add. + $vectorPrefs = [ + Constants::PREF_KEY_SKIN_VERSION => [ + 'type' => 'toggle', + // The checkbox title. + 'label-message' => 'prefs-vector-enable-vector-1-label', + // Show a little informational snippet underneath the checkbox. + 'help-message' => 'prefs-vector-enable-vector-1-help', + // The tab location and title of the section to insert the checkbox. The bit after the slash + // indicates that a prefs-skin-prefs string will be provided. + 'section' => 'rendering/skin-prefs', + // Convert the preference string to a boolean presentation. + 'default' => + $user->getOption( Constants::PREF_KEY_SKIN_VERSION ) === Constants::SKIN_VERSION_LATEST ? + '0' : + '1', + // Only show this section when the Vector skin is checked. The JavaScript client also uses + // this state to determine whether to show or hide the whole section. + 'hide-if' => [ '!==', 'wpskin', Constants::SKIN_NAME ] + ], + ]; + + // Seek the skin preference section to add Vector preferences just below it. + $skinSectionIndex = array_search( 'skin', array_keys( $prefs ) ); + if ( $skinSectionIndex !== false ) { + // Skin preference section found. Inject Vector skin-specific preferences just below it. + // This pattern can be found in Popups too. See T246162. + $vectorSectionIndex = $skinSectionIndex + 1; + $prefs = array_slice( $prefs, 0, $vectorSectionIndex, true ) + + $vectorPrefs + + array_slice( $prefs, $vectorSectionIndex, null, true ); + } else { + // Skin preference section not found. Just append Vector skin-specific preferences. + $prefs += $vectorPrefs; + } + } + + /** + * Hook executed on user's Special:Preferences form save. This is used to convert the boolean + * presentation of skin version to a version string. That is, a single preference change by the + * user may trigger two writes: a boolean followed by a string. + * + * @param array $formData Form data submitted by user + * @param HTMLForm $form A preferences form + * @param User $user Logged-in user + * @param bool &$result Variable defining is form save successful + * @param array $oldPreferences + */ + public static function onPreferencesFormPreSave( + array $formData, + HTMLForm $form, + User $user, + &$result, + $oldPreferences + ) { + $preference = null; + $isVectorEnabled = ( $formData[ 'skin' ] ?? '' ) === Constants::SKIN_NAME; + if ( $isVectorEnabled && array_key_exists( Constants::PREF_KEY_SKIN_VERSION, $formData ) ) { + // A preference was set. However, Special:Preferences converts the result to a boolean when a + // version name string is wanted instead. Convert the boolean to a version string in case the + // preference display is changed to a list later (e.g., a "_new_ new Vector" / '3' or + // 'alpha'). + $preference = $formData[ Constants::PREF_KEY_SKIN_VERSION ] ? + Constants::SKIN_VERSION_LEGACY : + Constants::SKIN_VERSION_LATEST; + } elseif ( array_key_exists( Constants::PREF_KEY_SKIN_VERSION, $oldPreferences ) ) { + // The setting was cleared. However, this is likely because a different skin was chosen and + // the skin version preference was hidden. + $preference = $oldPreferences[ Constants::PREF_KEY_SKIN_VERSION ]; + } + if ( $preference !== null ) { + $user->setOption( Constants::PREF_KEY_SKIN_VERSION, $preference ); + } + } + + /** + * Called on each pageview to populate preference defaults for existing users. + * + * @param array &$defaultPrefs + */ + public static function onUserGetDefaultOptions( array &$defaultPrefs ) { + $default = self::getConfig( Constants::CONFIG_KEY_DEFAULT_SKIN_VERSION_FOR_EXISTING_ACCOUNTS ); + $defaultPrefs[ Constants::PREF_KEY_SKIN_VERSION ] = $default; + } + + /** + * Called one time when initializing a users preferences for a newly created account. + * + * @param User $user Newly created user object. + * @param bool $isAutoCreated + */ + public static function onLocalUserCreated( User $user, $isAutoCreated ) { + $default = self::getConfig( Constants::CONFIG_KEY_DEFAULT_SKIN_VERSION_FOR_NEW_ACCOUNTS ); + // Permanently set the default preference. The user can later change this preference, however, + // self::onLocalUserCreated() will not be executed for that account again. + $user->setOption( Constants::PREF_KEY_SKIN_VERSION, $default ); + } + + /** + * Get a configuration variable such as `Constants::CONFIG_KEY_SHOW_SKIN_PREFERENCES`. + * + * @param string $name Name of configuration option. + * @return mixed Value configured. + * @throws \ConfigException + */ + private static function getConfig( $name ) { + /* @var Config */ $service = + MediaWikiServices::getInstance()->getService( Constants::SERVICE_CONFIG ); + return $service->get( $name ); + } } diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index 432a85c1..0366cbeb 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -23,10 +23,14 @@ */ use MediaWiki\MediaWikiServices; +use Vector\Constants; use Vector\FeatureManagement\FeatureManager; return [ - 'Vector.FeatureManager' => function ( MediaWikiServices $services ) { + Constants::SERVICE_CONFIG => function ( MediaWikiServices $services ) { + return $services->getService( 'ConfigFactory' )->makeConfig( Constants::SKIN_NAME ); + }, + Constants::SERVICE_FEATURE_MANAGER => function ( MediaWikiServices $services ) { return new FeatureManager(); } ]; diff --git a/skin.json b/skin.json index dfb75eaa..a85c31fd 100644 --- a/skin.json +++ b/skin.json @@ -22,6 +22,7 @@ ] }, "AutoloadClasses": { + "Vector\\Constants": "includes/Constants.php", "Vector\\Hooks": "includes/Hooks.php", "SkinVector": "includes/SkinVector.php", "VectorTemplate": "includes/VectorTemplate.php" @@ -29,8 +30,15 @@ "AutoloadNamespaces": { "Vector\\FeatureManagement\\": "includes/FeatureManagement/" }, + "ConfigRegistry": { + "vector": "GlobalVarConfig::newInstance" + }, "Hooks": { - "BeforePageDisplayMobile": "Vector\\Hooks::onBeforePageDisplayMobile" + "BeforePageDisplayMobile": "Vector\\Hooks::onBeforePageDisplayMobile", + "GetPreferences": "Vector\\Hooks::onGetPreferences", + "PreferencesFormPreSave": "Vector\\Hooks::onPreferencesFormPreSave", + "UserGetDefaultOptions": "Vector\\Hooks::onUserGetDefaultOptions", + "LocalUserCreated": "Vector\\Hooks::onLocalUserCreated" }, "@note": "When modifying skins.vector.styles definition, make sure the installer still works", "ResourceModules": { @@ -109,6 +117,22 @@ }, "VectorResponsive": { "value": false + }, + "VectorShowSkinPreferences": { + "value": true, + "description": "@var boolean Show skin-specific user preferences on the Special:Preferences appearance tab when true and hide them otherwise." + }, + "VectorDefaultSkinVersion": { + "value": "2", + "description": "@var string:['2'|'1'] The version ('2' for latest, '1' for legacy) of the Vector skin to use for anonymous users and as a fallback." + }, + "VectorDefaultSkinVersionForExistingAccounts": { + "value": "2", + "description": "@var string:['2'|'1'] The version ('2' for latest, '1' for legacy) of the Vector skin to use when an existing user has not specified a preference. This configuration is not used for new accounts (see VectorDefaultSkinVersionForNewAccounts) and is impermanent. In the future, this field may contains versions such as \"beta\" which when specified and the BetaFeatures extension is installed, and the user is enrolled, the latest version is used otherwise legacy." + }, + "VectorDefaultSkinVersionForNewAccounts": { + "value": "2", + "description": "@var string:['2'|'1'] The version ('2' for latest, '1' for legacy) of the Vector skin to **set** for newly created user accounts. This configuration is not used for preexisting accounts (see VectorDefaultSkinVersion) and only ever executed once at new account creation time." } }, "ServiceWiringFiles": [ diff --git a/tests/phpunit/integration/VectorHooksTest.php b/tests/phpunit/integration/VectorHooksTest.php new file mode 100644 index 00000000..a135177a --- /dev/null +++ b/tests/phpunit/integration/VectorHooksTest.php @@ -0,0 +1,308 @@ + false, + ] ); + $this->setService( 'Vector.Config', $config ); + + $prefs = []; + Hooks::onGetPreferences( $this->getTestUser()->getUser(), $prefs ); + $this->assertSame( $prefs, [], 'No preferences are added.' ); + } + + /** + * @covers ::onGetPreferences + */ + public function testOnGetPreferencesShowPreferencesEnabledSkinSectionFoundLegacy() { + $config = new HashConfig( [ + 'VectorShowSkinPreferences' => true, + // Required by test user's onUserGetDefaultOptions() hook but unused for this test. + 'VectorDefaultSkinVersionForExistingAccounts' => '1', + ] ); + $this->setService( 'Vector.Config', $config ); + + $prefs = [ + 'foo' => [], + 'skin' => [], + 'bar' => [] + ]; + Hooks::onGetPreferences( $this->getTestUser()->getUser(), $prefs ); + $this->assertSame( + $prefs, + [ + 'foo' => [], + 'skin' => [], + 'VectorSkinVersion' => [ + 'type' => 'toggle', + 'label-message' => 'prefs-vector-enable-vector-1-label', + 'help-message' => 'prefs-vector-enable-vector-1-help', + 'section' => 'rendering/skin-prefs', + // '1' is enabled which means Legacy. + 'default' => '1', + 'hide-if' => [ '!==', 'wpskin', 'vector' ] + ], + 'bar' => [] + ], + 'Preferences are inserted directly after skin.' + ); + } + + /** + * @covers ::onGetPreferences + */ + public function testOnGetPreferencesShowPreferencesEnabledSkinSectionMissingLegacy() { + $config = new HashConfig( [ + 'VectorShowSkinPreferences' => true, + // Required by test user's onUserGetDefaultOptions() hook but unused for this test. + 'VectorDefaultSkinVersionForExistingAccounts' => '1', + ] ); + $this->setService( 'Vector.Config', $config ); + + $prefs = [ + 'foo' => [], + 'bar' => [] + ]; + Hooks::onGetPreferences( $this->getTestUser()->getUser(), $prefs ); + $this->assertSame( + $prefs, + [ + 'foo' => [], + 'bar' => [], + 'VectorSkinVersion' => [ + 'type' => 'toggle', + 'label-message' => 'prefs-vector-enable-vector-1-label', + 'help-message' => 'prefs-vector-enable-vector-1-help', + 'section' => 'rendering/skin-prefs', + // '1' is enabled which means Legacy. + 'default' => '1', + 'hide-if' => [ '!==', 'wpskin', 'vector' ] + ], + ], + 'Preferences are appended.' + ); + } + + /** + * @covers ::onGetPreferences + */ + public function testOnGetPreferencesShowPreferencesEnabledSkinSectionMissingLatest() { + $config = new HashConfig( [ + 'VectorShowSkinPreferences' => true, + // Required by test user's onUserGetDefaultOptions() hook but unused for this test. + 'VectorDefaultSkinVersionForExistingAccounts' => '1', + ] ); + $this->setService( 'Vector.Config', $config ); + + $prefs = [ + 'foo' => [], + 'bar' => [], + ]; + $user = $this->createMock( \User::class ); + $user->expects( $this->once() ) + ->method( 'getOption' ) + ->with( 'VectorSkinVersion' ) + // '2' is latest. + ->will( $this->returnValue( '2' ) ); + Hooks::onGetPreferences( $user, $prefs ); + $this->assertSame( + $prefs, + [ + 'foo' => [], + 'bar' => [], + 'VectorSkinVersion' => [ + 'type' => 'toggle', + 'label-message' => 'prefs-vector-enable-vector-1-label', + 'help-message' => 'prefs-vector-enable-vector-1-help', + 'section' => 'rendering/skin-prefs', + // '0' is disabled (which means latest). + 'default' => '0', + 'hide-if' => [ '!==', 'wpskin', 'vector' ] + ], + ], + 'Legacy skin version is disabled.' + ); + } + + /** + * @covers ::onPreferencesFormPreSave + */ + public function testOnPreferencesFormPreSaveVectorEnabledLegacyNewPreference() { + $formData = [ + 'skin' => 'vector', + // True is Legacy. + 'VectorSkinVersion' => true, + ]; + $form = $this->createMock( HTMLForm::class ); + $user = $this->createMock( \User::class ); + $user->expects( $this->once() ) + ->method( 'setOption' ) + // '1' is Legacy. + ->with( 'VectorSkinVersion', '1' ); + $result = true; + $oldPreferences = []; + + Hooks::onPreferencesFormPreSave( $formData, $form, $user, $result, $oldPreferences ); + } + + /** + * @covers ::onPreferencesFormPreSave + */ + public function testOnPreferencesFormPreSaveVectorEnabledLatestNewPreference() { + $formData = [ + 'skin' => 'vector', + // False is latest. + 'VectorSkinVersion' => false, + ]; + $form = $this->createMock( HTMLForm::class ); + $user = $this->createMock( \User::class ); + $user->expects( $this->once() ) + ->method( 'setOption' ) + // '2' is latest. + ->with( 'VectorSkinVersion', '2' ); + $result = true; + $oldPreferences = []; + + Hooks::onPreferencesFormPreSave( $formData, $form, $user, $result, $oldPreferences ); + } + + /** + * @covers ::onPreferencesFormPreSave + */ + public function testOnPreferencesFormPreSaveVectorEnabledNoNewPreference() { + $formData = [ + 'skin' => 'vector', + ]; + $form = $this->createMock( HTMLForm::class ); + $user = $this->createMock( \User::class ); + $user->expects( $this->never() ) + ->method( 'setOption' ); + $result = true; + $oldPreferences = []; + + Hooks::onPreferencesFormPreSave( $formData, $form, $user, $result, $oldPreferences ); + } + + /** + * @covers ::onPreferencesFormPreSave + */ + public function testOnPreferencesFormPreSaveVectorDisabledNoOldPreference() { + $formData = [ + // False is latest. + 'VectorSkinVersion' => false, + ]; + $form = $this->createMock( HTMLForm::class ); + $user = $this->createMock( \User::class ); + $user->expects( $this->never() ) + ->method( 'setOption' ); + $result = true; + $oldPreferences = []; + + Hooks::onPreferencesFormPreSave( $formData, $form, $user, $result, $oldPreferences ); + } + + /** + * @covers ::onPreferencesFormPreSave + */ + public function testOnPreferencesFormPreSaveVectorDisabledOldPreference() { + $formData = [ + // False is latest. + 'VectorSkinVersion' => false, + ]; + $form = $this->createMock( HTMLForm::class ); + $user = $this->createMock( \User::class ); + $user->expects( $this->once() ) + ->method( 'setOption' ) + ->with( 'VectorSkinVersion', 'old' ); + $result = true; + $oldPreferences = [ + 'VectorSkinVersion' => 'old', + ]; + + Hooks::onPreferencesFormPreSave( $formData, $form, $user, $result, $oldPreferences ); + } + + /** + * @covers ::onUserGetDefaultOptions + */ + public function testOnUserGetDefaultOptionsLegacy() { + $config = new HashConfig( [ + // '1' is Legacy. + 'VectorDefaultSkinVersionForExistingAccounts' => '1', + ] ); + $this->setService( 'Vector.Config', $config ); + + $prefs = []; + Hooks::onUserGetDefaultOptions( $prefs ); + $this->assertSame( $prefs, [ 'VectorSkinVersion' => '1' ], 'Version is Legacy.' ); + } + + /** + * @covers ::onUserGetDefaultOptions + */ + public function testOnUserGetDefaultOptionsLatest() { + $config = new HashConfig( [ + // '2' is latest. + 'VectorDefaultSkinVersionForExistingAccounts' => '2', + ] ); + $this->setService( 'Vector.Config', $config ); + + $prefs = []; + Hooks::onUserGetDefaultOptions( $prefs ); + $this->assertSame( $prefs, [ 'VectorSkinVersion' => '2' ], 'Version is latest.' ); + } + + /** + * @covers ::onLocalUserCreated + */ + public function testOnLocalUserCreatedLegacy() { + $config = new HashConfig( [ + // '1' is Legacy. + 'VectorDefaultSkinVersionForNewAccounts' => '1', + ] ); + $this->setService( 'Vector.Config', $config ); + + $user = $this->createMock( \User::class ); + $user->expects( $this->once() ) + ->method( 'setOption' ) + // '1' is Legacy. + ->with( 'VectorSkinVersion', '1' ); + $isAutoCreated = false; + Hooks::onLocalUserCreated( $user, $isAutoCreated ); + } + + /** + * @covers ::onLocalUserCreated + */ + public function testOnLocalUserCreatedLatest() { + $config = new HashConfig( [ + // '2' is latest. + 'VectorDefaultSkinVersionForNewAccounts' => '2', + ] ); + $this->setService( 'Vector.Config', $config ); + + $user = $this->createMock( \User::class ); + $user->expects( $this->once() ) + ->method( 'setOption' ) + // '2' is latest. + ->with( 'VectorSkinVersion', '2' ); + $isAutoCreated = false; + Hooks::onLocalUserCreated( $user, $isAutoCreated ); + } +}