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