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