Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
71.43% |
60 / 84 |
|
55.56% |
5 / 9 |
CRAP | |
0.00% |
0 / 1 |
| FeatureManager | |
71.43% |
60 / 84 |
|
55.56% |
5 / 9 |
74.48 | |
0.00% |
0 / 1 |
| __construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| registerFeature | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| getUserPreferenceValue | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| getFeatureBodyClass | |
78.38% |
29 / 37 |
|
0.00% |
0 / 1 |
19.92 | |||
| isFeatureEnabled | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| registerRequirement | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| registerSimpleRequirement | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isRequirementMet | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| resolveNightModeQueryValue | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
56 | |||
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * This program is free software; you can redistribute it and/or modify |
| 5 | * it under the terms of the GNU General Public License as published by |
| 6 | * the Free Software Foundation; either version 2 of the License, or |
| 7 | * (at your option) any later version. |
| 8 | * |
| 9 | * This program is distributed in the hope that it will be useful, |
| 10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | * GNU General Public License for more details. |
| 13 | * |
| 14 | * You should have received a copy of the GNU General Public License along |
| 15 | * with this program; if not, write to the Free Software Foundation, Inc., |
| 16 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
| 17 | * http://www.gnu.org/copyleft/gpl.html |
| 18 | * |
| 19 | * @file |
| 20 | * @since 1.35 |
| 21 | */ |
| 22 | |
| 23 | namespace MediaWiki\Skins\Vector\FeatureManagement; |
| 24 | |
| 25 | use MediaWiki\Context\IContextSource; |
| 26 | use MediaWiki\Skins\Vector\ConfigHelper; |
| 27 | use MediaWiki\Skins\Vector\Constants; |
| 28 | use MediaWiki\Skins\Vector\FeatureManagement\Requirements\SimpleRequirement; |
| 29 | use MediaWiki\User\Options\UserOptionsLookup; |
| 30 | use RuntimeException; |
| 31 | use Wikimedia\Assert\Assert; |
| 32 | |
| 33 | /** |
| 34 | * A simple feature manager. |
| 35 | * |
| 36 | * NOTE: This API hasn't settled. It may change at any time without warning. Please don't bind to |
| 37 | * it unless you absolutely need to. |
| 38 | * |
| 39 | * @unstable |
| 40 | * |
| 41 | * @package MediaWiki\Skins\Vector\FeatureManagement |
| 42 | * @internal |
| 43 | * @final |
| 44 | */ |
| 45 | class FeatureManager { |
| 46 | |
| 47 | /** |
| 48 | * A map of feature name to the array of requirements (referenced by name). A feature is only |
| 49 | * considered enabled when all of its requirements are met. |
| 50 | * |
| 51 | * See FeatureManager::registerFeature for additional detail. |
| 52 | * |
| 53 | * @var Array<string,string[]> |
| 54 | */ |
| 55 | private $features = []; |
| 56 | |
| 57 | /** |
| 58 | * A map of requirement name to the Requirement instance that represents it. |
| 59 | * |
| 60 | * The names of requirements are assumed to be static for the lifetime of the request. Therefore |
| 61 | * we can use them to look up Requirement instances quickly. |
| 62 | * |
| 63 | * @var Array<string,Requirement> |
| 64 | */ |
| 65 | private $requirements = []; |
| 66 | |
| 67 | public function __construct( |
| 68 | private readonly ConfigHelper $configHelper, |
| 69 | private readonly UserOptionsLookup $userOptionsLookup, |
| 70 | private readonly IContextSource $context, |
| 71 | ) { |
| 72 | } |
| 73 | |
| 74 | /** |
| 75 | * Register a feature and its requirements. |
| 76 | * |
| 77 | * Essentially, a "feature" is a friendly (hopefully) name for some component, however big or |
| 78 | * small, that has some requirements. A feature manager allows us to decouple the component's |
| 79 | * logic from its requirements, allowing them to vary independently. Moreover, the use of |
| 80 | * friendly names wherever possible allows us to define a common language with our non-technical |
| 81 | * colleagues. |
| 82 | * |
| 83 | * ```php |
| 84 | * $featureManager->registerFeature( 'featureA', 'requirementA' ); |
| 85 | * ``` |
| 86 | * |
| 87 | * defines the "featureA" feature, which is enabled when the "requirementA" requirement is met. |
| 88 | * |
| 89 | * ```php |
| 90 | * $featureManager->registerFeature( 'featureB', [ 'requirementA', 'requirementB' ] ); |
| 91 | * ``` |
| 92 | * |
| 93 | * defines the "featureB" feature, which is enabled when the "requirementA" and "requirementB" |
| 94 | * requirements are met. Note well that the feature is only enabled when _all_ requirements are |
| 95 | * met, i.e. the requirements are evaluated in order and logically `AND`ed together. |
| 96 | * |
| 97 | * @param string $feature The name of the feature |
| 98 | * @param string|array $requirements The feature's requirements. As above, you can define a |
| 99 | * feature that requires a single requirement via the shorthand |
| 100 | * |
| 101 | * ```php |
| 102 | * $featureManager->registerFeature( 'feature', 'requirementA' ); |
| 103 | * // Equivalent to $featureManager->registerFeature( 'feature', [ 'requirementA' ] ); |
| 104 | * ``` |
| 105 | * |
| 106 | * @throws \LogicException If the feature is already registered |
| 107 | * @throws \Wikimedia\Assert\ParameterAssertionException If the feature's requirements aren't |
| 108 | * the name of a single requirement or a list of requirements |
| 109 | * @throws \InvalidArgumentException If the feature references a requirement that isn't |
| 110 | * registered |
| 111 | */ |
| 112 | public function registerFeature( string $feature, $requirements ) { |
| 113 | // |
| 114 | // Validation |
| 115 | if ( array_key_exists( $feature, $this->features ) ) { |
| 116 | throw new \LogicException( sprintf( |
| 117 | 'Feature "%s" is already registered.', |
| 118 | $feature |
| 119 | ) ); |
| 120 | } |
| 121 | |
| 122 | Assert::parameterType( [ 'string', 'array' ], $requirements, 'requirements' ); |
| 123 | |
| 124 | $requirements = (array)$requirements; |
| 125 | |
| 126 | Assert::parameterElementType( 'string', $requirements, 'requirements' ); |
| 127 | |
| 128 | foreach ( $requirements as $name ) { |
| 129 | if ( !array_key_exists( $name, $this->requirements ) ) { |
| 130 | throw new \InvalidArgumentException( sprintf( |
| 131 | 'Feature "%s" references requirement "%s", which hasn\'t been registered', |
| 132 | $feature, |
| 133 | $name |
| 134 | ) ); |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // Mutation |
| 139 | $this->features[$feature] = $requirements; |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Gets user's preference value |
| 144 | * |
| 145 | * If user preference is not set or did not appear in config |
| 146 | * set it to default value we go back to defualt suffix value |
| 147 | * that will ensure that the feature will be enabled when requirements are met |
| 148 | * |
| 149 | * @param string $preferenceKey User preference key |
| 150 | * @return string |
| 151 | */ |
| 152 | public function getUserPreferenceValue( $preferenceKey ) { |
| 153 | return $this->userOptionsLookup->getOption( |
| 154 | $this->context->getUser(), |
| 155 | $preferenceKey |
| 156 | // For client preferences, this should be the same as `preferenceKey` |
| 157 | // in 'resources/skins.vector.js/clientPreferences.json' |
| 158 | ); |
| 159 | } |
| 160 | |
| 161 | /** |
| 162 | * Return a list of classes that should be added to the body tag |
| 163 | * |
| 164 | * @return array |
| 165 | */ |
| 166 | public function getFeatureBodyClass() { |
| 167 | return array_map( function ( $featureName ) { |
| 168 | // switch to lower case and switch from camel case to hyphens |
| 169 | $featureClass = ltrim( strtolower( preg_replace( '/[A-Z]([A-Z](?![a-z]))*/', '-$0', $featureName ) ), '-' ); |
| 170 | $prefix = 'vector-feature-' . $featureClass . '-'; |
| 171 | |
| 172 | // some features (eg night mode) will require request context to determine status |
| 173 | $request = $this->context->getRequest(); |
| 174 | $config = $this->context->getConfig(); |
| 175 | $title = $this->context->getTitle(); |
| 176 | |
| 177 | // Client side preferences |
| 178 | switch ( $featureName ) { |
| 179 | // This feature has 3 possible states: 0, 1, 2 and -excluded. |
| 180 | // It persists for all users. |
| 181 | case CONSTANTS::FEATURE_FONT_SIZE: |
| 182 | if ( $this->configHelper->shouldDisable( |
| 183 | $config->get( 'VectorFontSizeConfigurableOptions' ), $request, $title |
| 184 | ) ) { |
| 185 | return $prefix . 'clientpref--excluded'; |
| 186 | } |
| 187 | $suffixEnabled = 'clientpref-' . $this->getUserPreferenceValue( CONSTANTS::PREF_KEY_FONT_SIZE ); |
| 188 | $suffixDisabled = 'clientpref-0'; |
| 189 | break; |
| 190 | // This feature has 4 possible states: day, night, os and -excluded. |
| 191 | // It persists for all users. |
| 192 | case CONSTANTS::PREF_NIGHT_MODE: |
| 193 | // if night mode is disabled for the page, add the exclude class instead and return early |
| 194 | if ( $this->configHelper->shouldDisable( |
| 195 | $config->get( 'VectorNightModeOptions' ), |
| 196 | $request, |
| 197 | $title |
| 198 | ) ) { |
| 199 | // The additional "-" prefix, makes this an invalid client preference for anonymous users. |
| 200 | return 'skin-theme-clientpref--excluded'; |
| 201 | } |
| 202 | |
| 203 | $prefix = ''; |
| 204 | $valueRequest = $request->getRawVal( 'vectornightmode' ); |
| 205 | // If night mode query string is used, hardcode pref value to the night mode value |
| 206 | // NOTE: The query string parameter only works for logged in users. |
| 207 | // IF you have set a cookie locally this will be overriden. |
| 208 | $value = $valueRequest !== null ? self::resolveNightModeQueryValue( $valueRequest ) : |
| 209 | $this->getUserPreferenceValue( CONSTANTS::PREF_KEY_NIGHT_MODE ); |
| 210 | $suffixEnabled = 'clientpref-' . $value; |
| 211 | $suffixDisabled = 'clientpref-day'; |
| 212 | // Must be hardcoded to 'skin-theme-' to be consistent with Minerva |
| 213 | // So that editors can target the same class across skins |
| 214 | $prefix .= 'skin-theme-'; |
| 215 | break; |
| 216 | // These features persist for all users and have two valid states: 0 and 1. |
| 217 | case CONSTANTS::FEATURE_LIMITED_WIDTH: |
| 218 | case CONSTANTS::FEATURE_TOC_PINNED: |
| 219 | case CONSTANTS::FEATURE_APPEARANCE_PINNED: |
| 220 | $suffixEnabled = 'clientpref-1'; |
| 221 | $suffixDisabled = 'clientpref-0'; |
| 222 | break; |
| 223 | // These features only persist for logged in users so do not contain the clientpref suffix. |
| 224 | // These features have two valid states: enabled and disabled. In future it would be nice if these |
| 225 | // were 0 and 1 so that the features.js module cannot be applied to server side only flags. |
| 226 | case CONSTANTS::FEATURE_MAIN_MENU_PINNED: |
| 227 | case CONSTANTS::FEATURE_PAGE_TOOLS_PINNED: |
| 228 | // Server side only feature flags. |
| 229 | // Note these classes are fixed and cannot be changed at runtime by JavaScript, |
| 230 | // only via modification to LocalSettings.php. |
| 231 | case Constants::FEATURE_NIGHT_MODE: |
| 232 | case Constants::FEATURE_LIMITED_WIDTH_CONTENT: |
| 233 | case Constants::FEATURE_LANGUAGE_IN_HEADER: |
| 234 | case Constants::FEATURE_LANGUAGE_IN_MAIN_PAGE_HEADER: |
| 235 | $suffixEnabled = 'enabled'; |
| 236 | $suffixDisabled = 'disabled'; |
| 237 | break; |
| 238 | default: |
| 239 | throw new RuntimeException( "Feature $featureName has no associated feature class." ); |
| 240 | } |
| 241 | return $this->isFeatureEnabled( $featureName ) ? |
| 242 | $prefix . $suffixEnabled : $prefix . $suffixDisabled; |
| 243 | }, array_keys( $this->features ) ); |
| 244 | } |
| 245 | |
| 246 | /** |
| 247 | * Gets whether the feature's requirements are met. |
| 248 | * |
| 249 | * @param string $feature |
| 250 | * @return bool |
| 251 | * |
| 252 | * @throws \InvalidArgumentException If the feature isn't registered |
| 253 | */ |
| 254 | public function isFeatureEnabled( string $feature ): bool { |
| 255 | if ( !array_key_exists( $feature, $this->features ) ) { |
| 256 | throw new \InvalidArgumentException( "The feature \"{$feature}\" isn't registered." ); |
| 257 | } |
| 258 | |
| 259 | $requirements = $this->features[$feature]; |
| 260 | |
| 261 | foreach ( $requirements as $name ) { |
| 262 | if ( !$this->requirements[$name]->isMet() ) { |
| 263 | return false; |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | return true; |
| 268 | } |
| 269 | |
| 270 | /** |
| 271 | * Register a complex {@see Requirement}. |
| 272 | * |
| 273 | * A complex requirement is one that depends on object that may or may not be fully loaded |
| 274 | * while the application is booting, e.g. see `User::isSafeToLoad`. |
| 275 | * |
| 276 | * Such requirements are expected to be registered during a hook that is run early on in the |
| 277 | * application lifecycle, e.g. the `BeforePerformAction` and `APIBeforeMain` hooks. |
| 278 | * |
| 279 | * @param Requirement $requirement |
| 280 | * |
| 281 | * @throws \LogicException If the requirement has already been registered |
| 282 | */ |
| 283 | public function registerRequirement( Requirement $requirement ) { |
| 284 | $name = $requirement->getName(); |
| 285 | |
| 286 | if ( array_key_exists( $name, $this->requirements ) ) { |
| 287 | throw new \LogicException( "The requirement \"{$name}\" is already registered." ); |
| 288 | } |
| 289 | |
| 290 | $this->requirements[$name] = $requirement; |
| 291 | } |
| 292 | |
| 293 | /** |
| 294 | * Register a {@see SimpleRequirement}. |
| 295 | * |
| 296 | * A requirement is some condition of the application state that a feature requires to be true |
| 297 | * or false. |
| 298 | * |
| 299 | * @param string $name The name of the requirement |
| 300 | * @param bool $isMet Whether the requirement is met |
| 301 | * |
| 302 | * @throws \LogicException If the requirement has already been registered |
| 303 | */ |
| 304 | public function registerSimpleRequirement( string $name, bool $isMet ) { |
| 305 | $this->registerRequirement( new SimpleRequirement( $name, $isMet ) ); |
| 306 | } |
| 307 | |
| 308 | /** |
| 309 | * Gets whether the requirement is met. |
| 310 | * |
| 311 | * @param string $name The name of the requirement |
| 312 | * @return bool |
| 313 | * |
| 314 | * @throws \InvalidArgumentException If the requirement isn't registered |
| 315 | */ |
| 316 | public function isRequirementMet( string $name ): bool { |
| 317 | if ( !array_key_exists( $name, $this->requirements ) ) { |
| 318 | throw new \InvalidArgumentException( "Requirement \"{$name}\" isn't registered." ); |
| 319 | } |
| 320 | |
| 321 | return $this->requirements[$name]->isMet(); |
| 322 | } |
| 323 | |
| 324 | /** |
| 325 | * Converts "1", "2", and "0" to equivalent values. |
| 326 | * |
| 327 | * @return string |
| 328 | */ |
| 329 | private static function resolveNightModeQueryValue( string $value ) { |
| 330 | switch ( $value ) { |
| 331 | case 'day': |
| 332 | case 'night': |
| 333 | case 'os': |
| 334 | return $value; |
| 335 | case '1': |
| 336 | return 'night'; |
| 337 | case '2': |
| 338 | return 'os'; |
| 339 | default: |
| 340 | return 'day'; |
| 341 | } |
| 342 | } |
| 343 | } |