Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 226
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
PendingChangesPager
0.00% covered (danger)
0.00%
0 / 226
0.00% covered (danger)
0.00%
0 / 19
2862
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 setLimit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 formatRow
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDefaultQuery
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
156
 getIndexField
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doBatchLookups
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultSort
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFieldNames
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 formatValue
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFieldSortable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStartBody
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 buildTableHeader
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 buildHeaderCaption
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getPendingCount
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 buildTableElement
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 buildTableHeaderCells
0.00% covered (danger)
0.00%
0 / 73
0.00% covered (danger)
0.00%
0 / 1
240
 getEndBody
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 buildTableCaption
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3use MediaWiki\Html\Html;
4use MediaWiki\MediaWikiServices;
5use MediaWiki\Pager\TablePager;
6use Wikimedia\Rdbms\RawSQLExpression;
7
8/**
9 * Query to list out outdated reviewed pages
10 */
11class PendingChangesPager extends TablePager {
12
13    private PendingChanges $mForm;
14    private ?string $category;
15    /** @var int|int[] */
16    private $namespace;
17    private ?string $size;
18    private bool $watched;
19    private bool $stable;
20    private ?string $tagFilter;
21    // Don't get too expensive
22    private const PAGE_LIMIT = 100;
23
24    /**
25     * The unique sort fields for the sort options for unique paginate
26     */
27    private const INDEX_FIELDS = [
28        'fp_pending_since' => [ 'fp_pending_since' ],
29    ];
30
31    /**
32     * @param PendingChanges $form
33     * @param int|null $namespace
34     * @param string $category
35     * @param int|null $size
36     * @param bool $watched
37     * @param bool $stable
38     * @param ?string $tagFilter
39     */
40    public function __construct( $form, $namespace, string $category = '',
41        ?int $size = null, bool $watched = false, bool $stable = false, ?string $tagFilter = ''
42    ) {
43        $this->mForm = $form;
44        # Must be a content page...
45        if ( $namespace !== null ) {
46            $namespace = (int)$namespace;
47        }
48        # Sanity check
49        if ( $namespace === null || !FlaggedRevs::isReviewNamespace( $namespace ) ) {
50            $namespace = FlaggedRevs::getReviewNamespaces();
51        }
52        $this->namespace = $namespace;
53        $this->category = $category ? str_replace( ' ', '_', $category ) : null;
54        $this->tagFilter = $tagFilter ? str_replace( ' ', '_', $tagFilter ) : null;
55        $this->size = $size;
56        $this->watched = $watched;
57        $this->stable = $stable && !FlaggedRevs::isStableShownByDefault()
58            && !FlaggedRevs::useOnlyIfProtected();
59
60        parent::__construct();
61        # Don't get too expensive
62        $this->mLimitsShown = [ 20, 50, 100 ];
63        $this->setLimit( $this->mLimit ); // apply max limit
64    }
65
66    /**
67     * @inheritDoc
68     */
69    public function setLimit( $limit ) {
70        $this->mLimit = min( $limit, self::PAGE_LIMIT );
71    }
72
73    /**
74     * @inheritDoc
75     */
76    public function formatRow( $row ): string {
77        return $this->mForm->formatRow( $row );
78    }
79
80    /**
81     * @inheritDoc
82     */
83    public function getDefaultQuery(): array {
84        $query = parent::getDefaultQuery();
85        $query['category'] = $this->category;
86        $query['tagFilter'] = $this->tagFilter;
87        return $query;
88    }
89
90    /**
91     * @inheritDoc
92     */
93    public function getQueryInfo(): array {
94        $tables = [ 'page', 'revision', 'flaggedpages' ];
95        $fields = [
96            'page_namespace',
97            'page_title',
98            'page_len',
99            'rev_len',
100            'page_latest',
101            'stable' => 'fp_stable',
102            'quality' => 'fp_quality',
103            'pending_since' => 'fp_pending_since'
104        ];
105        $conds = [ $this->mDb->expr( 'fp_pending_since', '!=', null ) ];
106        $joinConds = [
107            'page' => [ 'JOIN', 'page_id=fp_page_id' ],
108            'revision' => [ 'JOIN', 'rev_id=fp_stable' ],
109        ];
110
111        # Filter by pages configured to be stable
112        if ( $this->stable ) {
113            $tables[] = 'flaggedpage_config';
114            $joinConds['flaggedpage_config'] = [ 'JOIN', 'fpc_page_id=fp_page_id' ];
115            $conds['fpc_override'] = 1;
116        }
117        # Filter by category
118        if ( $this->category != '' ) {
119            $tables[] = 'categorylinks';
120            $tables[] = 'linktarget';
121            $joinConds['categorylinks'] = [ 'JOIN', 'cl_from=fp_page_id' ];
122            $joinConds['linktarget'] = [ 'JOIN', 'lt_id=cl_target_id' ];
123            $conds['lt_title'] = $this->category;
124            $conds['lt_namespace'] = NS_CATEGORY;
125        }
126        # Index field for sorting
127        $this->mIndexField = 'fp_pending_since';
128        $fields[] = $this->mIndexField; // Pager needs this
129        # Filter namespace
130        if ( $this->namespace !== null ) {
131            $conds['page_namespace'] = $this->namespace;
132        }
133        # Filter by watchlist
134        if ( $this->watched ) {
135            $uid = $this->getUser()->getId();
136            if ( $uid ) {
137                $tables[] = 'watchlist';
138                $conds['wl_user'] = $uid;
139                $joinConds['watchlist'] = [ 'JOIN', [
140                    'wl_namespace=page_namespace',
141                    'wl_title=page_title'
142                ] ];
143            }
144        }
145        # Filter by bytes changed
146        if ( $this->size !== null && $this->size >= 0 ) {
147            $conds[] = new RawSQLExpression(
148                "(GREATEST(page_len, rev_len) - LEAST(page_len, rev_len)) <= " . intval( $this->size )
149            );
150        }
151        # Filter by tag
152        if ( $this->tagFilter !== null && $this->tagFilter !== '' ) {
153            $tables[] = 'change_tag';
154            $tables[] = 'change_tag_def';
155            $joinConds['change_tag'] = [ 'JOIN', 'ct_rev_id=rev_id' ];
156            $joinConds['change_tag_def'] = [ 'JOIN', 'ct_tag_id=ctd_id' ];
157            $conds['ctd_name'] = $this->tagFilter;
158        }
159        # Don't display pages with expired protection (T350527)
160        if ( FlaggedRevs::useOnlyIfProtected() ) {
161            $tables[] = 'flaggedpage_config';
162            $joinConds['flaggedpage_config'] = [ 'JOIN', 'fpc_page_id=fp_page_id' ];
163            $conds[] = new RawSQLExpression( $this->mDb->buildComparison( '>',
164                    [ 'fpc_expiry' => $this->mDb->timestamp() ] ) . ' OR fpc_expiry = "infinity"'
165            );
166        }
167        # Set sorting options
168        $sortField = $this->getRequest()->getVal( 'sort', 'fp_pending_since' );
169        $sortOrder = $this->getRequest()->getVal( 'asc' ) ? 'ASC' : 'DESC';
170        $options = [ 'ORDER BY' => "$sortField $sortOrder" ];
171        # Return query information
172        return [
173            'tables' => $tables,
174            'fields' => $fields,
175            'conds' => $conds,
176            'options' => $options,
177            'join_conds' => $joinConds,
178        ];
179    }
180
181    /**
182     * @inheritDoc
183     */
184    public function getIndexField() {
185        return $this->mIndexField;
186    }
187
188    /**
189     * @inheritDoc
190     */
191    protected function doBatchLookups() {
192        $this->mResult->seek( 0 );
193        $lb = MediaWikiServices::getInstance()->getLinkBatchFactory();
194        $batch = $lb->newLinkBatch();
195        foreach ( $this->mResult as $row ) {
196            $batch->add( $row->page_namespace, $row->page_title );
197        }
198        $batch->execute();
199    }
200
201    /**
202     * @inheritDoc
203     * @since 1.43
204     */
205    public function getDefaultSort(): string {
206        return 'fp_pending_since';
207    }
208
209    /**
210     * @inheritDoc
211     * @since 1.43
212     */
213    protected function getFieldNames(): array {
214        $fields = [
215            'page_title' => 'pendingchanges-table-page',
216            'review' => 'pendingchanges-table-review',
217            'rev_len' => 'pendingchanges-table-size',
218            'fp_pending_since' => 'pendingchanges-table-pending-since',
219        ];
220
221        if ( $this->getAuthority()->isAllowed( 'unwatchedpages' ) ) {
222            $fields['watching'] = 'pendingchanges-table-watching';
223        }
224
225        return $fields;
226    }
227
228    /**
229     * @inheritDoc
230     * @since 1.43
231     */
232    public function formatValue( $name, $value ): ?string {
233        return htmlspecialchars( $value );
234    }
235
236    /**
237     * @inheritDoc
238     * @since 1.43
239     */
240    protected function isFieldSortable( $field ): bool {
241        return isset( self::INDEX_FIELDS[$field] );
242    }
243
244    /**
245     * Builds and returns the start body HTML for the table.
246     *
247     * @return string HTML
248     * @since 1.43
249     */
250    protected function getStartBody(): string {
251        return Html::openElement( 'div', [ 'class' => 'cdx-table mw-fr-pending-changes-table' ] ) .
252            $this->buildTableHeader() .
253            Html::openElement( 'div', [ 'class' => 'cdx-table__table-wrapper' ] ) .
254            $this->buildTableElement();
255    }
256
257    /**
258     * Builds and returns the table header HTML.
259     *
260     * @return string HTML
261     */
262    private function buildTableHeader(): string {
263        $headerCaption = $this->buildHeaderCaption();
264        $headerContent = $this->buildTableCaption( 'cdx-table__header__header-content' );
265
266        return Html::rawElement(
267            'div',
268            [ 'class' => 'cdx-table__header' ],
269            $headerCaption . $headerContent
270        );
271    }
272
273    /**
274     * Builds and returns the header caption HTML.
275     *
276     * @return string HTML
277     */
278    private function buildHeaderCaption(): string {
279        return Html::rawElement(
280            'div',
281            [ 'class' => 'cdx-table__header__caption', 'aria-hidden' => 'true' ],
282            $this->msg( 'pendingchanges-table-caption' )->escaped()
283        );
284    }
285
286    /**
287     * Retrieves the count of pending pages.
288     *
289     * @return int The count of pending pages.
290     */
291    private function getPendingCount(): int {
292        return $this->mDb->newSelectQueryBuilder()
293            ->select( '*' )
294            ->from( 'flaggedpages' )
295            ->join( 'page', null, 'fp_page_id = page_id' )
296            ->where( $this->mDb->expr( 'fp_pending_since', '!=', null ) )
297            ->andWhere( [ 'page_namespace' => FlaggedRevs::getReviewNamespaces() ] )
298            ->caller( __METHOD__ )
299            ->fetchRowCount();
300    }
301
302    /**
303     * Builds and returns the table element HTML.
304     *
305     * @return string HTML
306     */
307    private function buildTableElement(): string {
308        $caption = Html::element( 'caption', [], $this->msg( 'pendingchanges-table-caption' )->text() );
309        $thead = $this->buildTableHeaderCells();
310
311        return Html::openElement( 'table', [ 'class' => 'cdx-table__table cdx-table__table--borders-vertical' ] ) .
312            $caption .
313            $thead .
314            Html::openElement( 'tbody' );
315    }
316
317    /**
318     * Builds and returns the table header cells HTML.
319     *
320     * @return string HTML
321     */
322    private function buildTableHeaderCells(): string {
323        $fields = $this->getFieldNames();
324        $headerCells = '';
325
326        foreach ( $fields as $field => $labelKey ) {
327            $class = ( $field === 'review' || $field === 'history' ) ? 'cdx-table__table__cell--align-center' : '';
328
329            if ( $field === 'history' ) {
330                $headerCells .= Html::rawElement(
331                    'th',
332                    [ 'scope' => 'col', 'class' => $class ],
333                    Html::rawElement(
334                        'span',
335                        [ 'class' => 'fr-cdx-icon-clock', 'aria-hidden' => 'true' ]
336                    )
337                );
338            } elseif ( $this->isFieldSortable( $field ) ) {
339                $isCurrentSortField = ( $this->mSort === $field );
340                $currentAsc = $this->getRequest()->getVal( 'asc', '1' );
341
342                $newSortAsc = $isCurrentSortField && $currentAsc === '1' ? '' : '1';
343                $newSortDesc = $isCurrentSortField && $currentAsc === '1' ? '1' : '';
344
345                $ariaSort = 'none';
346                if ( $isCurrentSortField ) {
347                    $ariaSort = $currentAsc === '1' ? 'ascending' : 'descending';
348                }
349
350                $iconClass = 'fr-cdx-icon-sort-vertical';
351                if ( $isCurrentSortField ) {
352                    $iconClass = $currentAsc === '1' ? 'fr-icon-asc' : 'fr-icon-desc';
353                }
354
355                $currentParams = $this->getRequest()->getValues();
356                unset( $currentParams['title'], $currentParams['sort'], $currentParams['asc'], $currentParams['desc'] );
357                $currentParams['sort'] = $field;
358                $currentParams['asc'] = $newSortAsc;
359                $currentParams['desc'] = $newSortDesc;
360
361                $href = $this->getTitle()->getLocalURL( $currentParams );
362
363                $headerCells .= Html::rawElement(
364                    'th',
365                    [
366                        'scope' => 'col',
367                        'class' => 'cdx-table__table__cell--has-sort ' . $class,
368                        'aria-sort' => $ariaSort,
369                    ],
370                    Html::rawElement(
371                        'a',
372                        [ 'href' => $href ],
373                        Html::rawElement(
374                            'button',
375                            [
376                                'class' => 'cdx-table__table__sort-button',
377                                'aria-selected' => $isCurrentSortField ? 'true' : 'false'
378                            ],
379                            $this->msg( $labelKey )->escaped() . ' ' .
380                            Html::rawElement(
381                                'span',
382                                [ 'class' => 'cdx-icon cdx-icon--small cdx-table__table__sort-icon ' .
383                                    $iconClass, 'aria-hidden' => 'true' ]
384                            )
385                        )
386                    )
387                );
388            } else {
389                $headerCells .= Html::rawElement(
390                    'th',
391                    [ 'scope' => 'col', 'class' => $class ],
392                    Html::rawElement(
393                        'span',
394                        [ 'class' => 'cdx-table__th-content' ],
395                        $this->msg( $labelKey )->escaped()
396                    )
397                );
398            }
399        }
400
401        return Html::rawElement(
402            'thead',
403            [],
404            Html::rawElement(
405                'tr',
406                [],
407                $headerCells
408            )
409        );
410    }
411
412    /**
413     * Builds and returns the end body HTML for the table.
414     *
415     * @return string HTML
416     * @since 1.43
417     */
418    protected function getEndBody(): string {
419        return Html::closeElement( 'tbody' ) .
420            Html::closeElement( 'table' ) .
421            Html::closeElement( 'div' ) .
422            $this->buildTableCaption( 'cdx-table__footer' ) .
423            Html::closeElement( 'div' );
424    }
425
426    /**
427     * Builds and returns the table caption, currently used both in
428     * the header and the footer.
429     *
430     * @param string $class The class to use for the returning element
431     * @return string HTML
432     */
433    private function buildTableCaption( string $class ): string {
434        $pendingCount = $this->getPendingCount();
435        $formattedCount = $this->getLanguage()->formatNum( $pendingCount );
436        $chip = Html::element( 'strong', [ 'class' => 'cdx-info-chip' ], $formattedCount );
437        $message = $this->msg( 'pendingchanges-table-footer' )
438            ->rawParams( $chip )
439            ->numParams( $pendingCount )
440            ->escaped();
441
442        return Html::rawElement(
443            'div',
444            [ 'class' => $class ],
445            Html::rawElement( 'span', [], $message )
446        );
447    }
448}