Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.58% covered (danger)
4.58%
6 / 131
0.00% covered (danger)
0.00%
0 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
DashboardModule
4.58% covered (danger)
4.58%
6 / 131
0.00% covered (danger)
0.00%
0 / 39
2049.70
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
 getContext
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getConfig
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 canRender
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldRender
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleStyles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCssClasses
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 supports
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getJsData
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getJsConfigVars
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 outputDependencies
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 render
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 getHtml
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 buildModuleWrapper
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 buildSection
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 renderDesktop
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 renderMobileSummary
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 renderMobileDetails
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderTextElement
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderText
n/a
0 / 0
n/a
0 / 0
0
 getHeaderTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHeader
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getBody
n/a
0 / 0
n/a
0 / 0
0
 getMobileSummaryHeader
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMobileDetailsHeader
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getBackIcon
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getNavIcon
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getMobileSummaryBody
n/a
0 / 0
n/a
0 / 0
0
 getSubheader
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSubheaderText
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSubheaderTextElement
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getSubheaderTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFooter
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderIcon
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getHeaderIconName
n/a
0 / 0
n/a
0 / 0
0
 shouldInvertHeaderIcon
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldHeaderIncludeIcon
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 msg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments\DashboardModule;
4
5use InvalidArgumentException;
6use MediaWiki\Config\Config;
7use MediaWiki\Context\IContextSource;
8use MediaWiki\Html\Html;
9use MediaWiki\Message\Message;
10use MediaWiki\SpecialPage\SpecialPage;
11use MediaWiki\User\User;
12use OOUI\IconWidget;
13use Wikimedia\Message\MessageSpecifier;
14
15abstract class DashboardModule implements IDashboardModule {
16    /**
17     * Override this to change base CSS class used with the elements
18     */
19    protected const BASE_CSS_CLASS = 'growthexperiments-dashboard-module';
20
21    /**
22     * Modes that are supported by this module.Subclasses that don't support certain modes should
23     * override this to list only the modes they support. For more granular control, override
24     * supports() instead.
25     * @var string[]
26     */
27    protected static $supportedModes = [
28        self::RENDER_DESKTOP,
29        self::RENDER_MOBILE_SUMMARY,
30        self::RENDER_MOBILE_DETAILS
31    ];
32
33    /** @var IContextSource */
34    private $ctx;
35
36    /** @var string Name of the module */
37    protected $name;
38
39    /** @var string Rendering mode (one of RENDER_* constants) */
40    private $mode;
41
42    /**
43     * @param string $name
44     * @param IContextSource $ctx
45     */
46    public function __construct(
47        $name,
48        IContextSource $ctx
49    ) {
50        $this->name = $name;
51        $this->ctx = $ctx;
52    }
53
54    /**
55     * @return IContextSource
56     */
57    final protected function getContext(): IContextSource {
58        return $this->ctx;
59    }
60
61    /**
62     * Get current user
63     *
64     * Short for $this->getContext()->getUser().
65     *
66     * @return User
67     */
68    final protected function getUser(): User {
69        return $this->getContext()->getUser();
70    }
71
72    /**
73     * Shortcut to get main config object
74     *
75     * Short for $this->getContext()->getConfig().
76     *
77     * @return Config
78     */
79    final protected function getConfig(): Config {
80        return $this->getContext()->getConfig();
81    }
82
83    /**
84     * @return string Rendering mode (one of RENDER_* constants)
85     */
86    final protected function getMode(): string {
87        return $this->mode;
88    }
89
90    /**
91     * @return string
92     */
93    final protected function getName(): string {
94        return $this->name;
95    }
96
97    /**
98     * @param string $mode Rendering mode (one of RENDER_* constants)
99     */
100    protected function setMode( string $mode ) {
101        $this->mode = $mode;
102    }
103
104    /**
105     * Whether the module can be rendered or not.
106     * When this returns false, callers should never attempt to render the module.
107     * @return bool
108     */
109    protected function canRender() {
110        return true;
111    }
112
113    /**
114     * Whether the module is supposed to be present on the homepage.
115     * When canRender() is true but shouldRender() is false, the module should not be displayed,
116     * but callers can choose to pre-render the module to display it dynamically without delay
117     * when it becames enabled.
118     * @return bool
119     */
120    protected function shouldRender() {
121        return $this->canRender();
122    }
123
124    /**
125     * Override this function to provide module styles that need to be
126     * loaded in the <head> for this module.
127     *
128     * @return string[] Name of the module(s) to load
129     */
130    protected function getModuleStyles() {
131        return [];
132    }
133
134    /**
135     * Override this function to provide modules that need to be
136     * loaded for this module.
137     *
138     * @return string[] Name of the module(s) to load
139     */
140    protected function getModules() {
141        return [];
142    }
143
144    /**
145     * Override this function to add additional CSS classes to the top-level
146     * <div> of this module.
147     *
148     * @return string[] Additional CSS classes
149     */
150    protected function getCssClasses() {
151        return [];
152    }
153
154    /**
155     * @inheritDoc
156     */
157    public function supports( $mode ) {
158        return in_array( $mode, static::$supportedModes );
159    }
160
161    /**
162     * @inheritDoc
163     */
164    public function getJsData( $mode ) {
165        return [];
166    }
167
168    /**
169     * Override this function to provide JS config vars needed by this module.
170     *
171     * @return array
172     */
173    protected function getJsConfigVars() {
174        return [];
175    }
176
177    protected function outputDependencies() {
178        $out = $this->getContext()->getOutput();
179        $out->addModuleStyles( $this->getModuleStyles() );
180        $out->addModules( $this->getModules() );
181        $out->addJsConfigVars( $this->getJsConfigVars() );
182    }
183
184    /**
185     * @inheritDoc
186     */
187    public function render( $mode ) {
188        if ( !$this->supports( $mode ) ) {
189            return '';
190        }
191        $this->setMode( $mode );
192        if ( !$this->shouldRender() ) {
193            return '';
194        }
195
196        $this->outputDependencies();
197        return $this->getHtml();
198    }
199
200    /**
201     * Get the module HTML for current mode
202     *
203     * @return string
204     */
205    protected function getHtml() {
206        if ( $this->mode === self::RENDER_DESKTOP ) {
207            $html = $this->renderDesktop();
208        } elseif ( $this->mode === self::RENDER_MOBILE_SUMMARY ) {
209            $html = $this->renderMobileSummary();
210        } elseif ( $this->mode === self::RENDER_MOBILE_DETAILS ) {
211            $html = $this->renderMobileDetails();
212        } else {
213            throw new InvalidArgumentException( 'Invalid rendering mode: ' . $this->mode );
214        }
215        return $html;
216    }
217
218    /**
219     * @param string ...$sections
220     * @return string
221     */
222    protected function buildModuleWrapper( ...$sections ) {
223        return Html::rawElement(
224            'div',
225            [
226                'class' => array_merge( [
227                    static::BASE_CSS_CLASS,
228                    static::BASE_CSS_CLASS . '-' . $this->name,
229                    static::BASE_CSS_CLASS . '-' . $this->getMode()
230                ], $this->getCssClasses() ),
231                'data-module-name' => $this->name,
232                'data-mode' => $this->getMode()
233            ],
234            implode( "\n", $sections )
235        );
236    }
237
238    /**
239     * Build a module section.
240     *
241     * $content is HTML, do not pass plain text. Use ->escaped() or ->parse() for messages.
242     *
243     * @param string $name Name of the section, used to generate a class
244     * @param string $content HTML content of the section
245     * @param string $tag HTML tag to use for the section
246     * @return string
247     */
248    protected function buildSection( $name, $content, $tag = 'div' ) {
249        return $content ? Html::rawElement(
250            $tag,
251            [
252                'class' => [
253                    static::BASE_CSS_CLASS . '-section',
254                    static::BASE_CSS_CLASS . '-section-' . $name,
255                    static::BASE_CSS_CLASS . '-' . $name
256                ]
257            ],
258            $content
259        ) : '';
260    }
261
262    /**
263     * @return string HTML rendering for desktop.
264     */
265    protected function renderDesktop() {
266        return $this->buildModuleWrapper(
267            $this->buildSection( 'header', $this->getHeader(), $this->getHeaderTag() ),
268            $this->buildSection( 'subheader', $this->getSubheader(), $this->getSubheaderTag() ),
269            $this->buildSection( 'body', $this->getBody() ),
270            $this->buildSection( 'footer', $this->getFooter() )
271        );
272    }
273
274    /**
275     * @return string HTML rendering for mobile summary.
276     */
277    protected function renderMobileSummary() {
278        return $this->buildModuleWrapper(
279            $this->buildSection( 'header', $this->getMobileSummaryHeader(), $this->getHeaderTag() ),
280            $this->buildSection( 'body', $this->getMobileSummaryBody() )
281        );
282    }
283
284    /**
285     * @return string HTML rendering for mobile details.
286     */
287    protected function renderMobileDetails() {
288        return $this->buildModuleWrapper(
289            $this->buildSection( 'header', $this->getMobileDetailsHeader(), $this->getHeaderTag() ),
290            $this->buildSection( 'subheader', $this->getSubheader(), $this->getSubheaderTag() ),
291            $this->buildSection( 'body', $this->getBody() ),
292            $this->buildSection( 'footer', $this->getFooter() )
293        );
294    }
295
296    /**
297     * @return string HTML element containing the header text.
298     */
299    protected function getHeaderTextElement() {
300        return Html::element(
301            'div',
302            [ 'class' => static::BASE_CSS_CLASS . '-header-text' ],
303            $this->getHeaderText()
304        );
305    }
306
307    /**
308     * Override this function to provide the header text
309     *
310     * @return string
311     */
312    abstract protected function getHeaderText();
313
314    /**
315     * Override this function to change the default header tag.
316     *
317     * @return string Tag to use with the header, eg. h2, h3, h4, ...
318     */
319    protected function getHeaderTag() {
320        return 'h2';
321    }
322
323    /**
324     * Implement this function to provide the module header.
325     *
326     * @return string HTML content of the header. Will be wrapped in a section.
327     */
328    protected function getHeader() {
329        $html = '';
330        if ( $this->shouldHeaderIncludeIcon() ) {
331            $html .= $this->getHeaderIcon(
332                $this->getHeaderIconName(),
333                $this->shouldInvertHeaderIcon()
334            );
335        }
336        $html .= $this->getHeaderTextElement();
337        return $html;
338    }
339
340    /**
341     * Implement this function to provide the module body.
342     *
343     * @return string HTML content of the body
344     */
345    abstract protected function getBody();
346
347    /**
348     * @return string HTML string to be used as header of the mobile summary.
349     */
350    protected function getMobileSummaryHeader() {
351        return $this->getHeaderTextElement() . $this->getNavIcon();
352    }
353
354    /**
355     * @return string HTML string to be used as header of the mobile details.
356     */
357    protected function getMobileDetailsHeader() {
358        $icon = $this->getBackIcon();
359        $text = $this->getHeaderTextElement();
360        return $icon . $text;
361    }
362
363    private function getBackIcon() {
364        return Html::rawElement(
365            'a',
366            [
367                'href' => SpecialPage::getTitleFor( 'Homepage' )->getLinkURL(),
368            ],
369            new IconWidget( [
370                'icon' => 'arrowPrevious',
371                'classes' => [ static::BASE_CSS_CLASS . '-header-back-icon' ],
372            ] )
373        );
374    }
375
376    /**
377     * @return IconWidget The navigation icon.
378     */
379    protected function getNavIcon() {
380        return new IconWidget( [
381            'icon' => 'arrowNext',
382            'classes' => [ static::BASE_CSS_CLASS . '-header-nav-icon' ],
383        ] );
384    }
385
386    /**
387     * Implement this function to provide the module body
388     * when rendered as a mobile summary.
389     *
390     * @return string HTML content of the body
391     */
392    abstract protected function getMobileSummaryBody();
393
394    /**
395     * Provide optional subheader for the module
396     *
397     * @return string HTML content of the subheader
398     */
399    protected function getSubheader() {
400        return $this->getSubheaderTextElement();
401    }
402
403    /**
404     * Override this function to provide an optional subheader for the module
405     *
406     * @return string Text content of the subheader
407     */
408    protected function getSubheaderText() {
409        return '';
410    }
411
412    /**
413     * @return string HTML element containing the header text.
414     */
415    protected function getSubheaderTextElement() {
416        $text = $this->getSubheaderText();
417        return $text ? Html::element(
418            'div',
419            [ 'class' => static::BASE_CSS_CLASS . '-subheader-text' ],
420            $text
421        ) : '';
422    }
423
424    /**
425     * Override this function to change the default subheader tag.
426     *
427     * @return string Tag to use with the subheader, e.g. h2, h3, h4
428     */
429    protected function getSubheaderTag() {
430        return 'h3';
431    }
432
433    /**
434     * Override this function to provide an optional module footer.
435     *
436     * @return string HTML content of the footer
437     */
438    protected function getFooter() {
439        return '';
440    }
441
442    /**
443     * @param string $name Name of the icon
444     * @param bool $invert Whether the icon should be inverted
445     * @return IconWidget
446     */
447    protected function getHeaderIcon( $name, $invert ) {
448        $defaultIconClasses = [
449            self::BASE_CSS_CLASS . '-header-icon',
450            'icon-' . $name
451        ];
452        $invertClasses = $invert ?
453            [ 'oo-ui-image-invert', 'oo-ui-checkboxInputWidget-checkIcon' ] :
454            [];
455
456        return new IconWidget( [
457            'icon' => $name,
458            // HACK: IconWidget doesn't let us set 'invert' => true, and setting
459            // 'classes' => [ 'oo-ui-image-invert' ] doesn't work either, because
460            // Theme::getElementClasses() will unset it again. So instead, trick that code into
461            // thinking this is a checkbox icon, which will cause it to invert the icon
462            'classes' => array_merge( $defaultIconClasses, $invertClasses )
463        ] );
464    }
465
466    /**
467     * Override this function to provide the name of the header icon.
468     *
469     * @return string
470     */
471    abstract protected function getHeaderIconName();
472
473    /**
474     * @return bool Whether the header icon should be inverted.
475     */
476    protected function shouldInvertHeaderIcon() {
477        return false;
478    }
479
480    /**
481     * Override this method if header should include the icon
482     *
483     * No styles provided by default! Remember to position the icon manually via CSS.
484     *
485     * @return bool Should header include the icon?
486     */
487    protected function shouldHeaderIncludeIcon(): bool {
488        return false;
489    }
490
491    /**
492     * Alias for MessageLocalizer::msg
493     *
494     * @param string|string[]|MessageSpecifier $key
495     * @param mixed ...$params
496     * @return Message
497     * @see MessageLocalizer::msg()
498     */
499    protected function msg( $key, ...$params ) {
500        return $this->getContext()->msg( $key, ...$params );
501    }
502}