Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 233
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
CNCampaignPager
0.00% covered (danger)
0.00%
0 / 233
0.00% covered (danger)
0.00%
0 / 14
4556
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
12
 doQuery
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getFieldNames
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getStartBody
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 formatValue
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 1
930
 getRowClass
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getCellAttrs
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
72
 getEndBody
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 isFieldSortable
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getDefaultSort
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isWithinLimit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 extractResultInfo
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTableClass
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3use MediaWiki\Html\Html;
4use MediaWiki\Pager\TablePager;
5use MediaWiki\Xml\Xml;
6use Wikimedia\Rdbms\IDatabase;
7use Wikimedia\Rdbms\IResultWrapper;
8
9/**
10 * A pager for viewing lists of CentralNotice campaigns. Optionally allows
11 * modification of some campaign properties. It is expected that this will only
12 * be included on special pages that are subclasses of CentralNotice.
13 *
14 * This class is a reorganization of code formerly in
15 * CentralNotice::listNotices().
16 */
17class CNCampaignPager extends TablePager {
18
19    // For now, we want to make this display without paging on
20    // meta.wikimedia.org, in line with the functionality that users currently
21    // encounter.
22    // This should be enough--Meta has less than 500 campaigns.
23    private const DEFAULT_LIMIT = 5000;
24
25    /** @var CentralNotice */
26    private $onSpecialCN;
27    /** @var string|false */
28    private $editable;
29    /** @var int|null */
30    private $assignedBannerId;
31    /** @var bool|null */
32    private $showArchived;
33    /** @var string[]|null */
34    private $fieldNames = null;
35
36    /**
37     * @param CentralNotice $onSpecialCN The CentralNotice special page we're on
38     * @param string|false $editable Whether or not to make the list editable
39     * @param int|null $assignedBannerId Set this to show only the campaigns
40     *   associated with this banner id.
41     * @param bool|null $showArchived Set true to only show archived campaigns,
42     *      false to only show unarchived campaigns
43     */
44    public function __construct( CentralNotice $onSpecialCN,
45        $editable = false, $assignedBannerId = null, $showArchived = null
46    ) {
47        $this->onSpecialCN = $onSpecialCN;
48        $this->assignedBannerId = $assignedBannerId;
49        $this->editable = $editable;
50        $this->showArchived = $showArchived;
51
52        parent::__construct( $onSpecialCN->getContext() );
53
54        $req = $onSpecialCN->getRequest();
55
56        // The 'limit' request param is used by the pager superclass.
57        // If it's absent, we'll set the limit to our own default.
58        $this->setLimit(
59            $req->getVal( 'limit', null ) ?:
60            self::DEFAULT_LIMIT );
61
62        // If the request doesn't an order by value, set descending order.
63        // This makes our order-by-id compatible with the previous default
64        // ordering in the UI.
65        if ( !$req->getVal( 'sort', null ) ) {
66            $this->mDefaultDirection = true;
67        }
68    }
69
70    /**
71     * @inheritDoc
72     */
73    public function getQueryInfo() {
74        $db = $this->getDatabase();
75        $pagerQuery = [
76            'tables' => [
77                'notices' => 'cn_notices',
78            ],
79            'fields' => [
80                'notices.not_id',
81                'not_name',
82                'not_type',
83                'not_start',
84                'not_end',
85                'not_enabled',
86                'not_preferred',
87                'not_throttle',
88                'not_geo',
89                'not_locked',
90                'not_archived',
91                'countries' => $db->newSelectQueryBuilder()
92                    ->table( 'cn_notice_countries' )
93                    ->field( 'nc_country' )
94                    ->where( 'nc_notice_id = notices.not_id' )
95                    ->buildGroupConcatField( ',' ),
96                'regions' => $db->newSelectQueryBuilder()
97                    ->table( 'cn_notice_regions' )
98                    ->field( 'nr_region' )
99                    ->where( 'nr_notice_id = notices.not_id' )
100                    ->buildGroupConcatField( ',' ),
101                'languages' => $db->newSelectQueryBuilder()
102                    ->table( 'cn_notice_languages' )
103                    ->field( 'nl_language' )
104                    ->where( 'nl_notice_id = notices.not_id' )
105                    ->buildGroupConcatField( ',' ),
106                'projects' => $db->newSelectQueryBuilder()
107                    ->table( 'cn_notice_projects' )
108                    ->field( 'np_project' )
109                    ->where( 'np_notice_id = notices.not_id' )
110                    ->buildGroupConcatField( ',' ),
111            ],
112            'conds' => [],
113        ];
114
115        if ( $this->assignedBannerId ) {
116            // Query for only campaigns associated with a specific banner id.
117            $pagerQuery['tables']['assignments'] = 'cn_assignments';
118            $pagerQuery['conds'] = [
119                'notices.not_id = assignments.not_id',
120                'assignments.tmp_id' => (int)$this->assignedBannerId,
121            ];
122        }
123
124        if ( $this->showArchived !== null ) {
125            $pagerQuery['conds']['not_archived'] = (int)$this->showArchived;
126        }
127
128        return $pagerQuery;
129    }
130
131    public function doQuery() {
132        // group_concat output is limited to 1024 characters by default, increase
133        // the limit temporarily so the list of all languages can be rendered.
134        $db = $this->getDatabase();
135        if ( $db instanceof IDatabase ) {
136            $db->setSessionOptions( [ 'groupConcatMaxLen' => 10000 ] );
137        }
138
139        parent::doQuery();
140    }
141
142    /**
143     * @inheritDoc
144     */
145    public function getFieldNames() {
146        if ( !$this->fieldNames ) {
147            $this->fieldNames = [
148                'not_name' => $this->msg( 'centralnotice-notice-name' )->text(),
149                'not_type' => $this->msg( 'centralnotice-campaign-type' )->text(),
150                'projects' => $this->msg( 'centralnotice-projects' )->text(),
151                'languages' => $this->msg( 'centralnotice-languages' )->text(),
152                'location' => $this->msg( 'centralnotice-location' )->text(),
153                'not_start' => $this->msg( 'centralnotice-start-timestamp' )->text(),
154                'not_end' => $this->msg( 'centralnotice-end-timestamp' )->text(),
155                'not_enabled' => $this->msg( 'centralnotice-enabled' )->text(),
156                'not_preferred' => $this->msg( 'centralnotice-preferred' )->text(),
157                'not_throttle' => $this->msg( 'centralnotice-throttle' )->text(),
158                'not_locked' => $this->msg( 'centralnotice-locked' )->text(),
159                'not_archived' => $this->msg( 'centralnotice-archive-campaign' )->text()
160            ];
161        }
162
163        return $this->fieldNames;
164    }
165
166    /**
167     * @inheritDoc
168     */
169    public function getStartBody() {
170        $htmlOut = '';
171
172        $htmlOut .= Html::openElement(
173            'fieldset',
174            [
175                'class' => 'prefsection',
176                'id' => 'cn-campaign-pager',
177                'data-editable' => ( $this->editable ? 1 : 0 )
178            ]
179        );
180
181        return $htmlOut . parent::getStartBody();
182    }
183
184    /**
185     * Format the data in the pager
186     *
187     * This calls a method which calls Language::listToText. Language
188     * uses ->escaped() messages for commas, so this triggers a double
189     * escape warning in phan. However in terms of double escaping, a
190     * comma message doesn't matter that much, and it would be difficult
191     * to avoid without rewriting how all these classes work, so we
192     * suppress this for now, and leave fixing it as a future FIXME.
193     * @suppress SecurityCheck-DoubleEscaped
194     * @param string $fieldName While field are we formatting
195     * @param string $value The value for the field
196     * @return string HTML
197     */
198    public function formatValue( $fieldName, $value ) {
199        // These are used in a few cases below.
200        $rowIsEnabled = (bool)$this->mCurrentRow->not_enabled;
201        $rowIsLocked = (bool)$this->mCurrentRow->not_locked;
202        $rowIsArchived = (bool)$this->mCurrentRow->not_archived;
203        $name = $this->mCurrentRow->not_name;
204        $readonly = [ 'disabled' => 'disabled' ];
205
206        switch ( $fieldName ) {
207            case 'not_name':
208                $linkRenderer = $this->getLinkRenderer();
209                return $linkRenderer->makeLink(
210                    Campaign::getTitleForURL(),
211                    $value,
212                    [],
213                    Campaign::getQueryForURL( $value )
214                );
215
216            case 'not_type':
217                return $this->onSpecialCN->campaignTypeSelector(
218                    $this->editable && !$rowIsLocked && !$rowIsArchived,
219                    $value,
220                    $name
221                );
222
223            case 'projects':
224                $p = explode( ',', $this->mCurrentRow->projects );
225                return htmlspecialchars( $this->onSpecialCN->listProjects( $p ) );
226
227            case 'languages':
228                $l = explode( ',', $this->mCurrentRow->languages );
229                return htmlspecialchars( $this->onSpecialCN->listLanguages( $l ) );
230
231            case 'location':
232                $countries = $this->mCurrentRow->countries
233                    ? explode( ',', $this->mCurrentRow->countries )
234                    : [];
235                $regions = $this->mCurrentRow->regions
236                    ? explode( ',', $this->mCurrentRow->regions )
237                    : [];
238                // if not geotargeted or no countries and regions chosen, show "all"
239                $emptyGeo = !$countries && !$regions;
240                if ( !$this->mCurrentRow->not_geo || $emptyGeo ) {
241                    return $this->msg( 'centralnotice-all' )->text();
242                }
243
244                $list = $this->onSpecialCN->listCountriesRegions( $countries, $regions );
245
246                return htmlspecialchars( $list );
247
248            case 'not_start':
249            case 'not_end':
250                return date( '<\b>Y-m-d</\b> H:i', (int)wfTimestamp( TS_UNIX, $value ) );
251
252            // Note: Names of controls and data attributes must coordinate with
253            // ext.centralNotice.adminUi.campaignPager.js
254
255            case 'not_enabled':
256                return Html::check(
257                    'enabled',
258                    $rowIsEnabled,
259                    array_replace(
260                        ( !$this->editable || $rowIsLocked || $rowIsArchived )
261                        ? $readonly : [],
262                        [
263                            'data-campaign-name' => $name,
264                            'data-initial-value' => $rowIsEnabled,
265                            'class' => 'noshiftselect mw-cn-input-check-sort'
266                        ]
267                    )
268                );
269
270            case 'not_preferred':
271                return $this->onSpecialCN->prioritySelector(
272                    $name,
273                    $this->editable && !$rowIsLocked && !$rowIsArchived,
274                    (int)$value
275                );
276
277            case 'not_throttle':
278                if ( $value < 100 ) {
279                    return htmlspecialchars( $value . "%" );
280                } else {
281                    return '';
282                }
283
284            case 'not_locked':
285                return Html::check(
286                    'locked',
287                    $rowIsLocked,
288                    array_replace(
289                        // Note: Lockability should always be modifiable
290                        // regardless of whether the camapgin is archived.
291                        // Otherwise we create a dead-end state of locked and
292                        // archived.
293                        ( !$this->editable )
294                        ? $readonly : [],
295                        [
296                            'data-campaign-name' => $name,
297                            'data-initial-value' => $rowIsLocked,
298                            'class' => 'noshiftselect mw-cn-input-check-sort'
299                        ]
300                    )
301                );
302
303            case 'not_archived':
304                return Html::check(
305                    'archived',
306                    $rowIsArchived,
307                    array_replace(
308                        ( !$this->editable || $rowIsLocked || $rowIsEnabled )
309                        ? $readonly : [],
310                        [
311                            'data-campaign-name' => $name,
312                            'data-initial-value' => $rowIsArchived,
313                            'class' => 'noshiftselect mw-cn-input-check-sort'
314                        ]
315                    )
316                );
317        }
318    }
319
320    /**
321     * Set special CSS classes for active and archived campaigns.
322     *
323     * @inheritDoc
324     */
325    public function getRowClass( $row ) {
326        $enabled = (bool)$row->not_enabled;
327
328        $now = wfTimestamp();
329        $started = $now >= wfTimestamp( TS_UNIX, $row->not_start );
330        $notEnded = $now <= wfTimestamp( TS_UNIX, $row->not_end );
331
332        $cssClass = parent::getRowClass( $row );
333
334        if ( $enabled && $started && $notEnded ) {
335            $cssClass .= ' cn-active-campaign';
336        }
337
338        return $cssClass;
339    }
340
341    /**
342     * @inheritDoc
343     */
344    public function getCellAttrs( $field, $value ) {
345        $attrs = parent::getCellAttrs( $field, $value );
346
347        switch ( $field ) {
348            case 'not_start':
349            case 'not_end':
350                // Set css class, or add to the class(es) set by parent
351                $attrs['class'] = ltrim( ( $attrs['class'] ?? '' ) . ' cn-date-column' );
352                break;
353
354            case 'not_enabled':
355            case 'not_preferred':
356            case 'not_throttle':
357            case 'not_locked':
358            case 'not_archived':
359                // These fields use the extra sort-value attribute for JS
360                // sorting.
361                $attrs['data-sort-value'] = $value;
362        }
363
364        return $attrs;
365    }
366
367    /**
368     * @inheritDoc
369     */
370    public function getEndBody() {
371        $htmlOut = '';
372
373        if ( $this->editable ) {
374            $htmlOut .=
375                Html::openElement( 'div',
376                [ 'class' => 'cn-buttons cn-formsection-emphasis' ] );
377
378            $htmlOut .= $this->onSpecialCN->makeSummaryField();
379
380            $htmlOut .= Xml::input(
381                'centralnoticesubmit',
382                false,
383                $this->msg( 'centralnotice-modify' )->text(),
384                [
385                    'type' => 'button',
386                    'id' => 'cn-campaign-pager-submit'
387                ]
388            );
389
390            $htmlOut .= Html::closeElement( 'div' );
391        }
392
393        $htmlOut .= Html::closeElement( 'fieldset' );
394
395        return parent::getEndBody() . $htmlOut;
396    }
397
398    /**
399     * @inheritDoc
400     */
401    public function isFieldSortable( $field ) {
402        // If this is the only page shown, we'll sort via JS, which works on all
403        // columns.
404        if ( $this->isWithinLimit() ) {
405            return false;
406        }
407
408        // Because of how paging works, it seems that only unique columns can be
409        // ordered if there's more than one page of results.
410        // TODO If paging is ever needed in the UI, it should be possible to
411        // partially address this by using the id as a secondary field for
412        // ordering and for the paging offset. Some fields still won't be
413        // sortable via the DB because of how values are munged in the UI (for
414        // example, "All" and "All except..." for languages and countries).
415        // If needed, filters could be added for such fields, though.
416        if ( $field === 'not_name' ) {
417            return true;
418        }
419
420        return false;
421    }
422
423    /**
424     * @inheritDoc
425     */
426    public function getDefaultSort() {
427        return $this->assignedBannerId === null ?
428            'not_id' : 'notices.not_id';
429    }
430
431    /**
432     * Returns true if this is the only page of results there is to show.
433     * @return bool
434     */
435    private function isWithinLimit() {
436        return $this->mIsFirst && $this->mIsLast;
437    }
438
439    /**
440     * @inheritDoc
441     */
442    public function extractResultInfo( $isFirst, $limit, IResultWrapper $res ) {
443        parent::extractResultInfo( $isFirst, $limit, $res );
444
445        // Disable editing if there's more than one page. (This is a legacy
446        // requirement; it might work even with paging now.)
447        if ( !$this->isWithinLimit() ) {
448            $this->editable = false;
449        }
450    }
451
452    /**
453     * @inheritDoc
454     */
455    public function getTableClass() {
456        $jsSortable = $this->isWithinLimit() ? ' sortable' : '';
457        return parent::getTableClass() . ' wikitable' . $jsSortable;
458    }
459}