Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 222
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 / 222
0.00% covered (danger)
0.00%
0 / 19
2970
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 / 60
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 / 3
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 / 82
0.00% covered (danger)
0.00%
0 / 1
272
 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 / 10
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 = [
106            'page_id = fp_page_id',
107            'rev_id = fp_stable',
108            $this->mDb->expr( 'fp_pending_since', '!=', null )
109        ];
110
111        # Filter by pages configured to be stable
112        if ( $this->stable ) {
113            $tables[] = 'flaggedpage_config';
114            $conds[] = 'fp_page_id = fpc_page_id';
115            $conds['fpc_override'] = 1;
116        }
117        # Filter by category
118        if ( $this->category != '' ) {
119            $tables[] = 'categorylinks';
120            $conds[] = 'cl_from = fp_page_id';
121            $conds['cl_to'] = $this->category;
122        }
123        # Index field for sorting
124        $this->mIndexField = 'fp_pending_since';
125        $fields[] = $this->mIndexField; // Pager needs this
126        # Filter namespace
127        if ( $this->namespace !== null ) {
128            $conds['page_namespace'] = $this->namespace;
129        }
130        # Filter by watchlist
131        if ( $this->watched ) {
132            $uid = $this->getUser()->getId();
133            if ( $uid ) {
134                $tables[] = 'watchlist';
135                $conds['wl_user'] = $uid;
136                $conds[] = 'page_namespace = wl_namespace';
137                $conds[] = 'page_title = wl_title';
138            }
139        }
140        # Filter by bytes changed
141        if ( $this->size !== null && $this->size >= 0 ) {
142            $conds[] = new RawSQLExpression(
143                "(GREATEST(page_len, rev_len) - LEAST(page_len, rev_len)) <= " . intval( $this->size )
144            );
145        }
146        # Filter by tag
147        if ( $this->tagFilter !== null && $this->tagFilter !== '' ) {
148            $tables[] = 'change_tag';
149            $tables[] = 'change_tag_def';
150            $conds[] = 'ct_tag_id = ctd_id';
151            $conds[] = 'ct_rev_id = rev_id';
152            $conds['ctd_name'] = $this->tagFilter;
153        }
154        # Don't display pages with expired protection (T350527)
155        if ( FlaggedRevs::useOnlyIfProtected() ) {
156            $tables[] = 'flaggedpage_config';
157            $conds[] = 'fpc_page_id = fp_page_id';
158            $conds[] = new RawSQLExpression( $this->mDb->buildComparison( '>',
159                    [ 'fpc_expiry' => $this->mDb->timestamp() ] ) . ' OR fpc_expiry = "infinity"'
160            );
161        }
162        # Set sorting options
163        $sortField = $this->getRequest()->getVal( 'sort', 'fp_pending_since' );
164        $sortOrder = $this->getRequest()->getVal( 'asc' ) ? 'ASC' : 'DESC';
165        $options = [ 'ORDER BY' => "$sortField $sortOrder" ];
166        # Return query information
167        return [
168            'tables' => $tables,
169            'fields' => $fields,
170            'conds' => $conds,
171            'options' => $options,
172        ];
173    }
174
175    /**
176     * @inheritDoc
177     */
178    public function getIndexField() {
179        return $this->mIndexField;
180    }
181
182    /**
183     * @inheritDoc
184     */
185    protected function doBatchLookups() {
186        $this->mResult->seek( 0 );
187        $lb = MediaWikiServices::getInstance()->getLinkBatchFactory();
188        $batch = $lb->newLinkBatch();
189        foreach ( $this->mResult as $row ) {
190            $batch->add( $row->page_namespace, $row->page_title );
191        }
192        $batch->execute();
193    }
194
195    /**
196     * @inheritDoc
197     * @since 1.43
198     */
199    public function getDefaultSort(): string {
200        return 'fp_pending_since';
201    }
202
203    /**
204     * @inheritDoc
205     * @since 1.43
206     */
207    protected function getFieldNames(): array {
208        $fields = [
209            'page_title' => 'pendingchanges-table-page',
210            'review' => 'pendingchanges-table-review',
211            'rev_len' => 'pendingchanges-table-size',
212            'fp_pending_since' => 'pendingchanges-table-pending-since',
213        ];
214
215        if ( $this->getAuthority()->isAllowed( 'unwatchedpages' ) ) {
216            $fields['watching'] = 'pendingchanges-table-watching';
217        }
218
219        return $fields;
220    }
221
222    /**
223     * @inheritDoc
224     * @since 1.43
225     */
226    public function formatValue( $name, $value ): ?string {
227        return htmlspecialchars( $value );
228    }
229
230    /**
231     * @inheritDoc
232     * @since 1.43
233     */
234    protected function isFieldSortable( $field ): bool {
235        return isset( self::INDEX_FIELDS[$field] );
236    }
237
238    /**
239     * Builds and returns the start body HTML for the table.
240     *
241     * @return string HTML
242     * @since 1.43
243     */
244    protected function getStartBody(): string {
245        return Html::openElement( 'div', [ 'class' => 'cdx-table mw-fr-pending-changes-table' ] ) .
246            $this->buildTableHeader() .
247            Html::openElement( 'div', [ 'class' => 'cdx-table__table-wrapper' ] ) .
248            $this->buildTableElement();
249    }
250
251    /**
252     * Builds and returns the table header HTML.
253     *
254     * @return string HTML
255     */
256    private function buildTableHeader(): string {
257        $headerCaption = $this->buildHeaderCaption();
258        $headerContent = $this->buildTableCaption( 'cdx-table__header__header-content' );
259
260        return Html::rawElement(
261            'div',
262            [ 'class' => 'cdx-table__header' ],
263            $headerCaption . $headerContent
264        );
265    }
266
267    /**
268     * Builds and returns the header caption HTML.
269     *
270     * @return string HTML
271     */
272    private function buildHeaderCaption(): string {
273        return Html::rawElement(
274            'div',
275            [ 'class' => 'cdx-table__header__caption', 'aria-hidden' => 'true' ],
276            $this->msg( 'pendingchanges-table-caption' )->text()
277        );
278    }
279
280    /**
281     * Retrieves the count of pending pages.
282     *
283     * @return int The count of pending pages.
284     */
285    private function getPendingCount(): int {
286        return $this->mDb->selectRowCount(
287            'flaggedpages', '*', [ $this->mDb->expr( 'fp_pending_since', '!=', null ) ], __METHOD__
288        );
289    }
290
291    /**
292     * Builds and returns the table element HTML.
293     *
294     * @return string HTML
295     */
296    private function buildTableElement(): string {
297        $caption = Html::element( 'caption', [], $this->msg( 'pendingchanges-table-caption' )->text() );
298        $thead = $this->buildTableHeaderCells();
299
300        return Html::openElement( 'table', [ 'class' => 'cdx-table__table cdx-table__table--borders-vertical' ] ) .
301            $caption .
302            $thead .
303            Html::openElement( 'tbody' );
304    }
305
306    /**
307     * Builds and returns the table header cells HTML.
308     *
309     * @return string HTML
310     */
311    private function buildTableHeaderCells(): string {
312        $fields = $this->getFieldNames();
313        $headerCells = '';
314
315        foreach ( $fields as $field => $labelKey ) {
316            $class = ( $field === 'review' || $field === 'history' ) ? 'cdx-table__table__cell--align-center' : '';
317
318            if ( $field === 'review' ) {
319                $headerCells .= Html::rawElement(
320                    'th',
321                    [ 'scope' => 'col', 'class' => $class ],
322                    Html::rawElement(
323                        'span',
324                        [ 'class' => 'fr-cdx-icon-eye', 'aria-hidden' => 'true' ]
325                    )
326                );
327            } elseif ( $field === 'history' ) {
328                $headerCells .= Html::rawElement(
329                    'th',
330                    [ 'scope' => 'col', 'class' => $class ],
331                    Html::rawElement(
332                        'span',
333                        [ 'class' => 'fr-cdx-icon-clock', 'aria-hidden' => 'true' ]
334                    )
335                );
336            } elseif ( $this->isFieldSortable( $field ) ) {
337                $isCurrentSortField = ( $this->mSort === $field );
338                $currentAsc = $this->getRequest()->getVal( 'asc', '1' );
339
340                $newSortAsc = $isCurrentSortField && $currentAsc === '1' ? '' : '1';
341                $newSortDesc = $isCurrentSortField && $currentAsc === '1' ? '1' : '';
342
343                $ariaSort = 'none';
344                if ( $isCurrentSortField ) {
345                    $ariaSort = $currentAsc === '1' ? 'ascending' : 'descending';
346                }
347
348                $iconClass = 'fr-cdx-icon-sort-vertical';
349                if ( $isCurrentSortField ) {
350                    $iconClass = $currentAsc === '1' ? 'fr-icon-asc' : 'fr-icon-desc';
351                }
352
353                $currentParams = $this->getRequest()->getValues();
354                unset( $currentParams['title'], $currentParams['sort'], $currentParams['asc'], $currentParams['desc'] );
355                $currentParams['sort'] = $field;
356                $currentParams['asc'] = $newSortAsc;
357                $currentParams['desc'] = $newSortDesc;
358
359                $href = $this->getTitle()->getLocalURL( $currentParams );
360
361                $headerCells .= Html::rawElement(
362                    'th',
363                    [
364                        'scope' => 'col',
365                        'class' => 'cdx-table__table__cell--has-sort ' . $class,
366                        'aria-sort' => $ariaSort,
367                    ],
368                    Html::rawElement(
369                        'a',
370                        [ 'href' => $href ],
371                        Html::rawElement(
372                            'button',
373                            [
374                                'class' => 'cdx-table__table__sort-button',
375                                'aria-selected' => $isCurrentSortField ? 'true' : 'false'
376                            ],
377                            $this->msg( $labelKey )->text() . ' ' .
378                            Html::rawElement(
379                                'span',
380                                [ 'class' => 'cdx-icon cdx-icon--small cdx-table__table__sort-icon ' .
381                                    $iconClass, 'aria-hidden' => 'true' ]
382                            )
383                        )
384                    )
385                );
386            } else {
387                $headerCells .= Html::rawElement(
388                    'th',
389                    [ 'scope' => 'col', 'class' => $class ],
390                    Html::rawElement(
391                        'span',
392                        [ 'class' => 'cdx-table__th-content' ],
393                        $this->msg( $labelKey )->text()
394                    )
395                );
396            }
397        }
398
399        return Html::rawElement(
400            'thead',
401            [],
402            Html::rawElement(
403                'tr',
404                [],
405                $headerCells
406            )
407        );
408    }
409
410    /**
411     * Builds and returns the end body HTML for the table.
412     *
413     * @return string HTML
414     * @since 1.43
415     */
416    protected function getEndBody(): string {
417        return Html::closeElement( 'tbody' ) .
418            Html::closeElement( 'table' ) .
419            Html::closeElement( 'div' ) .
420            $this->buildTableCaption( 'cdx-table__footer' ) .
421            Html::closeElement( 'div' );
422    }
423
424    /**
425     * Builds and returns the table caption, currently used both in
426     * the header and the footer.
427     *
428     * @param string $class The class to use for the returning element
429     * @return string HTML
430     */
431    private function buildTableCaption( string $class ): string {
432        $pendingCount = $this->getPendingCount();
433        $formattedCount = $this->getLanguage()->formatNum( $pendingCount );
434        $chip = Html::element( 'strong', [ 'class' => 'cdx-info-chip' ], $formattedCount );
435        $message = $this->msg( 'pendingchanges-table-footer', $chip )
436            ->numParams( $pendingCount )->text();
437
438        return Html::rawElement(
439            'div',
440            [ 'class' => $class ],
441            Html::rawElement( 'span', [], $message )
442        );
443    }
444}