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 &#160; 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