Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.12% covered (success)
94.12%
224 / 238
50.00% covered (danger)
50.00%
6 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialWatchlistLabels
94.12% covered (success)
94.12%
224 / 238
50.00% covered (danger)
50.00%
6 / 12
41.34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
8.30
 showEditForm
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
3
 filterName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validateName
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 onEditFormSubmit
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 showDeleteConfirmation
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 getDeleteConfirmationListItem
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
3.47
 onDeleteFormSubmit
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 showTable
98.94% covered (success)
98.94%
93 / 94
0.00% covered (danger)
0.00%
0 / 1
8
 getCheckbox
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-3.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use InvalidArgumentException;
10use MediaWiki\Exception\UserNotLoggedIn;
11use MediaWiki\Html\Html;
12use MediaWiki\HTMLForm\HTMLForm;
13use MediaWiki\MainConfigNames;
14use MediaWiki\Message\Message;
15use MediaWiki\SpecialPage\SpecialPage;
16use MediaWiki\Status\Status;
17use MediaWiki\Watchlist\WatchlistLabel;
18use MediaWiki\Watchlist\WatchlistLabelStore;
19use MediaWiki\Watchlist\WatchlistSpecialPage;
20use StatusValue;
21use Wikimedia\Codex\Builder\TableBuilder;
22use Wikimedia\Codex\Utility\Codex;
23
24/**
25 * A special page for viewing a user's watchlist labels and performing CRUD operations on them.
26 *
27 * @ingroup SpecialPage
28 * @ingroup Watchlist
29 */
30class SpecialWatchlistLabels extends SpecialPage {
31
32    private const SUBPAGE_EDIT = 'edit';
33    private const SUBPAGE_DELETE = 'delete';
34    private const PARAM_ID = 'wll_id';
35    private const PARAM_IDS = 'wll_ids';
36    private const PARAM_NAME = 'wll_name';
37
38    use WatchlistSpecialPage;
39
40    private ?WatchlistLabel $watchlistLabel = null;
41
42    /** @inheritDoc */
43    public function __construct(
44        private WatchlistLabelStore $labelStore,
45        $name = 'WatchlistLabels',
46        $restriction = 'viewmywatchlist',
47    ) {
48        parent::__construct( $name, $restriction, false );
49    }
50
51    /** @inheritDoc */
52    public function doesWrites() {
53        return true;
54    }
55
56    /** @inheritDoc */
57    public function execute( $subPage ) {
58        $user = $this->getUser();
59        $right = $subPage === self::SUBPAGE_EDIT ? 'editmywatchlist' : 'viewmywatchlist';
60        if ( !$user->isRegistered()
61            || ( $user->isTemp() && !$user->isAllowed( $right ) )
62        ) {
63            // The message used here will be one of:
64            // * watchlistlabels-not-logged-in
65            // * watchlistlabels-not-logged-in-for-temp-user
66            throw new UserNotLoggedIn( 'watchlistlabels-not-logged-in' );
67        }
68        $this->checkPermissions();
69
70        $output = $this->getOutput();
71        $output->setPageTitleMsg( $this->msg( 'watchlistlabels-title' ) );
72        $this->addHelpLink( 'Help:Watchlist labels' );
73        if ( !$this->getConfig()->get( MainConfigNames::EnableWatchlistLabels ) ) {
74            $output->addHTML( Html::errorBox( $this->msg( 'watchlistlabels-not-enabled' )->escaped() ) );
75            return;
76        }
77
78        $this->outputSubtitle();
79
80        if ( $subPage === self::SUBPAGE_EDIT ) {
81            $this->showEditForm();
82        } elseif ( $subPage === self::SUBPAGE_DELETE ) {
83            $this->showDeleteConfirmation();
84        } else {
85            $this->showTable();
86        }
87    }
88
89    /**
90     * Get the label editing form.
91     */
92    private function showEditForm() {
93        $id = $this->getRequest()->getInt( self::PARAM_ID );
94        $descriptor = [
95            self::PARAM_NAME => [
96                'type' => 'text',
97                'name' => self::PARAM_NAME,
98                'label-message' => 'watchlistlabels-form-field-name',
99                'validation-callback' => [ $this, 'validateName' ],
100                'filter-callback' => [ $this, 'filterName' ],
101                'required' => true,
102            ],
103        ];
104        $msgSuffix = 'new';
105        if ( $id ) {
106            $this->watchlistLabel = $this->labelStore->loadById( $this->getUser(), $id );
107            if ( $this->watchlistLabel ) {
108                $descriptor[self::PARAM_NAME]['default'] = $this->watchlistLabel->getName();
109                $descriptor[self::PARAM_ID] = [
110                    'type' => 'hidden',
111                    'name' => self::PARAM_ID,
112                    'default' => $this->watchlistLabel->getId(),
113                ];
114                $msgSuffix = 'edit';
115            }
116        }
117        $form = HTMLForm::factory( 'codex', $descriptor, $this->getContext() )
118            // Messages used here:
119            // - watchlistlabels-form-header-new
120            // - watchlistlabels-form-header-edit
121            ->setHeaderHtml( Html::element( 'h3', [], $this->msg( "watchlistlabels-form-header-$msgSuffix" )->text() ) )
122            ->showCancel( true )
123            ->setCancelTarget( $this->getPageTitle() )
124            // Messages used here:
125            // - watchlistlabels-form-submit-new
126            // - watchlistlabels-form-submit-edit
127            ->setSubmitTextMsg( "watchlistlabels-form-submit-$msgSuffix" )
128            ->setSubmitCallback( [ $this, 'onEditFormSubmit' ] );
129        $form->show();
130    }
131
132    /**
133     * Filter the 'name' field value.
134     *
135     * @param ?mixed $value
136     * @param ?array $alldata
137     * @param ?HTMLForm $form
138     *
139     * @return (StatusValue|string|bool|Message)|null
140     */
141    public function filterName( $value, ?array $alldata, ?HTMLForm $form ) {
142        $label = new WatchlistLabel( $this->getUser(), $value ?? '' );
143        return $label->getName();
144    }
145
146    /**
147     * @param mixed $value
148     * @param ?array $alldata
149     * @param ?HTMLForm $form
150     *
151     * @return (StatusValue|string|bool|Message)|null
152     */
153    public function validateName( $value, ?array $alldata, ?HTMLForm $form ) {
154        $length = strlen( trim( $value ) );
155        if ( $length === 0 ) {
156            return Status::newFatal( $this->msg( 'watchlistlabels-form-name-too-short', $length ) );
157        }
158        if ( $length > 255 ) {
159            return Status::newFatal( $this->msg( 'watchlistlabels-form-name-too-long', $length ) );
160        }
161        $existingLabel = $this->labelStore->loadByName( $this->getUser(), $value );
162        $thisId = $alldata[self::PARAM_ID] ?? null;
163        if ( $existingLabel && $thisId && $existingLabel->getId() !== (int)$thisId ) {
164            return Status::newFatal( $this->msg( 'watchlistlabels-form-name-exists', $existingLabel->getName() ) );
165        }
166        return Status::newGood();
167    }
168
169    /**
170     * Handle the form submission, for saving new or existing labels.
171     *
172     * @param mixed $data Form submission data.
173     * @return Status
174     */
175    public function onEditFormSubmit( $data ): Status {
176        if ( !isset( $data[self::PARAM_NAME] ) ) {
177            throw new InvalidArgumentException( 'No name data submitted.' );
178        }
179        if ( !$this->watchlistLabel ) {
180            $this->watchlistLabel = new WatchlistLabel( $this->getUser(), $data[self::PARAM_NAME] );
181        } else {
182            $this->watchlistLabel->setName( $data[self::PARAM_NAME] );
183        }
184        $saved = $this->labelStore->save( $this->watchlistLabel );
185        if ( $saved->isOK() ) {
186            $this->getOutput()->redirect( $this->getPageTitle()->getLocalURL() );
187        }
188        return $saved;
189    }
190
191    private function showDeleteConfirmation(): void {
192        $ids = $this->getRequest()->getArray( self::PARAM_IDS ) ?? [];
193        $labels = $this->labelStore->loadAllForUser( $this->getUser() );
194        $toDelete = array_intersect_key( $labels, array_flip( $ids ) );
195        $labelCounts = $this->labelStore->countItems( array_keys( $labels ) );
196        $listItems = '';
197        foreach ( $toDelete as $label ) {
198            $listItems .= Html::rawElement( 'li', [], $this->getDeleteConfirmationListItem( $label, $labelCounts ) );
199        }
200        $count = count( $toDelete );
201        $formattedCount = $this->getLanguage()->formatNum( $count );
202        $msg = $this->msg( 'watchlistlabels-delete-warning', $count, $formattedCount )->text();
203        $warning = Html::element( 'p', [], $msg );
204        $list = Html::rawElement( 'ol', [], $listItems );
205        $descriptor = [
206            'list' => [
207                'type' => 'info',
208                'default' => $warning . $list,
209                'rawrow' => true,
210            ],
211            self::PARAM_IDS => [
212                'type' => 'hidden',
213                'name' => self::PARAM_IDS,
214                'default' => implode( ',', array_keys( $toDelete ) ),
215            ],
216        ];
217        $header = $this->msg( 'watchlistlabels-delete-header', $count )->text();
218        HTMLForm::factory( 'codex', $descriptor, $this->getContext() )
219            ->setHeaderHtml( Html::element( 'h3', [], $header ) )
220            ->showCancel( true )
221            ->setCancelTarget( $this->getPageTitle() )
222            ->setSubmitTextMsg( 'delete' )
223            ->setSubmitDestructive()
224            ->setSubmitCallback( [ $this, 'onDeleteFormSubmit' ] )
225            ->show();
226    }
227
228    private function getDeleteConfirmationListItem( WatchlistLabel $label, array $labelCounts ): string {
229        $id = $label->getId();
230        if ( !$id ) {
231            return '';
232        }
233        $itemCount = $labelCounts[ $id ];
234        if ( $labelCounts[ $id ] > 0 ) {
235            $labelCountMsg = $this->msg(
236                'watchlistlabels-delete-count',
237                $this->getLanguage()->formatNum( $itemCount ),
238                $itemCount
239            )->escaped();
240        } else {
241            $labelCountMsg = $this->msg( 'watchlistlabels-delete-unused' )->escaped();
242        }
243        return Html::element( 'span', [], $label->getName() )
244            . $this->msg( 'word-separator' )->escaped()
245            . $this->msg( 'parentheses-start' )->escaped()
246            . Html::rawElement( 'em', [], $labelCountMsg )
247            . $this->msg( 'parentheses-end' )->escaped();
248    }
249
250    /**
251     * Handle the delete confirmation form submission.
252     *
253     * @param mixed $data Form submission data.
254     * @return Status
255     */
256    public function onDeleteFormSubmit( $data ) {
257        if ( !isset( $data[self::PARAM_IDS] ) ) {
258            throw new InvalidArgumentException( 'No name data submitted.' );
259        }
260        $ids = array_map( 'intval', array_filter( explode( ',', $data[self::PARAM_IDS] ) ) );
261        if ( $this->labelStore->delete( $this->getUser(), $ids ) ) {
262            $this->getOutput()->redirect( $this->getPageTitle()->getLocalURL() );
263            return Status::newGood();
264        }
265        return Status::newFatal( 'watchlistlabels-delete-failed' );
266    }
267
268    /**
269     * Show the table of all labels.
270     */
271    private function showTable() {
272        $codex = new Codex();
273        $this->getOutput()->addModules( 'mediawiki.special.watchlistlabels' );
274        $this->getOutput()->addModuleStyles( 'mediawiki.special.watchlistlabels.styles' );
275
276        // Page title and description.
277        $this->getOutput()->addHTML(
278            Html::element( 'h3', [], $this->msg( 'watchlistlabels-table-title' )->text() )
279            . Html::element( 'p', [], $this->msg( 'watchlistlabels-table-description' )->text() ),
280        );
281
282        // Buttons in the table header.
283        $params = [
284            'href' => $this->getPageTitle( self::SUBPAGE_EDIT )->getLinkURL(),
285            // @todo Remove Codex classes when T406372 is resolved.
286            'class' => 'cdx-button cdx-button--fake-button cdx-button--fake-button--enabled'
287                . ' cdx-button--action-progressive cdx-button--weight-primary'
288        ];
289        $createButton = Html::element( 'a', $params, $this->msg( 'watchlistlabels-table-new-link' )->text() );
290        $deleteButton = $codex->button()
291            ->setAttributes( [ 'class' => 'mw-specialwatchlistlabels-delete-button' ] )
292            ->setLabel( $this->msg( 'watchlistlabels-table-delete-link' )->text() )
293            ->setIconClass( 'mw-specialwatchlistlabels-icon--trash' )
294            ->setType( 'submit' )
295            ->setAction( 'destructive' )
296            ->build()
297            ->getHtml();
298
299        // Data.
300        $data = [];
301        $labels = $this->labelStore->loadAllForUser( $this->getUser() );
302        $labelCounts = $this->labelStore->countItems( array_keys( $labels ) );
303        $editIcon = Html::element( 'span', [ 'class' => 'cdx-button__icon mw-specialwatchlistlabels-icon--edit' ] );
304        foreach ( $labels as $label ) {
305            $id = $label->getId();
306            if ( !$id ) {
307                continue;
308            }
309            $url = $this->getPageTitle( self::SUBPAGE_EDIT )->getLocalURL( [ self::PARAM_ID => $id ] );
310            $params = [
311                'href' => $url,
312                'role' => 'button',
313                'class' => 'cdx-button cdx-button--fake-button cdx-button--fake-button--enabled'
314                    . ' cdx-button--weight-quiet cdx-button--icon-only cdx-button--size-small',
315                'title' => $this->msg( 'watchlistlabels-table-edit' )->text(),
316            ];
317            $checkboxId = self::PARAM_IDS . '_' . $id;
318            $labelVal = Html::element( 'bdi', [], $label->getName() );
319            // The sortable columns must have matching '*-sort' elements containing unformatted data for sorting.
320            $data[] = [
321                'select' => $this->getCheckbox( $checkboxId, (string)$id ),
322                'name' => Html::rawElement( 'label', [ 'for' => $checkboxId ], $labelVal ),
323                'name-sort' => mb_strtolower( $label->getName() ),
324                'count' => $this->getLanguage()->formatNum( $labelCounts[ $id ] ),
325                'count-sort' => $labelCounts[ $id ],
326                'edit' => Html::rawElement( 'a', $params, $editIcon ),
327            ];
328        }
329
330        // Sort by count by default, and others as requested.
331        // We sort here rather than in the DB because we're combining multiple queries' data,
332        // and there's only ever one page of results to show (up to 100).
333        $sortCol = $this->getRequest()->getText( 'sort', 'count' );
334        $sortDir = $this->getRequest()->getBool( 'asc' ) ? TableBuilder::SORT_ASCENDING : TableBuilder::SORT_DESCENDING;
335        $sortColName = $sortCol . '-sort';
336        usort(
337            $data,
338            static function ( $a, $b ) use ( $sortDir, $sortColName ) {
339                if ( !isset( $a[$sortColName] )
340                    || !isset( $b[$sortColName] )
341                    || $a[$sortColName] === $b[$sortColName]
342                ) {
343                    return 0;
344                }
345                return $sortDir === TableBuilder::SORT_ASCENDING
346                    ? $a[$sortColName] <=> $b[$sortColName]
347                    : $b[$sortColName] <=> $a[$sortColName];
348            }
349        );
350
351        // Put it all together in the table.
352        $table = $codex->table()
353            ->setCurrentSortColumn( $sortCol )
354            ->setCurrentSortDirection( $sortDir )
355            ->setAttributes( [ 'class' => 'mw-specialwatchlistlabels-table' ] )
356            ->setCaption( $this->msg( 'watchlistlabels-table-header' )->text() )
357            ->setHeaderContent( "$createButton $deleteButton" )
358            ->setColumns( [
359                [
360                    'id' => 'select',
361                    'label' => '',
362                ],
363                [
364                    'id' => 'name',
365                    'label' => $this->msg( 'watchlistlabels-table-col-name' )->escaped(),
366                    'sortable' => true,
367                ],
368                [
369                    'id' => 'count',
370                    'label' => $this->msg( 'watchlistlabels-table-col-count' )->escaped(),
371                    'sortable' => true,
372                ],
373                [
374                    'id' => 'edit',
375                    'label' => $this->msg( 'watchlistlabels-table-col-actions' )->escaped(),
376                ],
377            ] )
378            ->setData( $data )
379            ->setPaginate( false )
380            ->build();
381        $deleteUrl = $this->getPageTitle( self::SUBPAGE_DELETE )->getLocalURL();
382        $form = Html::rawElement( 'form', [ 'action' => $deleteUrl ], $table->getHtml() );
383        $this->getOutput()->addHTML( $form );
384    }
385
386    /**
387     * Get a Codex-structured HTML checkbox.
388     *
389     * @param string $id
390     * @param string $value
391     *
392     * @return string HTML of the checkbox wrapper.
393     */
394    private function getCheckbox( string $id, string $value ): string {
395        $checkbox = Html::check(
396            self::PARAM_IDS . '[]',
397            false,
398            [ 'value' => $value, 'class' => 'cdx-checkbox__input', 'id' => $id ]
399        );
400        $checkboxIcon = Html::element( 'span', [ 'class' => 'cdx-checkbox__icon' ] );
401        $checkboxWrapper = Html::rawElement(
402            'div',
403            [ 'class' => 'cdx-checkbox__wrapper' ],
404            $checkbox . $checkboxIcon
405        );
406        return Html::rawElement( 'div', [ 'class' => 'cdx-checkbox' ], $checkboxWrapper );
407    }
408}