Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 233 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
CNCampaignPager | |
0.00% |
0 / 233 |
|
0.00% |
0 / 14 |
4556 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
getQueryInfo | |
0.00% |
0 / 49 |
|
0.00% |
0 / 1 |
12 | |||
doQuery | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getFieldNames | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
getStartBody | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
formatValue | |
0.00% |
0 / 92 |
|
0.00% |
0 / 1 |
930 | |||
getRowClass | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
getCellAttrs | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
72 | |||
getEndBody | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
6 | |||
isFieldSortable | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
getDefaultSort | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
isWithinLimit | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
6 | |||
extractResultInfo | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getTableClass | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | use MediaWiki\Html\Html; |
4 | use MediaWiki\Pager\TablePager; |
5 | use MediaWiki\Xml\Xml; |
6 | use Wikimedia\Rdbms\IDatabase; |
7 | use 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 | */ |
17 | class 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 | } |