Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 236 |
|
0.00% |
0 / 14 |
CRAP | |
0.00% |
0 / 1 |
CNCampaignPager | |
0.00% |
0 / 236 |
|
0.00% |
0 / 14 |
4556 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
12 | |||
getQueryInfo | |
0.00% |
0 / 52 |
|
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 Wikimedia\Rdbms\IDatabase; |
5 | use 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 | */ |
15 | class 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 | } |