Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.12% |
224 / 238 |
|
50.00% |
6 / 12 |
CRAP | |
0.00% |
0 / 1 |
| SpecialWatchlistLabels | |
94.12% |
224 / 238 |
|
50.00% |
6 / 12 |
41.34 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| execute | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
8.30 | |||
| showEditForm | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
3 | |||
| filterName | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| validateName | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
6.04 | |||
| onEditFormSubmit | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| showDeleteConfirmation | |
100.00% |
33 / 33 |
|
100.00% |
1 / 1 |
2 | |||
| getDeleteConfirmationListItem | |
62.50% |
10 / 16 |
|
0.00% |
0 / 1 |
3.47 | |||
| onDeleteFormSubmit | |
71.43% |
5 / 7 |
|
0.00% |
0 / 1 |
3.21 | |||
| showTable | |
98.94% |
93 / 94 |
|
0.00% |
0 / 1 |
8 | |||
| getCheckbox | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * @license GPL-3.0-or-later |
| 4 | * @file |
| 5 | */ |
| 6 | |
| 7 | namespace MediaWiki\Specials; |
| 8 | |
| 9 | use InvalidArgumentException; |
| 10 | use MediaWiki\Exception\UserNotLoggedIn; |
| 11 | use MediaWiki\Html\Html; |
| 12 | use MediaWiki\HTMLForm\HTMLForm; |
| 13 | use MediaWiki\MainConfigNames; |
| 14 | use MediaWiki\Message\Message; |
| 15 | use MediaWiki\SpecialPage\SpecialPage; |
| 16 | use MediaWiki\Status\Status; |
| 17 | use MediaWiki\Watchlist\WatchlistLabel; |
| 18 | use MediaWiki\Watchlist\WatchlistLabelStore; |
| 19 | use MediaWiki\Watchlist\WatchlistSpecialPage; |
| 20 | use StatusValue; |
| 21 | use Wikimedia\Codex\Builder\TableBuilder; |
| 22 | use 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 | */ |
| 30 | class 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 | } |