Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
4.58% |
6 / 131 |
|
0.00% |
0 / 39 |
CRAP | |
0.00% |
0 / 1 |
DashboardModule | |
4.58% |
6 / 131 |
|
0.00% |
0 / 39 |
2049.70 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
getContext | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getConfig | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setMode | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
canRender | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
shouldRender | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getModuleStyles | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getModules | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCssClasses | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
supports | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getJsData | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getJsConfigVars | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
outputDependencies | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
render | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
3.03 | |||
getHtml | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
buildModuleWrapper | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
buildSection | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
renderDesktop | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
renderMobileSummary | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
renderMobileDetails | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderTextElement | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderText | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getHeaderTag | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHeader | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
getBody | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getMobileSummaryHeader | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMobileDetailsHeader | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getBackIcon | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
getNavIcon | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getMobileSummaryBody | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getSubheader | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSubheaderText | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getSubheaderTextElement | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
getSubheaderTag | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getFooter | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getHeaderIcon | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
getHeaderIconName | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
shouldInvertHeaderIcon | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
shouldHeaderIncludeIcon | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
msg | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments\DashboardModule; |
4 | |
5 | use InvalidArgumentException; |
6 | use MediaWiki\Config\Config; |
7 | use MediaWiki\Context\IContextSource; |
8 | use MediaWiki\Html\Html; |
9 | use MediaWiki\Message\Message; |
10 | use MediaWiki\SpecialPage\SpecialPage; |
11 | use MediaWiki\User\User; |
12 | use OOUI\IconWidget; |
13 | use Wikimedia\Message\MessageSpecifier; |
14 | |
15 | abstract 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 | } |