1<?php 2/** 3 * Efficient paging for SQL queries. 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 * @ingroup Pager 22 */ 23 24use MediaWiki\Linker\LinkRenderer; 25 26/** 27 * Table-based display with a user-selectable sort order 28 * @stable to extend 29 * @ingroup Pager 30 */ 31abstract class TablePager extends IndexPager { 32 /** @var string */ 33 protected $mSort; 34 35 /** @var stdClass */ 36 protected $mCurrentRow; 37 38 /** 39 * @stable to call 40 * 41 * @param IContextSource|null $context 42 * @param LinkRenderer|null $linkRenderer 43 */ 44 public function __construct( IContextSource $context = null, LinkRenderer $linkRenderer = null ) { 45 if ( $context ) { 46 $this->setContext( $context ); 47 } 48 49 $this->mSort = $this->getRequest()->getText( 'sort' ); 50 if ( !array_key_exists( $this->mSort, $this->getFieldNames() ) 51 || !$this->isFieldSortable( $this->mSort ) 52 ) { 53 $this->mSort = $this->getDefaultSort(); 54 } 55 if ( $this->getRequest()->getBool( 'asc' ) ) { 56 $this->mDefaultDirection = IndexPager::DIR_ASCENDING; 57 } elseif ( $this->getRequest()->getBool( 'desc' ) ) { 58 $this->mDefaultDirection = IndexPager::DIR_DESCENDING; 59 } /* Else leave it at whatever the class default is */ 60 61 // Parent constructor needs mSort set, so we call it last 62 parent::__construct( null, $linkRenderer ); 63 } 64 65 /** 66 * Get the formatted result list. Calls getStartBody(), formatRow() and getEndBody(), concatenates 67 * the results and returns them. 68 * 69 * Also adds the required styles to our OutputPage object (this means that if context wasn't 70 * passed to constructor or otherwise set up, you will get a pager with missing styles). 71 * 72 * This method has been made 'final' in 1.24. There's no reason to override it, and if there exist 73 * any subclasses that do, the style loading hack is probably broken in them. Let's fail fast 74 * rather than mysteriously render things wrong. 75 * 76 * @deprecated since 1.24, use getBodyOutput() or getFullOutput() instead 77 * @return string 78 */ 79 final public function getBody() { 80 $this->getOutput()->addModuleStyles( $this->getModuleStyles() ); 81 return parent::getBody(); 82 } 83 84 /** 85 * Get the formatted result list. 86 * 87 * Calls getBody() and getModuleStyles() and builds a ParserOutput object. (This is a bit hacky 88 * but works well.) 89 * 90 * @since 1.24 91 * @return ParserOutput 92 */ 93 public function getBodyOutput() { 94 $body = parent::getBody(); 95 96 $pout = new ParserOutput; 97 $pout->setText( $body ); 98 $pout->addModuleStyles( $this->getModuleStyles() ); 99 return $pout; 100 } 101 102 /** 103 * Get the formatted result list, with navigation bars. 104 * 105 * Calls getBody(), getNavigationBar() and getModuleStyles() and 106 * builds a ParserOutput object. (This is a bit hacky but works well.) 107 * 108 * @since 1.24 109 * @return ParserOutput 110 */ 111 public function getFullOutput() { 112 $navigation = $this->getNavigationBar(); 113 $body = parent::getBody(); 114 115 $pout = new ParserOutput; 116 $pout->setText( $navigation . $body . $navigation ); 117 $pout->addModuleStyles( $this->getModuleStyles() ); 118 return $pout; 119 } 120 121 /** 122 * @stable to override 123 * @return string 124 */ 125 protected function getStartBody() { 126 $sortClass = $this->getSortHeaderClass(); 127 128 $s = ''; 129 $fields = $this->getFieldNames(); 130 131 // Make table header 132 foreach ( $fields as $field => $name ) { 133 if ( strval( $name ) == '' ) { 134 $s .= Html::rawElement( 'th', [], "\u{00A0}" ) . "\n"; 135 } elseif ( $this->isFieldSortable( $field ) ) { 136 $query = [ 'sort' => $field, 'limit' => $this->mLimit ]; 137 $linkType = null; 138 $class = null; 139 140 if ( $this->mSort == $field ) { 141 // The table is sorted by this field already, make a link to sort in the other direction 142 // We don't actually know in which direction other fields will be sorted by default… 143 if ( $this->mDefaultDirection == IndexPager::DIR_DESCENDING ) { 144 $linkType = 'asc'; 145 $class = "$sortClass mw-datatable-is-sorted mw-datatable-is-descending"; 146 $query['asc'] = '1'; 147 $query['desc'] = ''; 148 } else { 149 $linkType = 'desc'; 150 $class = "$sortClass mw-datatable-is-sorted mw-datatable-is-ascending"; 151 $query['asc'] = ''; 152 $query['desc'] = '1'; 153 } 154 } 155 156 $link = $this->makeLink( htmlspecialchars( $name ), $query, $linkType ); 157 $s .= Html::rawElement( 'th', [ 'class' => $class ], $link ) . "\n"; 158 } else { 159 $s .= Html::element( 'th', [], $name ) . "\n"; 160 } 161 } 162 163 $tableClass = $this->getTableClass(); 164 $ret = Html::openElement( 'table', [ 165 'class' => " $tableClass" ] 166 ); 167 $ret .= Html::rawElement( 'thead', [], Html::rawElement( 'tr', [], "\n" . $s . "\n" ) ); 168 $ret .= Html::openElement( 'tbody' ) . "\n"; 169 170 return $ret; 171 } 172 173 /** 174 * @stable to override 175 * @return string 176 */ 177 protected function getEndBody() { 178 return "</tbody></table>\n"; 179 } 180 181 /** 182 * @return string 183 */ 184 protected function getEmptyBody() { 185 $colspan = count( $this->getFieldNames() ); 186 $msgEmpty = $this->msg( 'table_pager_empty' )->text(); 187 return Html::rawElement( 'tr', [], 188 Html::element( 'td', [ 'colspan' => $colspan ], $msgEmpty ) ); 189 } 190 191 /** 192 * @stable to override 193 * @param stdClass $row 194 * @return string HTML 195 */ 196 public function formatRow( $row ) { 197 $this->mCurrentRow = $row; // In case formatValue etc need to know 198 $s = Html::openElement( 'tr', $this->getRowAttrs( $row ) ) . "\n"; 199 $fieldNames = $this->getFieldNames(); 200 201 foreach ( $fieldNames as $field => $name ) { 202 $value = $row->$field ?? null; 203 $formatted = strval( $this->formatValue( $field, $value ) ); 204 205 if ( $formatted == '' ) { 206 $formatted = "\u{00A0}"; 207 } 208 209 $s .= Html::rawElement( 'td', $this->getCellAttrs( $field, $value ), $formatted ) . "\n"; 210 } 211 212 $s .= Html::closeElement( 'tr' ) . "\n"; 213 214 return $s; 215 } 216 217 /** 218 * Get a class name to be applied to the given row. 219 * 220 * @stable to override 221 * 222 * @param object $row The database result row 223 * @return string 224 */ 225 protected function getRowClass( $row ) { 226 return ''; 227 } 228 229 /** 230 * Get attributes to be applied to the given row. 231 * 232 * @stable to override 233 * 234 * @param object $row The database result row 235 * @return array Array of attribute => value 236 */ 237 protected function getRowAttrs( $row ) { 238 $class = $this->getRowClass( $row ); 239 if ( $class === '' ) { 240 // Return an empty array to avoid clutter in HTML like class="" 241 return []; 242 } else { 243 return [ 'class' => $this->getRowClass( $row ) ]; 244 } 245 } 246 247 /** 248 * @return stdClass 249 */ 250 protected function getCurrentRow() { 251 return $this->mCurrentRow; 252 } 253 254 /** 255 * Get any extra attributes to be applied to the given cell. Don't 256 * take this as an excuse to hardcode styles; use classes and 257 * CSS instead. Row context is available in $this->mCurrentRow 258 * 259 * @stable to override 260 * 261 * @param string $field The column 262 * @param string $value The cell contents 263 * @return array Array of attr => value 264 */ 265 protected function getCellAttrs( $field, $value ) { 266 return [ 'class' => 'TablePager_col_' . $field ]; 267 } 268 269 /** 270 * @inheritDoc 271 * @stable to override 272 */ 273 public function getIndexField() { 274 return $this->mSort; 275 } 276 277 /** 278 * TablePager relies on `mw-datatable` for styling, see T214208 279 * 280 * @stable to override 281 * @return string 282 */ 283 protected function getTableClass() { 284 return 'mw-datatable'; 285 } 286 287 /** 288 * @stable to override 289 * @return string 290 */ 291 protected function getNavClass() { 292 return 'TablePager_nav'; 293 } 294 295 /** 296 * @stable to override 297 * @return string 298 */ 299 protected function getSortHeaderClass() { 300 return 'TablePager_sort'; 301 } 302 303 /** 304 * A navigation bar with images 305 * 306 * @stable to override 307 * @return string HTML 308 */ 309 public function getNavigationBar() { 310 if ( !$this->isNavigationBarShown() ) { 311 return ''; 312 } 313 314 $this->getOutput()->enableOOUI(); 315 316 $types = [ 'first', 'prev', 'next', 'last' ]; 317 318 $queries = $this->getPagingQueries(); 319 320 $buttons = []; 321 322 $title = $this->getTitle(); 323 324 foreach ( $types as $type ) { 325 $buttons[] = new \OOUI\ButtonWidget( [ 326 // Messages used here: 327 // * table_pager_first 328 // * table_pager_prev 329 // * table_pager_next 330 // * table_pager_last 331 'classes' => [ 'TablePager-button-' . $type ], 332 'flags' => [ 'progressive' ], 333 'framed' => false, 334 'label' => $this->msg( 'table_pager_' . $type )->text(), 335 'href' => $queries[ $type ] ? 336 $title->getLinkURL( $queries[ $type ] + $this->getDefaultQuery() ) : 337 null, 338 'icon' => $type === 'prev' ? 'previous' : $type, 339 'disabled' => $queries[ $type ] === false 340 ] ); 341 } 342 return new \OOUI\ButtonGroupWidget( [ 343 'classes' => [ $this->getNavClass() ], 344 'items' => $buttons, 345 ] ); 346 } 347 348 /** 349 * ResourceLoader modules that must be loaded to provide correct styling for this pager 350 * 351 * @stable to override 352 * @since 1.24 353 * @return string[] 354 */ 355 public function getModuleStyles() { 356 return [ 'mediawiki.pager.tablePager', 'oojs-ui.styles.icons-movement' ]; 357 } 358 359 /** 360 * Get a "<select>" element which has options for each of the allowed limits 361 * 362 * @param string[] $attribs Extra attributes to set 363 * @return string HTML fragment 364 */ 365 public function getLimitSelect( $attribs = [] ) { 366 $select = new XmlSelect( 'limit', false, $this->mLimit ); 367 $select->addOptions( $this->getLimitSelectList() ); 368 foreach ( $attribs as $name => $value ) { 369 $select->setAttribute( $name, $value ); 370 } 371 return $select->getHTML(); 372 } 373 374 /** 375 * Get a list of items to show in a "<select>" element of limits. 376 * This can be passed directly to XmlSelect::addOptions(). 377 * 378 * @since 1.22 379 * @return array 380 */ 381 public function getLimitSelectList() { 382 # Add the current limit from the query string 383 # to avoid that the limit is lost after clicking Go next time 384 if ( !in_array( $this->mLimit, $this->mLimitsShown ) ) { 385 $this->mLimitsShown[] = $this->mLimit; 386 sort( $this->mLimitsShown ); 387 } 388 $ret = []; 389 foreach ( $this->mLimitsShown as $key => $value ) { 390 # The pair is either $index => $limit, in which case the $value 391 # will be numeric, or $limit => $text, in which case the $value 392 # will be a string. 393 if ( is_int( $value ) ) { 394 $limit = $value; 395 $text = $this->getLanguage()->formatNum( $limit ); 396 } else { 397 $limit = $key; 398 $text = $value; 399 } 400 $ret[$text] = $limit; 401 } 402 return $ret; 403 } 404 405 /** 406 * Get \<input type="hidden"\> elements for use in a method="get" form. 407 * Resubmits all defined elements of the query string, except for a 408 * blacklist, passed in the $blacklist parameter. 409 * 410 * @param array $blacklist Parameters from the request query which should not be resubmitted 411 * @return string HTML fragment 412 */ 413 public function getHiddenFields( $blacklist = [] ) { 414 $blacklist = (array)$blacklist; 415 $query = $this->getRequest()->getQueryValues(); 416 foreach ( $blacklist as $name ) { 417 unset( $query[$name] ); 418 } 419 $s = ''; 420 foreach ( $query as $name => $value ) { 421 $s .= Html::hidden( $name, $value ) . "\n"; 422 } 423 return $s; 424 } 425 426 /** 427 * Get a form containing a limit selection dropdown 428 * 429 * @return string HTML fragment 430 */ 431 public function getLimitForm() { 432 return Html::rawElement( 433 'form', 434 [ 435 'method' => 'get', 436 'action' => wfScript(), 437 ], 438 "\n" . $this->getLimitDropdown() 439 ) . "\n"; 440 } 441 442 /** 443 * Gets a limit selection dropdown 444 * 445 * @return string 446 */ 447 private function getLimitDropdown() { 448 # Make the select with some explanatory text 449 $msgSubmit = $this->msg( 'table_pager_limit_submit' )->escaped(); 450 451 return $this->msg( 'table_pager_limit' ) 452 ->rawParams( $this->getLimitSelect() )->escaped() . 453 "\n<input type=\"submit\" value=\"$msgSubmit\"/>\n" . 454 $this->getHiddenFields( [ 'limit' ] ); 455 } 456 457 /** 458 * Return true if the named field should be sortable by the UI, false 459 * otherwise 460 * 461 * @param string $field 462 */ 463 abstract protected function isFieldSortable( $field ); 464 465 /** 466 * Format a table cell. The return value should be HTML, but use an empty 467 * string not   for empty cells. Do not include the <td> and </td>. 468 * 469 * The current result row is available as $this->mCurrentRow, in case you 470 * need more context. 471 * 472 * @param string $name The database field name 473 * @param string $value The value retrieved from the database 474 */ 475 abstract public function formatValue( $name, $value ); 476 477 /** 478 * The database field name used as a default sort order. 479 * 480 * Note that this field will only be sorted on if isFieldSortable returns 481 * true for this field. If not (e.g. paginating on multiple columns), this 482 * should return empty string, and getIndexField should be overridden. 483 * 484 * @return string 485 */ 486 abstract public function getDefaultSort(); 487 488 /** 489 * An array mapping database field names to a textual description of the 490 * field name, for use in the table header. The description should be plain 491 * text, it will be HTML-escaped later. 492 * 493 * @return array 494 */ 495 abstract protected function getFieldNames(); 496} 497