MediaWiki master
SpecialWatchlistLabels.php
Go to the documentation of this file.
1<?php
7namespace MediaWiki\Specials;
8
9use InvalidArgumentException;
19use MediaWiki\Watchlist\WatchlistSpecialPage;
20use StatusValue;
21use Wikimedia\Codex\Builder\TableBuilder;
22use Wikimedia\Codex\Utility\Codex;
23
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 private const SESSION_SUCCESS_TYPE = 'watchlistLabelsSuccessType';
38 private const SESSION_SUCCESS_COUNT = 'watchlistLabelsSuccessCount';
39 private const SESSION_SUCCESS_LABEL_NAME = 'watchlistLabelsSuccessLabelName';
40 private const SESSION_SUCCESS_OLD_LABEL_NAME = 'watchlistLabelsSuccessOldLabelName';
41 private const SUCCESS_CREATE = 'create';
42 private const SUCCESS_EDIT = 'edit';
43 private const SUCCESS_DELETE = 'delete';
44
45 use WatchlistSpecialPage;
46
47 private ?WatchlistLabel $watchlistLabel = null;
48
50 public function __construct(
51 private WatchlistLabelStore $labelStore,
52 $name = 'WatchlistLabels',
53 $restriction = 'viewmywatchlist',
54 ) {
55 parent::__construct( $name, $restriction, false );
56 }
57
59 public function doesWrites() {
60 return true;
61 }
62
64 public function execute( $subPage ) {
65 $user = $this->getUser();
66 $right = $subPage === self::SUBPAGE_EDIT ? 'editmywatchlist' : 'viewmywatchlist';
67 if ( !$user->isRegistered()
68 || ( $user->isTemp() && !$user->isAllowed( $right ) )
69 ) {
70 // The message used here will be one of:
71 // * watchlistlabels-not-logged-in
72 // * watchlistlabels-not-logged-in-for-temp-user
73 throw new UserNotLoggedIn( 'watchlistlabels-not-logged-in' );
74 }
75 $this->checkPermissions();
76
77 $output = $this->getOutput();
78 $output->setPageTitleMsg( $this->msg( 'watchlistlabels-title' ) );
79 $output->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
80 $this->addHelpLink( 'Help:Watchlist labels' );
81 if ( !$this->getConfig()->get( MainConfigNames::EnableWatchlistLabels ) ) {
82 $output->addHTML( Html::errorBox( $this->msg( 'watchlistlabels-not-enabled' )->escaped() ) );
83 return;
84 }
85
86 $this->outputSubtitle();
87
88 if ( $subPage === self::SUBPAGE_EDIT ) {
89 $this->showEditForm();
90 } elseif ( $subPage === self::SUBPAGE_DELETE ) {
91 $this->showDeleteConfirmation();
92 } else {
93 $this->showTable();
94 }
95 }
96
100 private function showEditForm() {
101 $id = $this->getRequest()->getInt( self::PARAM_ID );
102 $descriptor = [
103 self::PARAM_NAME => [
104 'type' => 'text',
105 'name' => self::PARAM_NAME,
106 'label-message' => 'watchlistlabels-form-field-name',
107 'validation-callback' => [ $this, 'validateName' ],
108 'filter-callback' => [ $this, 'filterName' ],
109 'required' => true,
110 ],
111 ];
112 $msgSuffix = 'new';
113 if ( $id ) {
114 $this->watchlistLabel = $this->labelStore->loadById( $this->getUser(), $id );
115 if ( $this->watchlistLabel ) {
116 $descriptor[self::PARAM_NAME]['default'] = $this->watchlistLabel->getName();
117 $descriptor[self::PARAM_ID] = [
118 'type' => 'hidden',
119 'name' => self::PARAM_ID,
120 'default' => $this->watchlistLabel->getId(),
121 ];
122 $msgSuffix = 'edit';
123 }
124 }
125 $form = HTMLForm::factory( 'codex', $descriptor, $this->getContext() )
126 // Messages used here:
127 // - watchlistlabels-form-header-new
128 // - watchlistlabels-form-header-edit
129 ->setHeaderHtml( Html::element( 'h3', [], $this->msg( "watchlistlabels-form-header-$msgSuffix" )->text() ) )
130 ->showCancel( true )
131 ->setCancelTarget( $this->getPageTitle() )
132 // Messages used here:
133 // - watchlistlabels-form-submit-new
134 // - watchlistlabels-form-submit-edit
135 ->setSubmitTextMsg( "watchlistlabels-form-submit-$msgSuffix" )
136 ->setSubmitCallback( [ $this, 'onEditFormSubmit' ] );
137 $form->show();
138 }
139
149 public function filterName( $value, ?array $alldata, ?HTMLForm $form ) {
150 $label = new WatchlistLabel( $this->getUser(), $value ?? '' );
151 return $label->getName();
152 }
153
161 public function validateName( $value, ?array $alldata, ?HTMLForm $form ) {
162 $length = strlen( trim( $value ) );
163 if ( $length === 0 ) {
164 return StatusValue::newFatal( 'watchlistlabels-form-name-too-short', $length );
165 }
166 if ( $length > 255 ) {
167 return StatusValue::newFatal( 'watchlistlabels-form-name-too-long', $length );
168 }
169 $existingLabel = $this->labelStore->loadByName( $this->getUser(), $value );
170 $thisId = $alldata[self::PARAM_ID] ?? null;
171 if ( $existingLabel && $existingLabel->getId() !== (int)$thisId ) {
172 return StatusValue::newFatal( 'watchlistlabels-form-name-exists', $existingLabel->getName() );
173 }
174 return StatusValue::newGood();
175 }
176
183 public function onEditFormSubmit( $data ): Status {
184 if ( !isset( $data[self::PARAM_NAME] ) ) {
185 throw new InvalidArgumentException( 'No name data submitted.' );
186 }
187 $successType = self::SUCCESS_CREATE;
188 $oldName = null;
189 if ( $this->watchlistLabel && $this->watchlistLabel->getId() ) {
190 $successType = self::SUCCESS_EDIT;
191 $oldName = $this->watchlistLabel->getName();
192 }
193 if ( !$this->watchlistLabel ) {
194 $this->watchlistLabel = new WatchlistLabel( $this->getUser(), $data[self::PARAM_NAME] );
195 } else {
196 $this->watchlistLabel->setName( $data[self::PARAM_NAME] );
197 }
198 $saved = $this->labelStore->save( $this->watchlistLabel );
199 if ( $saved->isOK() ) {
200 $session = $this->getRequest()->getSession();
201 $session->set( self::SESSION_SUCCESS_TYPE, $successType );
202 $session->set( self::SESSION_SUCCESS_LABEL_NAME, $this->watchlistLabel->getName() );
203 if ( $successType === self::SUCCESS_EDIT && $oldName !== null ) {
204 $session->set( self::SESSION_SUCCESS_OLD_LABEL_NAME, $oldName );
205 }
206 $this->getOutput()->redirect( $this->getPageTitle()->getFullUrlForRedirect() );
207 }
208 return Status::cast( $saved );
209 }
210
211 private function showDeleteConfirmation(): void {
212 $ids = $this->getRequest()->getArray( self::PARAM_IDS ) ?? [];
213 $labels = $this->labelStore->loadAllForUser( $this->getUser() );
214 $toDelete = array_intersect_key( $labels, array_flip( $ids ) );
215 $labelCounts = $this->labelStore->countItems( array_keys( $labels ) );
216 $listItems = '';
217 foreach ( $toDelete as $label ) {
218 $listItems .= Html::rawElement( 'li', [], $this->getDeleteConfirmationListItem( $label, $labelCounts ) );
219 }
220 $count = count( $toDelete );
221 $formattedCount = $this->getLanguage()->formatNum( $count );
222 $msg = $this->msg( 'watchlistlabels-delete-warning', $count, $formattedCount )->text();
223 $warning = Html::element( 'p', [], $msg );
224 $list = Html::rawElement( 'ol', [], $listItems );
225 $descriptor = [
226 'list' => [
227 'type' => 'info',
228 'default' => $warning . $list,
229 'rawrow' => true,
230 ],
231 self::PARAM_IDS => [
232 'type' => 'hidden',
233 'name' => self::PARAM_IDS,
234 'default' => implode( ',', array_keys( $toDelete ) ),
235 ],
236 ];
237 $header = $this->msg( 'watchlistlabels-delete-header', $count )->text();
238 HTMLForm::factory( 'codex', $descriptor, $this->getContext() )
239 ->setHeaderHtml( Html::element( 'h3', [], $header ) )
240 ->showCancel( true )
241 ->setCancelTarget( $this->getPageTitle() )
242 ->setSubmitTextMsg( 'delete' )
243 ->setSubmitDestructive()
244 ->setSubmitCallback( [ $this, 'onDeleteFormSubmit' ] )
245 ->show();
246 }
247
248 private function getDeleteConfirmationListItem( WatchlistLabel $label, array $labelCounts ): string {
249 $id = $label->getId();
250 if ( !$id ) {
251 return '';
252 }
253 $itemCount = $labelCounts[ $id ];
254 if ( $labelCounts[ $id ] > 0 ) {
255 $labelCountMsg = $this->msg(
256 'watchlistlabels-delete-count',
257 $this->getLanguage()->formatNum( $itemCount ),
258 $itemCount
259 )->escaped();
260 } else {
261 $labelCountMsg = $this->msg( 'watchlistlabels-delete-unused' )->escaped();
262 }
263 return Html::element( 'span', [], $label->getName() )
264 . $this->msg( 'word-separator' )->escaped()
265 . $this->msg( 'parentheses-start' )->escaped()
266 . Html::rawElement( 'em', [], $labelCountMsg )
267 . $this->msg( 'parentheses-end' )->escaped();
268 }
269
276 public function onDeleteFormSubmit( $data ) {
277 if ( !isset( $data[self::PARAM_IDS] ) ) {
278 throw new InvalidArgumentException( 'No name data submitted.' );
279 }
280 $ids = array_map( 'intval', array_filter( explode( ',', $data[self::PARAM_IDS] ) ) );
281 $labels = $this->labelStore->loadAllForUser( $this->getUser() );
282 $deletedLabelNames = [];
283 foreach ( $ids as $id ) {
284 if ( isset( $labels[$id] ) ) {
285 $deletedLabelNames[] = $labels[$id]->getName();
286 }
287 }
288 if ( $this->labelStore->delete( $this->getUser(), $ids ) ) {
289 $session = $this->getRequest()->getSession();
290 $session->set( self::SESSION_SUCCESS_TYPE, self::SUCCESS_DELETE );
291 $session->set( self::SESSION_SUCCESS_COUNT, count( $ids ) );
292 if ( count( $deletedLabelNames ) === 1 ) {
293 $session->set( self::SESSION_SUCCESS_LABEL_NAME, $deletedLabelNames[0] );
294 }
295 $this->getOutput()->redirect( $this->getPageTitle()->getFullUrlForRedirect() );
296 return Status::newGood();
297 }
298 return Status::newFatal( 'watchlistlabels-delete-failed' );
299 }
300
304 private function showTable() {
305 $codex = new Codex();
306 $this->getOutput()->addModules( 'mediawiki.special.watchlistlabels' );
307 $this->getOutput()->addModuleStyles( 'mediawiki.special.watchlistlabels.styles' );
308 $this->showSuccessMessage();
309
310 // Page title and description.
311 $this->getOutput()->addHTML(
312 Html::element( 'h3', [], $this->msg( 'watchlistlabels-table-title' )->text() )
313 . Html::element( 'p', [], $this->msg( 'watchlistlabels-table-description' )->text() ),
314 );
315
316 // Buttons in the table header.
317 $params = [
318 'href' => $this->getPageTitle( self::SUBPAGE_EDIT )->getLinkURL(),
319 // @todo Remove Codex classes when T406372 is resolved.
320 'class' => 'cdx-button cdx-button--fake-button cdx-button--fake-button--enabled'
321 . ' cdx-button--action-progressive cdx-button--weight-primary'
322 ];
323 $newIcon = Html::element( 'span', [
324 'class' => 'cdx-button__icon mw-specialwatchlistlabels-icon--add',
325 'aria-hidden' => 'true',
326 ] );
327 $createLabel = $newIcon . ' ' . $this->msg( 'watchlistlabels-table-new-link' )->escaped();
328 $createButton = Html::rawElement( 'a', $params, $createLabel );
329 $deleteButton = $codex->button()
330 ->setAttributes( [ 'class' => 'mw-specialwatchlistlabels-delete-button' ] )
331 ->setLabel( $this->msg( 'watchlistlabels-table-delete-link' )->text() )
332 ->setIconClass( 'mw-specialwatchlistlabels-icon--trash' )
333 ->setType( 'submit' )
334 ->setAction( 'destructive' )
335 ->build()
336 ->getHtml();
337
338 // Data.
339 $data = [];
340 $labels = $this->labelStore->loadAllForUser( $this->getUser() );
341 $labelCounts = $this->labelStore->countItems( array_keys( $labels ) );
342 $editIcon = Html::element( 'span', [ 'class' => 'cdx-button__icon mw-specialwatchlistlabels-icon--edit' ] );
343 foreach ( $labels as $label ) {
344 $id = $label->getId();
345 if ( !$id ) {
346 continue;
347 }
348 $url = $this->getPageTitle( self::SUBPAGE_EDIT )->getLocalURL( [ self::PARAM_ID => $id ] );
349 $params = [
350 'href' => $url,
351 'role' => 'button',
352 'class' => 'cdx-button cdx-button--fake-button cdx-button--fake-button--enabled'
353 . ' cdx-button--weight-quiet cdx-button--icon-only cdx-button--size-small',
354 'title' => $this->msg( 'watchlistlabels-table-edit' )->text(),
355 ];
356 $checkboxId = self::PARAM_IDS . '_' . $id;
357 $labelVal = Html::element( 'bdi', [], $label->getName() );
358 // The sortable columns must have matching '*-sort' elements containing unformatted data for sorting.
359 $data[] = [
360 'select' => $this->getCheckbox( $checkboxId, (string)$id ),
361 'name' => Html::rawElement( 'label', [ 'for' => $checkboxId ], $labelVal ),
362 'name-sort' => mb_strtolower( $label->getName() ),
363 'count' => $this->getLanguage()->formatNum( $labelCounts[ $id ] ),
364 'count-sort' => $labelCounts[ $id ],
365 'edit' => Html::rawElement( 'a', $params, $editIcon ),
366 ];
367 }
368
369 // Sort by count by default, and others as requested.
370 // We sort here rather than in the DB because we're combining multiple queries' data,
371 // and there's only ever one page of results to show (up to 100).
372 $sortCol = $this->getRequest()->getText( 'sort', 'count' );
373 $sortDir = $this->getRequest()->getBool( 'asc' ) ? TableBuilder::SORT_ASCENDING : TableBuilder::SORT_DESCENDING;
374 $sortColName = $sortCol . '-sort';
375 usort(
376 $data,
377 static function ( $a, $b ) use ( $sortDir, $sortColName ) {
378 if ( !isset( $a[$sortColName] )
379 || !isset( $b[$sortColName] )
380 || $a[$sortColName] === $b[$sortColName]
381 ) {
382 return 0;
383 }
384 return $sortDir === TableBuilder::SORT_ASCENDING
385 ? $a[$sortColName] <=> $b[$sortColName]
386 : $b[$sortColName] <=> $a[$sortColName];
387 }
388 );
389
390 // Put it all together in the table.
391 $table = $codex->table()
392 ->setCurrentSortColumn( $sortCol )
393 ->setCurrentSortDirection( $sortDir )
394 ->setAttributes( [ 'class' => 'mw-specialwatchlistlabels-table' ] )
395 ->setCaption( $this->msg( 'watchlistlabels-table-header' )->text() )
396 ->setHeaderContent( "$createButton $deleteButton" )
397 ->setColumns( [
398 [
399 'id' => 'select',
400 'label' => '',
401 ],
402 [
403 'id' => 'name',
404 'label' => $this->msg( 'watchlistlabels-table-col-name' )->escaped(),
405 'sortable' => true,
406 ],
407 [
408 'id' => 'count',
409 'label' => $this->msg( 'watchlistlabels-table-col-count' )->escaped(),
410 'sortable' => true,
411 ],
412 [
413 'id' => 'edit',
414 'label' => $this->msg( 'watchlistlabels-table-col-actions' )->escaped(),
415 ],
416 ] )
417 ->setData( $data )
418 ->setPaginate( false )
419 ->build();
420 $deleteUrl = $this->getPageTitle( self::SUBPAGE_DELETE )->getLocalURL();
421 $form = Html::rawElement( 'form', [ 'action' => $deleteUrl ], $table->getHtml() );
422 $this->getOutput()->addHTML( $form );
423 }
424
429 private function showSuccessMessage(): void {
430 $session = $this->getRequest()->getSession();
431 $successType = $session->get( self::SESSION_SUCCESS_TYPE );
432 if ( !$successType ) {
433 return;
434 }
435 $successCount = $session->get( self::SESSION_SUCCESS_COUNT );
436 $successLabelName = $session->get( self::SESSION_SUCCESS_LABEL_NAME );
437 $successOldLabelName = $session->get( self::SESSION_SUCCESS_OLD_LABEL_NAME );
438
439 // Clear session data after reading
440 $session->remove( self::SESSION_SUCCESS_TYPE );
441 $session->remove( self::SESSION_SUCCESS_COUNT );
442 $session->remove( self::SESSION_SUCCESS_LABEL_NAME );
443 $session->remove( self::SESSION_SUCCESS_OLD_LABEL_NAME );
444
445 $message = null;
446 switch ( $successType ) {
447 case self::SUCCESS_CREATE:
448 $message = $this->msg( 'watchlistlabels-success-created', $successLabelName )->escaped();
449 break;
450 case self::SUCCESS_EDIT:
451 if ( !$successOldLabelName || !$successLabelName ) {
452 return;
453 }
454 $message = $this->msg(
455 'watchlistlabels-success-edited',
456 $successOldLabelName,
457 $successLabelName
458 )->escaped();
459 break;
460 case self::SUCCESS_DELETE:
461 $count = max( 0, (int)$successCount );
462 if ( $count === 0 ) {
463 return;
464 }
465 $message = $this->msg( 'watchlistlabels-success-deleted', $successLabelName, $count )->escaped();
466 break;
467 }
468
469 if ( $message !== null ) {
470 $this->getOutput()->addHTML( Html::successBox( $message ) );
471 }
472 }
473
482 private function getCheckbox( string $id, string $value ): string {
483 $checkbox = Html::check(
484 self::PARAM_IDS . '[]',
485 false,
486 [ 'value' => $value, 'class' => 'cdx-checkbox__input', 'id' => $id ]
487 );
488 $checkboxIcon = Html::element( 'span', [ 'class' => 'cdx-checkbox__icon' ] );
489 $checkboxWrapper = Html::rawElement(
490 'div',
491 [ 'class' => 'cdx-checkbox__wrapper' ],
492 $checkbox . $checkboxIcon
493 );
494 return Html::rawElement( 'div', [ 'class' => 'cdx-checkbox' ], $checkboxWrapper );
495 }
496}
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:69
Redirect a user to the login page or account creation page.
Object handling generic submission, CSRF protection, layout and other logic for UI forms in a reusabl...
Definition HTMLForm.php:207
This class is a collection of static functions that serve two purposes:
Definition Html.php:43
A class containing constants representing the names of configuration variables.
const EnableWatchlistLabels
Name constant for the EnableWatchlistLabels setting, for use with Config::get()
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition Message.php:144
Parent class for all special pages.
getUser()
Shortcut to get the User executing this instance.
getPageTitle( $subpage=false)
Get a self-referential title object.
checkPermissions()
Checks if userCanExecute, and if not throws a PermissionsError.
getConfig()
Shortcut to get main config object.
getContext()
Gets the context this SpecialPage is executed in.
getRequest()
Get the WebRequest being used for this instance.
msg( $key,... $params)
Wrapper around wfMessage that sets the current context.
getOutput()
Get the OutputPage being used for this instance.
addHelpLink( $to, $overrideBaseUrl=false)
Adds help link with an icon via page indicators.
A special page for viewing a user's watchlist labels and performing CRUD operations on them.
validateName( $value, ?array $alldata, ?HTMLForm $form)
onDeleteFormSubmit( $data)
Handle the delete confirmation form submission.
execute( $subPage)
Default execute method Checks user permissions.This must be overridden by subclasses; it will be made...
__construct(private WatchlistLabelStore $labelStore, $name='WatchlistLabels', $restriction='viewmywatchlist',)
filterName( $value, ?array $alldata, ?HTMLForm $form)
Filter the 'name' field value.
doesWrites()
Indicates whether POST requests to this special page require write access to the wiki....
onEditFormSubmit( $data)
Handle the form submission, for saving new or existing labels.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
Service class for storage of watchlist labels.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
element(SerializerNode $parent, SerializerNode $node, $contents)
msg( $key,... $params)