Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.86% covered (danger)
3.86%
11 / 285
5.56% covered (danger)
5.56%
1 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
SkinVector22
3.86% covered (danger)
3.86%
11 / 285
5.56% covered (danger)
5.56%
1 / 18
3936.84
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 runOnSkinTemplateNavigationHooks
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isResponsive
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 isTocAvailable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canHaveLanguages
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 removeAddTopicButton
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getFeatureManager
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isLanguagesInContentAt
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
110
 isLanguagesInContent
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getLanguagesCached
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isULSExtensionEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isVisualEditorTabPositionFirst
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 shouldHideLanguages
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
30
 mergeViewOverflowIntoActions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getHtmlElementAttributes
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 extractPageToolsFromSidebar
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getULSLabels
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getTemplateData
0.00% covered (danger)
0.00%
0 / 187
0.00% covered (danger)
0.00%
0 / 1
306
1<?php
2
3namespace MediaWiki\Skins\Vector;
4
5use ExtensionRegistry;
6use MediaWiki\MediaWikiServices;
7use MediaWiki\Skins\Vector\Components\VectorComponentAppearance;
8use MediaWiki\Skins\Vector\Components\VectorComponentButton;
9use MediaWiki\Skins\Vector\Components\VectorComponentDropdown;
10use MediaWiki\Skins\Vector\Components\VectorComponentLanguageDropdown;
11use MediaWiki\Skins\Vector\Components\VectorComponentMainMenu;
12use MediaWiki\Skins\Vector\Components\VectorComponentPageTools;
13use MediaWiki\Skins\Vector\Components\VectorComponentPinnableContainer;
14use MediaWiki\Skins\Vector\Components\VectorComponentSearchBox;
15use MediaWiki\Skins\Vector\Components\VectorComponentStickyHeader;
16use MediaWiki\Skins\Vector\Components\VectorComponentTableOfContents;
17use MediaWiki\Skins\Vector\Components\VectorComponentUserLinks;
18use MediaWiki\Skins\Vector\Components\VectorComponentVariants;
19use MediaWiki\Skins\Vector\FeatureManagement\FeatureManager;
20use MediaWiki\Skins\Vector\FeatureManagement\FeatureManagerFactory;
21use RuntimeException;
22use SkinMustache;
23use SkinTemplate;
24
25/**
26 * @ingroup Skins
27 * @package Vector
28 * @internal
29 */
30class SkinVector22 extends SkinMustache {
31    private const STICKY_HEADER_ENABLED_CLASS = 'vector-sticky-header-enabled';
32    /** @var null|array for caching purposes */
33    private $languages;
34
35    private FeatureManagerFactory $featureManagerFactory;
36    private ?FeatureManager $featureManager = null;
37
38    public function __construct(
39        FeatureManagerFactory $featureManagerFactory,
40        array $options
41    ) {
42        parent::__construct( $options );
43
44        // Cannot use the context in the constructor, setContext is called after construction
45        $this->featureManagerFactory = $featureManagerFactory;
46    }
47
48    /**
49     * @inheritDoc
50     */
51    protected function runOnSkinTemplateNavigationHooks( SkinTemplate $skin, &$content_navigation ) {
52        parent::runOnSkinTemplateNavigationHooks( $skin, $content_navigation );
53        Hooks::onSkinTemplateNavigation( $skin, $content_navigation );
54    }
55
56    /**
57     * @inheritDoc
58     */
59    public function isResponsive() {
60        // Check it's enabled by user preference and configuration
61        $responsive = parent::isResponsive() && $this->getConfig()->get( 'VectorResponsive' );
62        // For historic reasons, the viewport is added when Vector is loaded on the mobile
63        // domain. This is only possible for 3rd parties or by useskin parameter as there is
64        // no preference for changing mobile skin. Only need to check if $responsive is falsey.
65        if ( !$responsive && ExtensionRegistry::getInstance()->isLoaded( 'MobileFrontend' ) ) {
66            $mobFrontContext = MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
67            if ( $mobFrontContext->shouldDisplayMobileView() ) {
68                return true;
69            }
70        }
71        return $responsive;
72    }
73
74    /**
75     * Whether or not toc data is available
76     *
77     * @param array $parentData Template data
78     * @return bool
79     */
80    private function isTocAvailable( array $parentData ): bool {
81        return !empty( $parentData['data-toc'][ 'array-sections' ] );
82    }
83
84    /**
85     * This should be upstreamed to the Skin class in core once the logic is finalized.
86     * Returns false if the page is a special page without any languages, or if an action
87     * other than view is being used.
88     *
89     * @return bool
90     */
91    private function canHaveLanguages(): bool {
92        $action = $this->getActionName();
93
94        // FIXME: This logic should be moved into the ULS extension or core given the button is hidden,
95        // it should not be rendered, short term fix for T328996.
96        if ( $action === 'history' ) {
97            return false;
98        }
99
100        $title = $this->getTitle();
101        // Defensive programming - if a special page has added languages explicitly, best to show it.
102        if ( $title && $title->isSpecialPage() && $this->getLanguagesCached() === [] ) {
103            return false;
104        }
105        return true;
106    }
107
108    /**
109     * Remove the add topic button from data-views if present
110     *
111     * @param array &$parentData Template data
112     * @return bool An add topic button was removed
113     */
114    private function removeAddTopicButton( array &$parentData ): bool {
115        $views = $parentData['data-portlets']['data-views']['array-items'];
116        $hasAddTopicButton = false;
117        $html = '';
118        foreach ( $views as $i => $view ) {
119            if ( $view['id'] === 'ca-addsection' ) {
120                array_splice( $views, $i, 1 );
121                $hasAddTopicButton = true;
122                continue;
123            }
124            $html .= $view['html-item'];
125        }
126        $parentData['data-portlets']['data-views']['array-items'] = $views;
127        $parentData['data-portlets']['data-views']['html-items'] = $html;
128        return $hasAddTopicButton;
129    }
130
131    private function getFeatureManager(): FeatureManager {
132        if ( $this->featureManager === null ) {
133            $this->featureManager = $this->featureManagerFactory->createFeatureManager( $this->getContext() );
134        }
135        return $this->featureManager;
136    }
137
138    /**
139     * @param string $location Either 'top' or 'bottom' is accepted.
140     * @return bool
141     */
142    protected function isLanguagesInContentAt( string $location ): bool {
143        if ( !$this->canHaveLanguages() ) {
144            return false;
145        }
146        $featureManager = $this->getFeatureManager();
147        $inContent = $featureManager->isFeatureEnabled(
148            Constants::FEATURE_LANGUAGE_IN_HEADER
149        );
150        $title = $this->getTitle();
151        $isMainPage = $title ? $title->isMainPage() : false;
152
153        switch ( $location ) {
154            case 'top':
155                return $isMainPage ? $inContent && $featureManager->isFeatureEnabled(
156                    Constants::FEATURE_LANGUAGE_IN_MAIN_PAGE_HEADER
157                ) : $inContent;
158            case 'bottom':
159                return $inContent && $isMainPage && !$featureManager->isFeatureEnabled(
160                    Constants::FEATURE_LANGUAGE_IN_MAIN_PAGE_HEADER
161                );
162            default:
163                throw new RuntimeException( 'unknown language button location' );
164        }
165    }
166
167    /**
168     * Whether or not the languages are out of the sidebar and in the content either at
169     * the top or the bottom.
170     *
171     * @return bool
172     */
173    final protected function isLanguagesInContent(): bool {
174        return $this->isLanguagesInContentAt( 'top' ) || $this->isLanguagesInContentAt( 'bottom' );
175    }
176
177    /**
178     * Calls getLanguages with caching.
179     *
180     * @return array
181     */
182    protected function getLanguagesCached(): array {
183        if ( $this->languages === null ) {
184            $this->languages = $this->getLanguages();
185        }
186        return $this->languages;
187    }
188
189    /**
190     * Check whether ULS is enabled
191     *
192     * @return bool
193     */
194    final protected function isULSExtensionEnabled(): bool {
195        return ExtensionRegistry::getInstance()->isLoaded( 'UniversalLanguageSelector' );
196    }
197
198    /**
199     * Check whether Visual Editor Tab Position is first
200     *
201     * @param array $dataViews
202     * @return bool
203     */
204    final protected function isVisualEditorTabPositionFirst( $dataViews ): bool {
205        $names = [ 've-edit', 'edit' ];
206        // find if under key 'name' 've-edit' or 'edit' is the before item in the array
207        for ( $i = 0; $i < count( $dataViews[ 'array-items' ] ); $i++ ) {
208            if ( in_array( $dataViews[ 'array-items' ][ $i ][ 'name' ], $names ) ) {
209                return $dataViews[ 'array-items' ][ $i ][ 'name' ] === $names[ 0 ];
210            }
211        }
212        return false;
213    }
214
215    /**
216     * Show the ULS button if it's modern Vector, languages in header is enabled,
217     * the ULS extension is enabled, and we are on a subect page. Hide it otherwise.
218     * There is no point in showing the language button if ULS extension is unavailable
219     * as there is no ways to add languages without it.
220     * @return bool
221     */
222    protected function shouldHideLanguages(): bool {
223        $title = $this->getTitle();
224        $isSubjectPage = $title && $title->exists() && !$title->isTalkPage();
225        return !$this->isLanguagesInContent() || !$this->isULSExtensionEnabled() || !$isSubjectPage;
226    }
227
228    /**
229     * Merges the `view-overflow` menu into the `action` menu.
230     * This ensures that the previous state of the menu e.g. emptyPortlet class
231     * is preserved.
232     *
233     * @param array $data
234     * @return array
235     */
236    private function mergeViewOverflowIntoActions( array $data ): array {
237        $portlets = $data['data-portlets'];
238        $actions = $portlets['data-actions'];
239        $overflow = $portlets['data-views-overflow'];
240        // if the views overflow menu is not empty, then signal that the more menu despite
241        // being initially empty now has collapsible items.
242        if ( !$overflow['is-empty'] ) {
243            $data['data-portlets']['data-actions']['class'] .= ' vector-has-collapsible-items';
244        }
245        $data['data-portlets']['data-actions']['html-items'] = $overflow['html-items'] . $actions['html-items'];
246        return $data;
247    }
248
249    /**
250     * @inheritDoc
251     */
252    public function getHtmlElementAttributes() {
253        $original = parent::getHtmlElementAttributes();
254        $featureManager = $this->getFeatureManager();
255        $original['class'] .= ' ' . implode( ' ', $featureManager->getFeatureBodyClass() );
256
257        if ( $featureManager->isFeatureEnabled( Constants::FEATURE_STICKY_HEADER ) ) {
258            // T290518: Add scroll padding to root element when the sticky header is
259            // enabled. This class needs to be server rendered instead of added from
260            // JS in order to correctly handle situations where the sticky header
261            // isn't visible yet but we still need scroll padding applied (e.g. when
262            // the user navigates to a page with a hash fragment in the URI). For this
263            // reason, we can't rely on the `vector-sticky-header-visible` class as it
264            // is added too late.
265            //
266            // Please note that this class applies scroll padding which does not work
267            // when applied to the body tag in Chrome, Safari, and Firefox (and
268            // possibly others). It must instead be applied to the html tag.
269            $original['class'] = implode( ' ', [ $original['class'] ?? '', self::STICKY_HEADER_ENABLED_CLASS ] );
270        }
271        $original['class'] = trim( $original['class'] );
272
273        return $original;
274    }
275
276    /**
277     * Pulls the page tools menu out of $sidebar into $pageToolsMenu
278     *
279     * @param array &$sidebar
280     * @param array &$pageToolsMenu
281     */
282    private static function extractPageToolsFromSidebar( array &$sidebar, array &$pageToolsMenu ) {
283        $restPortlets = $sidebar[ 'array-portlets-rest' ] ?? [];
284        $toolboxMenuIndex = array_search(
285            VectorComponentPageTools::TOOLBOX_ID,
286            array_column(
287                $restPortlets,
288                'id'
289            )
290        );
291
292        if ( $toolboxMenuIndex !== false ) {
293            // Splice removes the toolbox menu from the $restPortlets array
294            // and current returns the first value of array_splice, i.e. the $toolbox menu data.
295            $pageToolsMenu = array_splice( $restPortlets, $toolboxMenuIndex );
296            $sidebar['array-portlets-rest'] = $restPortlets;
297        }
298    }
299
300    /**
301     * Get the ULS button label, accounting for the number of available
302     * languages.
303     *
304     * @return array
305     */
306    final protected function getULSLabels(): array {
307        $numLanguages = count( $this->getLanguagesCached() );
308
309        if ( $numLanguages === 0 ) {
310            return [
311                'label' => $this->msg( 'vector-no-language-button-label' )->text(),
312                'aria-label' => $this->msg( 'vector-no-language-button-aria-label' )->text()
313            ];
314        } else {
315            return [
316                'label' => $this->msg( 'vector-language-button-label' )->numParams( $numLanguages )->text(),
317                'aria-label' => $this->msg( 'vector-language-button-aria-label' )->numParams( $numLanguages )->text()
318            ];
319        }
320    }
321
322    /**
323     * @return array
324     */
325    public function getTemplateData(): array {
326        $parentData = parent::getTemplateData();
327        $parentData = $this->mergeViewOverflowIntoActions( $parentData );
328        $portlets = $parentData['data-portlets'];
329
330        $langData = $portlets['data-languages'] ?? null;
331        $config = $this->getConfig();
332        $featureManager = $this->getFeatureManager();
333
334        $sidebar = $parentData[ 'data-portlets-sidebar' ];
335        $pageToolsMenu = [];
336        self::extractPageToolsFromSidebar( $sidebar, $pageToolsMenu );
337
338        $hasAddTopicButton = $config->get( 'VectorPromoteAddTopic' ) &&
339            $this->removeAddTopicButton( $parentData );
340
341        $langButtonClass = $langData['class'] ?? '';
342        $ulsLabels = $this->getULSLabels();
343        $user = $this->getUser();
344        $localizer = $this->getContext();
345        $title = $this->getTitle();
346
347        // If the table of contents has no items, we won't output it.
348        // empty array is interpreted by Mustache as falsey.
349        $tocComponents = [];
350        if ( $this->isTocAvailable( $parentData ) ) {
351            // @phan-suppress-next-line SecurityCheck-XSS
352            $dataToc = new VectorComponentTableOfContents(
353                $parentData['data-toc'],
354                $localizer,
355                $config,
356                $featureManager
357            );
358            $isPinned = $dataToc->isPinned();
359            $tocComponents = [
360                'data-toc' => $dataToc,
361                'data-toc-pinnable-container' => new VectorComponentPinnableContainer(
362                    VectorComponentTableOfContents::ID,
363                    $isPinned
364                ),
365                'data-page-titlebar-toc-dropdown' => new VectorComponentDropdown(
366                    'vector-page-titlebar-toc',
367                    // label
368                    $this->msg( 'vector-toc-collapsible-button-label' ),
369                    // class
370                    'vector-page-titlebar-toc vector-button-flush-left',
371                    // icon
372                    'listBullet',
373                ),
374                'data-page-titlebar-toc-pinnable-container' => new VectorComponentPinnableContainer(
375                    'vector-page-titlebar-toc',
376                    $isPinned
377                ),
378                'data-sticky-header-toc-dropdown' => new VectorComponentDropdown(
379                    'vector-sticky-header-toc',
380                    // label
381                    $this->msg( 'vector-toc-collapsible-button-label' ),
382                    // class
383                    'mw-portlet mw-portlet-sticky-header-toc vector-sticky-header-toc vector-button-flush-left',
384                    // icon
385                    'listBullet'
386                ),
387                'data-sticky-header-toc-pinnable-container' => new VectorComponentPinnableContainer(
388                    'vector-sticky-header-toc',
389                    $isPinned
390                ),
391            ];
392            $this->getOutput()->addHtmlClasses( 'vector-toc-available' );
393        } else {
394            $this->getOutput()->addHtmlClasses( 'vector-toc-not-available' );
395        }
396
397        $isRegistered = $user->isRegistered();
398        $userPage = $isRegistered ? $this->buildPersonalPageItem() : [];
399        $isAppearanceEnabled = $featureManager->isFeatureEnabled(
400            Constants::FEATURE_APPEARANCE,
401        );
402
403        $components = $tocComponents + [
404            'data-add-topic-button' => $hasAddTopicButton ? new VectorComponentButton(
405                $this->msg( [ 'vector-2022-action-addsection', 'skin-action-addsection' ] )->text(),
406                'speechBubbleAdd-progressive',
407                'ca-addsection',
408                '',
409                [ 'data-event-name' => 'addsection-header' ],
410                'quiet',
411                'progressive',
412                false,
413                $title->getLocalURL( [ 'action' => 'edit', 'section' => 'new' ] )
414            ) : null,
415            'data-variants' => new VectorComponentVariants(
416                $portlets['data-variants'],
417                $title->getPageLanguage(),
418                $this->msg( 'vector-language-variant-switcher-label' )
419            ),
420            'data-vector-user-links' => new VectorComponentUserLinks(
421                $localizer,
422                $user,
423                $portlets,
424                $this->getOptions()['link'],
425                $userPage[ 'icon' ] ?? ''
426            ),
427            'data-lang-dropdown' => $langData ? new VectorComponentLanguageDropdown(
428                $ulsLabels['label'],
429                $ulsLabels['aria-label'],
430                $langButtonClass,
431                count( $this->getLanguagesCached() ),
432                $langData['html-items'] ?? '',
433                $langData['html-before-portal'] ?? '',
434                $langData['html-after-portal'] ?? '',
435                $title
436            ) : null,
437            'data-search-box' => new VectorComponentSearchBox(
438                $parentData['data-search-box'],
439                true,
440                // is primary mode of search
441                true,
442                'searchform',
443                true,
444                $config,
445                Constants::SEARCH_BOX_INPUT_LOCATION_MOVED,
446                $localizer
447            ),
448            'data-main-menu' => new VectorComponentMainMenu(
449                $sidebar,
450                $portlets['data-languages'] ?? [],
451                $localizer,
452                $user,
453                $featureManager,
454                $this,
455            ),
456            'data-main-menu-dropdown' => new VectorComponentDropdown(
457                VectorComponentMainMenu::ID . '-dropdown',
458                $this->msg( VectorComponentMainMenu::ID . '-label' )->text(),
459                VectorComponentMainMenu::ID . '-dropdown' . ' vector-button-flush-left vector-button-flush-right',
460                'menu'
461            ),
462            'data-page-tools' => new VectorComponentPageTools(
463                array_merge( [ $portlets['data-actions'] ?? [] ], $pageToolsMenu ),
464                $localizer,
465                $featureManager
466            ),
467            'data-page-tools-dropdown' => new VectorComponentDropdown(
468                VectorComponentPageTools::ID . '-dropdown',
469                $this->msg( 'toolbox' )->text(),
470                VectorComponentPageTools::ID . '-dropdown',
471            ),
472            'data-appearance' => $isAppearanceEnabled ?
473                new VectorComponentAppearance( $localizer, $featureManager ) : null,
474            'data-appearance-dropdown' => $isAppearanceEnabled ? new VectorComponentDropdown(
475                'vector-appearance-dropdown',
476                $this->msg( 'vector-appearance-label' )->text(),
477                '',
478                // @todo: Use new theme icon (T351142)
479                'appearance'
480            ) : null,
481            'data-vector-sticky-header' => $featureManager->isFeatureEnabled(
482                Constants::FEATURE_STICKY_HEADER
483            ) ? new VectorComponentStickyHeader(
484                $localizer,
485                new VectorComponentSearchBox(
486                    $parentData['data-search-box'],
487                    // Collapse inside search box is disabled.
488                    false,
489                    false,
490                    'vector-sticky-search-form',
491                    false,
492                    $config,
493                    Constants::SEARCH_BOX_INPUT_LOCATION_MOVED,
494                    $localizer
495                ),
496                // Show sticky ULS if the ULS extension is enabled and the ULS in header is not hidden
497                $this->isULSExtensionEnabled() && !$this->shouldHideLanguages() ?
498                    new VectorComponentButton(
499                        $ulsLabels[ 'label' ],
500                        'wikimedia-language',
501                        'p-lang-btn-sticky-header',
502                        'mw-interlanguage-selector',
503                        [
504                            'tabindex' => '-1',
505                            'data-event-name' => 'ui.dropdown-p-lang-btn-sticky-header'
506                        ],
507                        'quiet',
508                    ) : null,
509                $this->isVisualEditorTabPositionFirst( $portlets[ 'data-views' ] )
510            ) : null,
511            'data-vector-settings-button' => $featureManager->isFeatureEnabled(
512                Constants::FEATURE_APPEARANCE,
513            ) ? null : new VectorComponentButton(
514                $this->msg( 'vector-limited-width-toggle' ),
515                $featureManager->isFeatureEnabled(
516                    Constants::FEATURE_LIMITED_WIDTH
517                ) ? 'fullScreen' : 'exitFullscreen',
518                '',
519                'vector-limited-width-toggle',
520                [],
521                'normal',
522                'default',
523                true
524            )
525        ];
526
527        foreach ( $components as $key => $component ) {
528            // Array of components or null values.
529            if ( $component ) {
530                $parentData[$key] = $component->getTemplateData();
531            }
532        }
533
534        return array_merge( $parentData, [
535            'is-language-in-content' => $this->isLanguagesInContent(),
536            'has-buttons-in-content-top' => $this->isLanguagesInContentAt( 'top' ) || $hasAddTopicButton,
537            'is-language-in-content-bottom' => $this->isLanguagesInContentAt( 'bottom' ),
538            // Cast empty string to null
539            'html-subtitle' => $parentData['html-subtitle'] === '' ? null : $parentData['html-subtitle'],
540        ] );
541    }
542}