Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 235
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 / 235
0.00% covered (danger)
0.00%
0 / 14
4830
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
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 / 96
0.00% covered (danger)
0.00%
0 / 1
1056
 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 / 17
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 Wikimedia\Rdbms\IDatabase;
6use Wikimedia\Rdbms\IResultWrapper;
7
8/**
9 * A pager for viewing lists of CentralNotice campaigns. Optionally allows
10 * modification of some campaign properties. It is expected that this will only
11 * be included on special pages that are subclasses of CentralNotice.
12 *
13 * This class is a reorganization of code formerly in
14 * CentralNotice::listNotices().
15 */
16class CNCampaignPager extends TablePager {
17
18    // For now, we want to make this display without paging on
19    // meta.wikimedia.org, in line with the functionality that users currently
20    // encounter.
21    // This should be enough--Meta has less than 500 campaigns.
22    private const DEFAULT_LIMIT = 5000;
23
24    /** @var string|false */
25    private $editable;
26    /** @var int|null */
27    private $assignedBannerId;
28    /** @var bool|null */
29    private $showArchived;
30    /** @var string[]|null */
31    private $fieldNames = null;
32
33    /**
34     * @param CentralNotice $onSpecialCN The CentralNotice special page we're on
35     * @param string|false $editable Whether or not to make the list editable
36     * @param int|null $assignedBannerId Set this to show only the campaigns
37     *   associated with this banner id.
38     * @param bool|null $showArchived Set true to only show archived campaigns,
39     *      false to only show unarchived campaigns
40     */
41    public function __construct(
42        private readonly CentralNotice $onSpecialCN,
43        $editable = false, $assignedBannerId = null, $showArchived = null
44    ) {
45        $this->assignedBannerId = $assignedBannerId;
46        $this->editable = $editable;
47        $this->showArchived = $showArchived;
48
49        parent::__construct( $onSpecialCN->getContext() );
50
51        $req = $onSpecialCN->getRequest();
52
53        // The 'limit' request param is used by the pager superclass.
54        // If it's absent, we'll set the limit to our own default.
55        $this->setLimit(
56            $req->getVal( 'limit', null ) ?:
57            self::DEFAULT_LIMIT );
58
59        // If the request doesn't an order by value, set descending order.
60        // This makes our order-by-id compatible with the previous default
61        // ordering in the UI.
62        if ( !$req->getVal( 'sort', null ) ) {
63            $this->mDefaultDirection = true;
64        }
65    }
66
67    /**
68     * @inheritDoc
69     */
70    public function getQueryInfo() {
71        $db = $this->getDatabase();
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                'countries' => $db->newSelectQueryBuilder()
89                    ->table( 'cn_notice_countries' )
90                    ->field( 'nc_country' )
91                    ->where( 'nc_notice_id = notices.not_id' )
92                    ->buildGroupConcatField( ',' ),
93                'regions' => $db->newSelectQueryBuilder()
94                    ->table( 'cn_notice_regions' )
95                    ->field( 'nr_region' )
96                    ->where( 'nr_notice_id = notices.not_id' )
97                    ->buildGroupConcatField( ',' ),
98                'languages' => $db->newSelectQueryBuilder()
99                    ->table( 'cn_notice_languages' )
100                    ->field( 'nl_language' )
101                    ->where( 'nl_notice_id = notices.not_id' )
102                    ->buildGroupConcatField( ',' ),
103                'projects' => $db->newSelectQueryBuilder()
104                    ->table( 'cn_notice_projects' )
105                    ->field( 'np_project' )
106                    ->where( 'np_notice_id = notices.not_id' )
107                    ->buildGroupConcatField( ',' ),
108            ],
109            'conds' => [],
110        ];
111
112        if ( $this->assignedBannerId ) {
113            // Query for only campaigns associated with a specific banner id.
114            $pagerQuery['tables']['assignments'] = 'cn_assignments';
115            $pagerQuery['conds'] = [
116                'notices.not_id = assignments.not_id',
117                'assignments.tmp_id' => (int)$this->assignedBannerId,
118            ];
119        }
120
121        if ( $this->showArchived !== null ) {
122            $pagerQuery['conds']['not_archived'] = (int)$this->showArchived;
123        }
124
125        return $pagerQuery;
126    }
127
128    public function doQuery() {
129        // group_concat output is limited to 1024 characters by default, increase
130        // the limit temporarily so the list of all languages can be rendered.
131        $db = $this->getDatabase();
132        if ( $db instanceof IDatabase ) {
133            $db->setSessionOptions( [ 'groupConcatMaxLen' => 10000 ] );
134        }
135
136        parent::doQuery();
137    }
138
139    /**
140     * @inheritDoc
141     */
142    public function getFieldNames() {
143        if ( !$this->fieldNames ) {
144            $this->fieldNames = [
145                'not_name' => $this->msg( 'centralnotice-notice-name' )->text(),
146                'not_type' => $this->msg( 'centralnotice-campaign-type' )->text(),
147                'projects' => $this->msg( 'centralnotice-projects' )->text(),
148                'languages' => $this->msg( 'centralnotice-languages' )->text(),
149                'location' => $this->msg( 'centralnotice-location' )->text(),
150                'not_start' => $this->msg( 'centralnotice-start-timestamp' )->text(),
151                'not_end' => $this->msg( 'centralnotice-end-timestamp' )->text(),
152                'not_enabled' => $this->msg( 'centralnotice-enabled' )->text(),
153                'not_preferred' => $this->msg( 'centralnotice-preferred' )->text(),
154                'not_throttle' => $this->msg( 'centralnotice-throttle' )->text(),
155                'not_locked' => $this->msg( 'centralnotice-locked' )->text(),
156                'not_archived' => $this->msg( 'centralnotice-archive-campaign' )->text()
157            ];
158        }
159
160        return $this->fieldNames;
161    }
162
163    /**
164     * @inheritDoc
165     */
166    public function getStartBody() {
167        $htmlOut = '';
168
169        $htmlOut .= Html::openElement(
170            'fieldset',
171            [
172                'class' => 'prefsection',
173                'id' => 'cn-campaign-pager',
174                'data-editable' => ( $this->editable ? 1 : 0 )
175            ]
176        );
177
178        return $htmlOut . parent::getStartBody();
179    }
180
181    /**
182     * Format the data in the pager
183     *
184     * This calls a method which calls Language::listToText. Language
185     * uses ->escaped() messages for commas, so this triggers a double
186     * escape warning in phan. However in terms of double escaping, a
187     * comma message doesn't matter that much, and it would be difficult
188     * to avoid without rewriting how all these classes work, so we
189     * suppress this for now, and leave fixing it as a future FIXME.
190     * @suppress SecurityCheck-DoubleEscaped
191     * @param string $fieldName While field are we formatting
192     * @param string $value The value for the field
193     * @return string HTML
194     */
195    public function formatValue( $fieldName, $value ) {
196        // These are used in a few cases below.
197        $rowIsEnabled = (bool)$this->mCurrentRow->not_enabled;
198        $rowIsLocked = (bool)$this->mCurrentRow->not_locked;
199        $rowIsArchived = (bool)$this->mCurrentRow->not_archived;
200        $name = $this->mCurrentRow->not_name;
201        $readonly = [ 'disabled' => 'disabled' ];
202
203        switch ( $fieldName ) {
204            case 'not_name':
205                $linkRenderer = $this->getLinkRenderer();
206                return $linkRenderer->makeLink(
207                    Campaign::getTitleForURL(),
208                    $value,
209                    [],
210                    Campaign::getQueryForURL( $value )
211                );
212
213            case 'not_type':
214                return $this->onSpecialCN->campaignTypeSelector(
215                    $this->editable && !$rowIsLocked && !$rowIsArchived,
216                    $value,
217                    $name
218                );
219
220            case 'projects':
221                $p = $this->mCurrentRow->projects
222                    ? explode( ',', $this->mCurrentRow->projects )
223                    : [];
224                return htmlspecialchars( $this->onSpecialCN->listProjects( $p ) );
225
226            case 'languages':
227                $l = $this->mCurrentRow->languages
228                    ? explode( ',', $this->mCurrentRow->languages )
229                    : [];
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 Html::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 Html::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 Html::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 .= Html::input(
382                'centralnoticesubmit',
383                $this->msg( 'centralnotice-modify' )->text(),
384                'button',
385                [
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}