1<?php 2 3/** 4 * HTML form generation and submission handling. 5 * 6 * This program is free software; you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation; either version 2 of the License, or 9 * (at your option) any later version. 10 * 11 * This program is distributed in the hope that it will be useful, 12 * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 * GNU General Public License for more details. 15 * 16 * You should have received a copy of the GNU General Public License along 17 * with this program; if not, write to the Free Software Foundation, Inc., 18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 19 * http://www.gnu.org/copyleft/gpl.html 20 * 21 * @file 22 */ 23 24use MediaWiki\HookContainer\ProtectedHookAccessorTrait; 25 26/** 27 * Object handling generic submission, CSRF protection, layout and 28 * other logic for UI forms in a reusable manner. 29 * 30 * In order to generate the form, the HTMLForm object takes an array 31 * structure detailing the form fields available. Each element of the 32 * array is a basic property-list, including the type of field, the 33 * label it is to be given in the form, callbacks for validation and 34 * 'filtering', and other pertinent information. 35 * 36 * Field types are implemented as subclasses of the generic HTMLFormField 37 * object, and typically implement at least getInputHTML, which generates 38 * the HTML for the input field to be placed in the table. 39 * 40 * You can find extensive documentation on the www.mediawiki.org wiki: 41 * - https://www.mediawiki.org/wiki/HTMLForm 42 * - https://www.mediawiki.org/wiki/HTMLForm/tutorial 43 * 44 * The constructor input is an associative array of $fieldname => $info, 45 * where $info is an Associative Array with any of the following: 46 * 47 * 'class' -- the subclass of HTMLFormField that will be used 48 * to create the object. *NOT* the CSS class! 49 * 'type' -- roughly translates into the <select> type attribute. 50 * if 'class' is not specified, this is used as a map 51 * through HTMLForm::$typeMappings to get the class name. 52 * 'default' -- default value when the form is displayed 53 * 'nodata' -- if set (to any value, which casts to true), the data 54 * for this field will not be loaded from the actual request. Instead, 55 * always the default data is set as the value of this field. 56 * 'id' -- HTML id attribute 57 * 'cssclass' -- CSS class 58 * 'csshelpclass' -- CSS class used to style help text 59 * 'dir' -- Direction of the element. 60 * 'options' -- associative array mapping raw text labels to values. 61 * Some field types support multi-level arrays. 62 * Overwrites 'options-message'. 63 * 'options-messages' -- associative array mapping message keys to values. 64 * Some field types support multi-level arrays. 65 * Overwrites 'options' and 'options-message'. 66 * 'options-message' -- message key or object to be parsed to extract the list of 67 * options (like 'ipbreason-dropdown'). 68 * 'label-message' -- message key or object for a message to use as the label. 69 * can be an array of msg key and then parameters to 70 * the message. 71 * 'label' -- alternatively, a raw text message. Overridden by 72 * label-message 73 * 'help' -- message text for a message to use as a help text. 74 * 'help-message' -- message key or object for a message to use as a help text. 75 * can be an array of msg key and then parameters to 76 * the message. 77 * Overwrites 'help-messages' and 'help'. 78 * 'help-messages' -- array of message keys/objects. As above, each item can 79 * be an array of msg key and then parameters. 80 * Overwrites 'help'. 81 * 'help-inline' -- Whether help text (defined using options above) will be shown 82 * inline after the input field, rather than in a popup. 83 * Defaults to true. Only used by OOUI form fields. 84 * 'required' -- passed through to the object, indicating that it 85 * is a required field. 86 * 'size' -- the length of text fields 87 * 'filter-callback' -- a function name to give you the chance to 88 * massage the inputted value before it's processed. 89 * @see HTMLFormField::filter() 90 * 'validation-callback' -- a function name to give you the chance 91 * to impose extra validation on the field input. 92 * @see HTMLFormField::validate() 93 * 'name' -- By default, the 'name' attribute of the input field 94 * is "wp{$fieldname}". If you want a different name 95 * (eg one without the "wp" prefix), specify it here and 96 * it will be used without modification. 97 * 'hide-if' -- expression given as an array stating when the field 98 * should be hidden. The first array value has to be the 99 * expression's logic operator. Supported expressions: 100 * 'NOT' 101 * [ 'NOT', array $expression ] 102 * To hide a field if a given expression is not true. 103 * '===' 104 * [ '===', string $fieldName, string $value ] 105 * To hide a field if another field identified by 106 * $field has the value $value. 107 * '!==' 108 * [ '!==', string $fieldName, string $value ] 109 * Same as [ 'NOT', [ '===', $fieldName, $value ] 110 * 'OR', 'AND', 'NOR', 'NAND' 111 * [ 'XXX', array $expression1, ..., array $expressionN ] 112 * To hide a field if one or more (OR), all (AND), 113 * neither (NOR) or not all (NAND) given expressions 114 * are evaluated as true. 115 * The expressions will be given to a JavaScript frontend 116 * module which will continually update the field's 117 * visibility. 118 * 119 * Since 1.20, you can chain mutators to ease the form generation: 120 * @par Example: 121 * @code 122 * $form = new HTMLForm( $someFields ); 123 * $form->setMethod( 'get' ) 124 * ->setWrapperLegendMsg( 'message-key' ) 125 * ->prepareForm() 126 * ->displayForm( '' ); 127 * @endcode 128 * Note that you will have prepareForm and displayForm at the end. Other 129 * method calls done after that would simply not be part of the form :( 130 * 131 * @stable to extend 132 * 133 * @todo Document 'section' / 'subsection' stuff 134 */ 135class HTMLForm extends ContextSource { 136 use ProtectedHookAccessorTrait; 137 138 // A mapping of 'type' inputs onto standard HTMLFormField subclasses 139 public static $typeMappings = [ 140 'api' => HTMLApiField::class, 141 'text' => HTMLTextField::class, 142 'textwithbutton' => HTMLTextFieldWithButton::class, 143 'textarea' => HTMLTextAreaField::class, 144 'select' => HTMLSelectField::class, 145 'combobox' => HTMLComboboxField::class, 146 'radio' => HTMLRadioField::class, 147 'multiselect' => HTMLMultiSelectField::class, 148 'limitselect' => HTMLSelectLimitField::class, 149 'check' => HTMLCheckField::class, 150 'toggle' => HTMLCheckField::class, 151 'int' => HTMLIntField::class, 152 'float' => HTMLFloatField::class, 153 'info' => HTMLInfoField::class, 154 'selectorother' => HTMLSelectOrOtherField::class, 155 'selectandother' => HTMLSelectAndOtherField::class, 156 'namespaceselect' => HTMLSelectNamespace::class, 157 'namespaceselectwithbutton' => HTMLSelectNamespaceWithButton::class, 158 'tagfilter' => HTMLTagFilter::class, 159 'sizefilter' => HTMLSizeFilterField::class, 160 'submit' => HTMLSubmitField::class, 161 'hidden' => HTMLHiddenField::class, 162 'edittools' => HTMLEditTools::class, 163 'checkmatrix' => HTMLCheckMatrix::class, 164 'cloner' => HTMLFormFieldCloner::class, 165 'autocompleteselect' => HTMLAutoCompleteSelectField::class, 166 'language' => HTMLSelectLanguageField::class, 167 'date' => HTMLDateTimeField::class, 168 'time' => HTMLDateTimeField::class, 169 'datetime' => HTMLDateTimeField::class, 170 'expiry' => HTMLExpiryField::class, 171 // HTMLTextField will output the correct type="" attribute automagically. 172 // There are about four zillion other HTML5 input types, like range, but 173 // we don't use those at the moment, so no point in adding all of them. 174 'email' => HTMLTextField::class, 175 'password' => HTMLTextField::class, 176 'url' => HTMLTextField::class, 177 'title' => HTMLTitleTextField::class, 178 'user' => HTMLUserTextField::class, 179 'usersmultiselect' => HTMLUsersMultiselectField::class, 180 'titlesmultiselect' => HTMLTitlesMultiselectField::class, 181 'namespacesmultiselect' => HTMLNamespacesMultiselectField::class, 182 ]; 183 184 public $mFieldData; 185 186 protected $mMessagePrefix; 187 188 /** @var HTMLFormField[] */ 189 protected $mFlatFields = []; 190 protected $mFieldTree = []; 191 protected $mShowReset = false; 192 protected $mShowSubmit = true; 193 /** @var string[] */ 194 protected $mSubmitFlags = [ 'primary', 'progressive' ]; 195 protected $mShowCancel = false; 196 protected $mCancelTarget; 197 198 protected $mSubmitCallback; 199 protected $mValidationErrorMessage; 200 201 protected $mPre = ''; 202 protected $mHeader = ''; 203 protected $mFooter = ''; 204 protected $mSectionHeaders = []; 205 protected $mSectionFooters = []; 206 protected $mPost = ''; 207 protected $mId; 208 protected $mName; 209 protected $mTableId = ''; 210 211 protected $mSubmitID; 212 protected $mSubmitName; 213 protected $mSubmitText; 214 protected $mSubmitTooltip; 215 216 protected $mFormIdentifier; 217 protected $mTitle; 218 protected $mMethod = 'post'; 219 protected $mWasSubmitted = false; 220 221 /** 222 * Form action URL. false means we will use the URL to set Title 223 * @since 1.19 224 * @var bool|string 225 */ 226 protected $mAction = false; 227 228 /** 229 * Whether the form can be collapsed 230 * @since 1.34 231 * @var bool 232 */ 233 protected $mCollapsible = false; 234 235 /** 236 * Whether the form is collapsed by default 237 * @since 1.34 238 * @var bool 239 */ 240 protected $mCollapsed = false; 241 242 /** 243 * Form attribute autocomplete. A typical value is "off". null does not set the attribute 244 * @since 1.27 245 * @var string|null 246 */ 247 protected $mAutocomplete = null; 248 249 protected $mUseMultipart = false; 250 /** 251 * @var array[] 252 * @phan-var array<int,array{0:string,1:array}> 253 */ 254 protected $mHiddenFields = []; 255 /** 256 * @var array[] 257 * @phan-var array<array{name:string,value:string,label-message?:string|string[]|MessageSpecifier,label?:string,label-raw?:string,id?:string,attribs?:array,flags?:string|string[],framed?:bool}> 258 */ 259 protected $mButtons = []; 260 261 protected $mWrapperLegend = false; 262 protected $mWrapperAttributes = []; 263 264 /** 265 * Salt for the edit token. 266 * @var string|array 267 */ 268 protected $mTokenSalt = ''; 269 270 /** 271 * If true, sections that contain both fields and subsections will 272 * render their subsections before their fields. 273 * 274 * Subclasses may set this to false to render subsections after fields 275 * instead. 276 */ 277 protected $mSubSectionBeforeFields = true; 278 279 /** 280 * Format in which to display form. For viable options, 281 * @see $availableDisplayFormats 282 * @var string 283 */ 284 protected $displayFormat = 'table'; 285 286 /** 287 * Available formats in which to display the form 288 * @var array 289 */ 290 protected $availableDisplayFormats = [ 291 'table', 292 'div', 293 'raw', 294 'inline', 295 ]; 296 297 /** 298 * Available formats in which to display the form 299 * @var array 300 */ 301 protected $availableSubclassDisplayFormats = [ 302 'vform', 303 'ooui', 304 ]; 305 306 /** 307 * Construct a HTMLForm object for given display type. May return a HTMLForm subclass. 308 * 309 * @stable to call 310 * 311 * @param string $displayFormat 312 * @param mixed ...$arguments Additional arguments to pass to the constructor. 313 * @return HTMLForm 314 */ 315 public static function factory( $displayFormat, ...$arguments ) { 316 switch ( $displayFormat ) { 317 case 'vform': 318 return new VFormHTMLForm( ...$arguments ); 319 case 'ooui': 320 return new OOUIHTMLForm( ...$arguments ); 321 default: 322 $form = new self( ...$arguments ); 323 $form->setDisplayFormat( $displayFormat ); 324 return $form; 325 } 326 } 327 328 /** 329 * Build a new HTMLForm from an array of field attributes 330 * 331 * @stable to call 332 * 333 * @param array $descriptor Array of Field constructs, as described 334 * in the class documentation 335 * @param IContextSource|null $context Available since 1.18, will become compulsory in 1.18. 336 * Obviates the need to call $form->setTitle() 337 * @param string $messagePrefix A prefix to go in front of default messages 338 */ 339 public function __construct( $descriptor, /*IContextSource*/ $context = null, 340 $messagePrefix = '' 341 ) { 342 if ( $context instanceof IContextSource ) { 343 $this->setContext( $context ); 344 $this->mTitle = false; // We don't need them to set a title 345 $this->mMessagePrefix = $messagePrefix; 346 } elseif ( $context === null && $messagePrefix !== '' ) { 347 $this->mMessagePrefix = $messagePrefix; 348 } elseif ( is_string( $context ) && $messagePrefix === '' ) { 349 // B/C since 1.18 350 // it's actually $messagePrefix 351 $this->mMessagePrefix = $context; 352 } 353 354 // Evil hack for mobile :( 355 if ( 356 !$this->getConfig()->get( 'HTMLFormAllowTableFormat' ) 357 && $this->displayFormat === 'table' 358 ) { 359 $this->displayFormat = 'div'; 360 } 361 362 $this->addFields( $descriptor ); 363 } 364 365 /** 366 * Add fields to the form 367 * 368 * @since 1.34 369 * 370 * @param array $descriptor Array of Field constructs, as described 371 * in the class documentation 372 * @return HTMLForm 373 */ 374 public function addFields( $descriptor ) { 375 $loadedDescriptor = []; 376 377 foreach ( $descriptor as $fieldname => $info ) { 378 379 $section = $info['section'] ?? ''; 380 381 if ( isset( $info['type'] ) && $info['type'] === 'file' ) { 382 $this->mUseMultipart = true; 383 } 384 385 $field = static::loadInputFromParameters( $fieldname, $info, $this ); 386 387 $setSection =& $loadedDescriptor; 388 if ( $section ) { 389 foreach ( explode( '/', $section ) as $newName ) { 390 if ( !isset( $setSection[$newName] ) ) { 391 $setSection[$newName] = []; 392 } 393 394 $setSection =& $setSection[$newName]; 395 } 396 } 397 398 $setSection[$fieldname] = $field; 399 $this->mFlatFields[$fieldname] = $field; 400 } 401 402 $this->mFieldTree = array_merge( $this->mFieldTree, $loadedDescriptor ); 403 404 return $this; 405 } 406 407 /** 408 * @param string $fieldname 409 * @return bool 410 */ 411 public function hasField( $fieldname ) { 412 return isset( $this->mFlatFields[$fieldname] ); 413 } 414 415 /** 416 * @param string $fieldname 417 * @return HTMLFormField 418 * @throws DomainException on invalid field name 419 */ 420 public function getField( $fieldname ) { 421 if ( !$this->hasField( $fieldname ) ) { 422 throw new DomainException( __METHOD__ . ': no field named ' . $fieldname ); 423 } 424 return $this->mFlatFields[$fieldname]; 425 } 426 427 /** 428 * Set format in which to display the form 429 * 430 * @param string $format The name of the format to use, must be one of 431 * $this->availableDisplayFormats 432 * 433 * @throws MWException 434 * @since 1.20 435 * @return HTMLForm $this for chaining calls (since 1.20) 436 */ 437 public function setDisplayFormat( $format ) { 438 if ( 439 in_array( $format, $this->availableSubclassDisplayFormats, true ) || 440 in_array( $this->displayFormat, $this->availableSubclassDisplayFormats, true ) 441 ) { 442 throw new MWException( 'Cannot change display format after creation, ' . 443 'use HTMLForm::factory() instead' ); 444 } 445 446 if ( !in_array( $format, $this->availableDisplayFormats, true ) ) { 447 throw new MWException( 'Display format must be one of ' . 448 print_r( 449 array_merge( 450 $this->availableDisplayFormats, 451 $this->availableSubclassDisplayFormats 452 ), 453 true 454 ) ); 455 } 456 457 // Evil hack for mobile :( 458 if ( !$this->getConfig()->get( 'HTMLFormAllowTableFormat' ) && $format === 'table' ) { 459 $format = 'div'; 460 } 461 462 $this->displayFormat = $format; 463 464 return $this; 465 } 466 467 /** 468 * Getter for displayFormat 469 * @since 1.20 470 * @return string 471 */ 472 public function getDisplayFormat() { 473 return $this->displayFormat; 474 } 475 476 /** 477 * Get the HTMLFormField subclass for this descriptor. 478 * 479 * The descriptor can be passed either 'class' which is the name of 480 * a HTMLFormField subclass, or a shorter 'type' which is an alias. 481 * This makes sure the 'class' is always set, and also is returned by 482 * this function for ease. 483 * 484 * @since 1.23 485 * 486 * @param string $fieldname Name of the field 487 * @param array &$descriptor Input Descriptor, as described 488 * in the class documentation 489 * 490 * @throws MWException 491 * @return string Name of a HTMLFormField subclass 492 */ 493 public static function getClassFromDescriptor( $fieldname, &$descriptor ) { 494 if ( isset( $descriptor['class'] ) ) { 495 $class = $descriptor['class']; 496 } elseif ( isset( $descriptor['type'] ) ) { 497 $class = static::$typeMappings[$descriptor['type']]; 498 $descriptor['class'] = $class; 499 } else { 500 $class = null; 501 } 502 503 if ( !$class ) { 504 throw new MWException( "Descriptor with no class for $fieldname: " 505 . print_r( $descriptor, true ) ); 506 } 507 508 return $class; 509 } 510 511 /** 512 * Initialise a new Object for the field 513 * @stable to override 514 * 515 * @param string $fieldname Name of the field 516 * @param array $descriptor Input Descriptor, as described 517 * in the class documentation 518 * @param HTMLForm|null $parent Parent instance of HTMLForm 519 * 520 * @throws MWException 521 * @return HTMLFormField Instance of a subclass of HTMLFormField 522 */ 523 public static function loadInputFromParameters( $fieldname, $descriptor, 524 HTMLForm $parent = null 525 ) { 526 $class = static::getClassFromDescriptor( $fieldname, $descriptor ); 527 528 $descriptor['fieldname'] = $fieldname; 529 if ( $parent ) { 530 $descriptor['parent'] = $parent; 531 } 532 533 # @todo This will throw a fatal error whenever someone try to use 534 # 'class' to feed a CSS class instead of 'cssclass'. Would be 535 # great to avoid the fatal error and show a nice error. 536 return new $class( $descriptor ); 537 } 538 539 /** 540 * Prepare form for submission. 541 * 542 * @warning When doing method chaining, that should be the very last 543 * method call before displayForm(). 544 * 545 * @throws MWException 546 * @return HTMLForm $this for chaining calls (since 1.20) 547 */ 548 public function prepareForm() { 549 # Check if we have the info we need 550 if ( !$this->mTitle instanceof Title && $this->mTitle !== false ) { 551 throw new MWException( 'You must call setTitle() on an HTMLForm' ); 552 } 553 554 # Load data from the request. 555 if ( 556 $this->mFormIdentifier === null || 557 $this->getRequest()->getVal( 'wpFormIdentifier' ) === $this->mFormIdentifier 558 ) { 559 $this->loadData(); 560 } else { 561 $this->mFieldData = []; 562 } 563 564 return $this; 565 } 566 567 /** 568 * Try submitting, with edit token check first 569 * @return Status|bool 570 */ 571 public function tryAuthorizedSubmit() { 572 $result = false; 573 574 if ( $this->mFormIdentifier === null ) { 575 $identOkay = true; 576 } else { 577 $identOkay = $this->getRequest()->getVal( 'wpFormIdentifier' ) === $this->mFormIdentifier; 578 } 579 580 $tokenOkay = false; 581 if ( $this->getMethod() !== 'post' ) { 582 $tokenOkay = true; // no session check needed 583 } elseif ( $this->getRequest()->wasPosted() ) { 584 $editToken = $this->getRequest()->getVal( 'wpEditToken' ); 585 if ( $this->getUser()->isLoggedIn() || $editToken !== null ) { 586 // Session tokens for logged-out users have no security value. 587 // However, if the user gave one, check it in order to give a nice 588 // "session expired" error instead of "permission denied" or such. 589 $tokenOkay = $this->getUser()->matchEditToken( $editToken, $this->mTokenSalt ); 590 } else { 591 $tokenOkay = true; 592 } 593 } 594 595 if ( $tokenOkay && $identOkay ) { 596 $this->mWasSubmitted = true; 597 $result = $this->trySubmit(); 598 } 599 600 return $result; 601 } 602 603 /** 604 * The here's-one-I-made-earlier option: do the submission if 605 * posted, or display the form with or without funky validation 606 * errors 607 * @stable to override 608 * @return bool|Status Whether submission was successful. 609 */ 610 public function show() { 611 $this->prepareForm(); 612 613 $result = $this->tryAuthorizedSubmit(); 614 if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) { 615 return $result; 616 } 617 618 $this->displayForm( $result ); 619 620 return false; 621 } 622 623 /** 624 * Same as self::show with the difference, that the form will be 625 * added to the output, no matter, if the validation was good or not. 626 * @return bool|Status Whether submission was successful. 627 */ 628 public function showAlways() { 629 $this->prepareForm(); 630 631 $result = $this->tryAuthorizedSubmit(); 632 633 $this->displayForm( $result ); 634 635 return $result; 636 } 637 638 /** 639 * Validate all the fields, and call the submission callback 640 * function if everything is kosher. 641 * @stable to override 642 * @throws MWException 643 * @return bool|string|array|Status 644 * - Bool true or a good Status object indicates success, 645 * - Bool false indicates no submission was attempted, 646 * - Anything else indicates failure. The value may be a fatal Status 647 * object, an HTML string, or an array of arrays (message keys and 648 * params) or strings (message keys) 649 */ 650 public function trySubmit() { 651 $valid = true; 652 $hoistedErrors = Status::newGood(); 653 if ( $this->mValidationErrorMessage ) { 654 foreach ( $this->mValidationErrorMessage as $error ) { 655 $hoistedErrors->fatal( ...$error ); 656 } 657 } else { 658 $hoistedErrors->fatal( 'htmlform-invalid-input' ); 659 } 660 661 $this->mWasSubmitted = true; 662 663 # Check for cancelled submission 664 foreach ( $this->mFlatFields as $fieldname => $field ) { 665 if ( !array_key_exists( $fieldname, $this->mFieldData ) ) { 666 continue; 667 } 668 if ( $field->cancelSubmit( $this->mFieldData[$fieldname], $this->mFieldData ) ) { 669 $this->mWasSubmitted = false; 670 return false; 671 } 672 } 673 674 # Check for validation 675 foreach ( $this->mFlatFields as $fieldname => $field ) { 676 if ( !array_key_exists( $fieldname, $this->mFieldData ) ) { 677 continue; 678 } 679 if ( $field->isHidden( $this->mFieldData ) ) { 680 continue; 681 } 682 $res = $field->validate( $this->mFieldData[$fieldname], $this->mFieldData ); 683 if ( $res !== true ) { 684 $valid = false; 685 if ( $res !== false && !$field->canDisplayErrors() ) { 686 if ( is_string( $res ) ) { 687 $hoistedErrors->fatal( 'rawmessage', $res ); 688 } else { 689 $hoistedErrors->fatal( $res ); 690 } 691 } 692 } 693 } 694 695 if ( !$valid ) { 696 return $hoistedErrors; 697 } 698 699 $callback = $this->mSubmitCallback; 700 if ( !is_callable( $callback ) ) { 701 throw new MWException( 'HTMLForm: no submit callback provided. Use ' . 702 'setSubmitCallback() to set one.' ); 703 } 704 705 $data = $this->filterDataForSubmit( $this->mFieldData ); 706 707 $res = call_user_func( $callback, $data, $this ); 708 if ( $res === false ) { 709 $this->mWasSubmitted = false; 710 } 711 712 return $res; 713 } 714 715 /** 716 * Test whether the form was considered to have been submitted or not, i.e. 717 * whether the last call to tryAuthorizedSubmit or trySubmit returned 718 * non-false. 719 * 720 * This will return false until HTMLForm::tryAuthorizedSubmit or 721 * HTMLForm::trySubmit is called. 722 * 723 * @since 1.23 724 * @return bool 725 */ 726 public function wasSubmitted() { 727 return $this->mWasSubmitted; 728 } 729 730 /** 731 * Set a callback to a function to do something with the form 732 * once it's been successfully validated. 733 * 734 * @param callable $cb The function will be passed the output from 735 * HTMLForm::filterDataForSubmit and this HTMLForm object, and must 736 * return as documented for HTMLForm::trySubmit 737 * 738 * @return HTMLForm $this for chaining calls (since 1.20) 739 */ 740 public function setSubmitCallback( $cb ) { 741 $this->mSubmitCallback = $cb; 742 743 return $this; 744 } 745 746 /** 747 * Set a message to display on a validation error. 748 * 749 * @param array $msg Array of valid inputs to wfMessage() 750 * (so each entry must itself be an array of arguments) 751 * 752 * @return HTMLForm $this for chaining calls (since 1.20) 753 */ 754 public function setValidationErrorMessage( $msg ) { 755 $this->mValidationErrorMessage = $msg; 756 757 return $this; 758 } 759 760 /** 761 * Set the introductory message, overwriting any existing message. 762 * 763 * @param string $msg Complete text of message to display 764 * 765 * @return HTMLForm $this for chaining calls (since 1.20) 766 */ 767 public function setIntro( $msg ) { 768 $this->setPreText( $msg ); 769 770 return $this; 771 } 772 773 /** 774 * Set the introductory message HTML, overwriting any existing message. 775 * @since 1.19 776 * 777 * @param string $msg Complete HTML of message to display 778 * 779 * @return HTMLForm $this for chaining calls (since 1.20) 780 */ 781 public function setPreText( $msg ) { 782 $this->mPre = $msg; 783 784 return $this; 785 } 786 787 /** 788 * Add HTML to introductory message. 789 * 790 * @param string $msg Complete HTML of message to display 791 * 792 * @return HTMLForm $this for chaining calls (since 1.20) 793 */ 794 public function addPreText( $msg ) { 795 $this->mPre .= $msg; 796 797 return $this; 798 } 799 800 /** 801 * Get the introductory message HTML. 802 * 803 * @since 1.32 804 * 805 * @return string 806 */ 807 public function getPreText() { 808 return $this->mPre; 809 } 810 811 /** 812 * Add HTML to the header, inside the form. 813 * 814 * @param string $msg Additional HTML to display in header 815 * @param string|null $section The section to add the header to 816 * 817 * @return HTMLForm $this for chaining calls (since 1.20) 818 */ 819 public function addHeaderText( $msg, $section = null ) { 820 if ( $section === null ) { 821 $this->mHeader .= $msg; 822 } else { 823 if ( !isset( $this->mSectionHeaders[$section] ) ) { 824 $this->mSectionHeaders[$section] = ''; 825 } 826 $this->mSectionHeaders[$section] .= $msg; 827 } 828 829 return $this; 830 } 831 832 /** 833 * Set header text, inside the form. 834 * @since 1.19 835 * 836 * @param string $msg Complete HTML of header to display 837 * @param string|null $section The section to add the header to 838 * 839 * @return HTMLForm $this for chaining calls (since 1.20) 840 */ 841 public function setHeaderText( $msg, $section = null ) { 842 if ( $section === null ) { 843 $this->mHeader = $msg; 844 } else { 845 $this->mSectionHeaders[$section] = $msg; 846 } 847 848 return $this; 849 } 850 851 /** 852 * Get header text. 853 * @stable to override 854 * 855 * @param string|null $section The section to get the header text for 856 * @since 1.26 857 * @return string HTML 858 */ 859 public function getHeaderText( $section = null ) { 860 if ( $section === null ) { 861 return $this->mHeader; 862 } else { 863 return $this->mSectionHeaders[$section] ?? ''; 864 } 865 } 866 867 /** 868 * Add footer text, inside the form. 869 * 870 * @param string $msg Complete text of message to display 871 * @param string|null $section The section to add the footer text to 872 * 873 * @return HTMLForm $this for chaining calls (since 1.20) 874 */ 875 public function addFooterText( $msg, $section = null ) { 876 if ( $section === null ) { 877 $this->mFooter .= $msg; 878 } else { 879 if ( !isset( $this->mSectionFooters[$section] ) ) { 880 $this->mSectionFooters[$section] = ''; 881 } 882 $this->mSectionFooters[$section] .= $msg; 883 } 884 885 return $this; 886 } 887 888 /** 889 * Set footer text, inside the form. 890 * @since 1.19 891 * 892 * @param string $msg Complete text of message to display 893 * @param string|null $section The section to add the footer text to 894 * 895 * @return HTMLForm $this for chaining calls (since 1.20) 896 */ 897 public function setFooterText( $msg, $section = null ) { 898 if ( $section === null ) { 899 $this->mFooter = $msg; 900 } else { 901 $this->mSectionFooters[$section] = $msg; 902 } 903 904 return $this; 905 } 906 907 /** 908 * Get footer text. 909 * 910 * @param string|null $section The section to get the footer text for 911 * @since 1.26 912 * @return string 913 */ 914 public function getFooterText( $section = null ) { 915 if ( $section === null ) { 916 return $this->mFooter; 917 } else { 918 return $this->mSectionFooters[$section] ?? ''; 919 } 920 } 921 922 /** 923 * Add text to the end of the display. 924 * 925 * @param string $msg Complete text of message to display 926 * 927 * @return HTMLForm $this for chaining calls (since 1.20) 928 */ 929 public function addPostText( $msg ) { 930 $this->mPost .= $msg; 931 932 return $this; 933 } 934 935 /** 936 * Set text at the end of the display. 937 * 938 * @param string $msg Complete text of message to display 939 * 940 * @return HTMLForm $this for chaining calls (since 1.20) 941 */ 942 public function setPostText( $msg ) { 943 $this->mPost = $msg; 944 945 return $this; 946 } 947 948 /** 949 * Add a hidden field to the output 950 * 951 * @param string $name Field name. This will be used exactly as entered 952 * @param mixed $value Field value 953 * @param array $attribs 954 * 955 * @return HTMLForm $this for chaining calls (since 1.20) 956 */ 957 public function addHiddenField( $name, $value, array $attribs = [] ) { 958 $attribs += [ 'name' => $name ]; 959 $this->mHiddenFields[] = [ $value, $attribs ]; 960 961 return $this; 962 } 963 964 /** 965 * Add an array of hidden fields to the output 966 * 967 * @since 1.22 968 * 969 * @param array $fields Associative array of fields to add; 970 * mapping names to their values 971 * 972 * @return HTMLForm $this for chaining calls 973 */ 974 public function addHiddenFields( array $fields ) { 975 foreach ( $fields as $name => $value ) { 976 $this->mHiddenFields[] = [ $value, [ 'name' => $name ] ]; 977 } 978 979 return $this; 980 } 981 982 /** 983 * Add a button to the form 984 * 985 * @since 1.27 takes an array as shown. Earlier versions accepted 986 * 'name', 'value', 'id', and 'attribs' as separate parameters in that 987 * order. 988 * @param array $data Data to define the button: 989 * - name: (string) Button name. 990 * - value: (string) Button value. 991 * - label-message: (string|string[]|MessageSpecifier, optional) Button label 992 * message key to use instead of 'value'. Overrides 'label' and 'label-raw'. 993 * - label: (string, optional) Button label text to use instead of 994 * 'value'. Overrides 'label-raw'. 995 * - label-raw: (string, optional) Button label HTML to use instead of 996 * 'value'. 997 * - id: (string, optional) DOM id for the button. 998 * - attribs: (array, optional) Additional HTML attributes. 999 * - flags: (string|string[], optional) OOUI flags. 1000 * - framed: (boolean=true, optional) OOUI framed attribute. 1001 * @codingStandardsIgnoreStart 1002 * @phan-param array{name:string,value:string,label-message?:string|string[]|MessageSpecifier,label?:string,label-raw?:string,id?:string,attribs?:array,flags?:string|string[],framed?:bool} $data 1003 * @codingStandardsIgnoreEnd 1004 * @return HTMLForm $this for chaining calls (since 1.20) 1005 */ 1006 public function addButton( $data ) { 1007 if ( !is_array( $data ) ) { 1008 $args = func_get_args(); 1009 if ( count( $args ) < 2 || count( $args ) > 4 ) { 1010 throw new InvalidArgumentException( 1011 'Incorrect number of arguments for deprecated calling style' 1012 ); 1013 } 1014 $data = [ 1015 'name' => $args[0], 1016 'value' => $args[1], 1017 'id' => $args[2] ?? null, 1018 'attribs' => $args[3] ?? null, 1019 ]; 1020 } else { 1021 if ( !isset( $data['name'] ) ) { 1022 throw new InvalidArgumentException( 'A name is required' ); 1023 } 1024 if ( !isset( $data['value'] ) ) { 1025 throw new InvalidArgumentException( 'A value is required' ); 1026 } 1027 } 1028 $this->mButtons[] = $data + [ 1029 'id' => null, 1030 'attribs' => null, 1031 'flags' => null, 1032 'framed' => true, 1033 ]; 1034 1035 return $this; 1036 } 1037 1038 /** 1039 * Set the salt for the edit token. 1040 * 1041 * Only useful when the method is "post". 1042 * 1043 * @since 1.24 1044 * @param string|array $salt Salt to use 1045 * @return HTMLForm $this For chaining calls 1046 */ 1047 public function setTokenSalt( $salt ) { 1048 $this->mTokenSalt = $salt; 1049 1050 return $this; 1051 } 1052 1053 /** 1054 * Display the form (sending to the context's OutputPage object), with an 1055 * appropriate error message or stack of messages, and any validation errors, etc. 1056 * 1057 * @warning You should call prepareForm() before calling this function. 1058 * Moreover, when doing method chaining this should be the very last method 1059 * call just after prepareForm(). 1060 * 1061 * @stable to override 1062 * 1063 * @param bool|string|array|Status $submitResult Output from HTMLForm::trySubmit() 1064 * 1065 * @return void Nothing, should be last call 1066 */ 1067 public function displayForm( $submitResult ) { 1068 $this->getOutput()->addHTML( $this->getHTML( $submitResult ) ); 1069 } 1070 1071 /** 1072 * Returns the raw HTML generated by the form 1073 * 1074 * @stable to override 1075 * 1076 * @param bool|string|array|Status $submitResult Output from HTMLForm::trySubmit() 1077 * 1078 * @return string HTML 1079 * @return-taint escaped 1080 */ 1081 public function getHTML( $submitResult ) { 1082 # For good measure (it is the default) 1083 $this->getOutput()->preventClickjacking(); 1084 $this->getOutput()->addModules( 'mediawiki.htmlform' ); 1085 $this->getOutput()->addModuleStyles( 'mediawiki.htmlform.styles' ); 1086 1087 $html = '' 1088 . $this->getErrorsOrWarnings( $submitResult, 'error' ) 1089 . $this->getErrorsOrWarnings( $submitResult, 'warning' ) 1090 . $this->getHeaderText() 1091 . $this->getBody() 1092 . $this->getHiddenFields() 1093 . $this->getButtons() 1094 . $this->getFooterText(); 1095 1096 $html = $this->wrapForm( $html ); 1097 1098 return '' . $this->mPre . $html . $this->mPost; 1099 } 1100 1101 /** 1102 * Enable collapsible mode, and set whether the form is collapsed by default. 1103 * 1104 * @since 1.34 1105 * @param bool $collapsedByDefault Whether the form is collapsed by default (optional). 1106 * @return HTMLForm $this for chaining calls 1107 */ 1108 public function setCollapsibleOptions( $collapsedByDefault = false ) { 1109 $this->mCollapsible = true; 1110 $this->mCollapsed = $collapsedByDefault; 1111 return $this; 1112 } 1113 1114 /** 1115 * Get HTML attributes for the `<form>` tag. 1116 * @stable to override 1117 * @return array 1118 */ 1119 protected function getFormAttributes() { 1120 # Use multipart/form-data 1121 $encType = $this->mUseMultipart 1122 ? 'multipart/form-data' 1123 : 'application/x-www-form-urlencoded'; 1124 # Attributes 1125 $attribs = [ 1126 'class' => 'mw-htmlform', 1127 'action' => $this->getAction(), 1128 'method' => $this->getMethod(), 1129 'enctype' => $encType, 1130 ]; 1131 if ( $this->mId ) { 1132 $attribs['id'] = $this->mId; 1133 } 1134 if ( is_string( $this->mAutocomplete ) ) { 1135 $attribs['autocomplete'] = $this->mAutocomplete; 1136 } 1137 if ( $this->mName ) { 1138 $attribs['name'] = $this->mName; 1139 } 1140 if ( $this->needsJSForHtml5FormValidation() ) { 1141 $attribs['novalidate'] = true; 1142 } 1143 return $attribs; 1144 } 1145 1146 /** 1147 * Wrap the form innards in an actual "<form>" element 1148 * @stable to override 1149 * 1150 * @param string $html HTML contents to wrap. 1151 * 1152 * @return string Wrapped HTML. 1153 */ 1154 public function wrapForm( $html ) { 1155 # Include a <fieldset> wrapper for style, if requested. 1156 if ( $this->mWrapperLegend !== false ) { 1157 $legend = is_string( $this->mWrapperLegend ) ? $this->mWrapperLegend : false; 1158 $html = Xml::fieldset( $legend, $html, $this->mWrapperAttributes ); 1159 } 1160 1161 return Html::rawElement( 1162 'form', 1163 $this->getFormAttributes(), 1164 $html 1165 ); 1166 } 1167 1168 /** 1169 * Get the hidden fields that should go inside the form. 1170 * @return string HTML. 1171 */ 1172 public function getHiddenFields() { 1173 $html = ''; 1174 if ( $this->mFormIdentifier !== null ) { 1175 $html .= Html::hidden( 1176 'wpFormIdentifier', 1177 $this->mFormIdentifier 1178 ) . "\n"; 1179 } 1180 if ( $this->getMethod() === 'post' ) { 1181 $html .= Html::hidden( 1182 'wpEditToken', 1183 $this->getUser()->getEditToken( $this->mTokenSalt ), 1184 [ 'id' => 'wpEditToken' ] 1185 ) . "\n"; 1186 $html .= Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) . "\n"; 1187 } 1188 1189 $articlePath = $this->getConfig()->get( 'ArticlePath' ); 1190 if ( strpos( $articlePath, '?' ) !== false && $this->getMethod() === 'get' ) { 1191 $html .= Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) . "\n"; 1192 } 1193 1194 foreach ( $this->mHiddenFields as $data ) { 1195 list( $value, $attribs ) = $data; 1196 $html .= Html::hidden( $attribs['name'], $value, $attribs ) . "\n"; 1197 } 1198 1199 return $html; 1200 } 1201 1202 /** 1203 * Get the submit and (potentially) reset buttons. 1204 * @stable to override 1205 * @return string HTML. 1206 */ 1207 public function getButtons() { 1208 $buttons = ''; 1209 $useMediaWikiUIEverywhere = $this->getConfig()->get( 'UseMediaWikiUIEverywhere' ); 1210 1211 if ( $this->mShowSubmit ) { 1212 $attribs = []; 1213 1214 if ( isset( $this->mSubmitID ) ) { 1215 $attribs['id'] = $this->mSubmitID; 1216 } 1217 1218 if ( isset( $this->mSubmitName ) ) { 1219 $attribs['name'] = $this->mSubmitName; 1220 } 1221 1222 if ( isset( $this->mSubmitTooltip ) ) { 1223 $attribs += Linker::tooltipAndAccesskeyAttribs( $this->mSubmitTooltip ); 1224 } 1225 1226 $attribs['class'] = [ 'mw-htmlform-submit' ]; 1227 1228 if ( $useMediaWikiUIEverywhere ) { 1229 foreach ( $this->mSubmitFlags as $flag ) { 1230 $attribs['class'][] = 'mw-ui-' . $flag; 1231 } 1232 $attribs['class'][] = 'mw-ui-button'; 1233 } 1234 1235 $buttons .= Xml::submitButton( $this->getSubmitText(), $attribs ) . "\n"; 1236 } 1237 1238 if ( $this->mShowReset ) { 1239 $buttons .= Html::element( 1240 'input', 1241 [ 1242 'type' => 'reset', 1243 'value' => $this->msg( 'htmlform-reset' )->text(), 1244 'class' => $useMediaWikiUIEverywhere ? 'mw-ui-button' : null, 1245 ] 1246 ) . "\n"; 1247 } 1248 1249 if ( $this->mShowCancel ) { 1250 $target = $this->mCancelTarget ?: Title::newMainPage(); 1251 if ( $target instanceof Title ) { 1252 $target = $target->getLocalURL(); 1253 } 1254 $buttons .= Html::element( 1255 'a', 1256 [ 1257 'class' => $useMediaWikiUIEverywhere ? 'mw-ui-button' : null, 1258 'href' => $target, 1259 ], 1260 $this->msg( 'cancel' )->text() 1261 ) . "\n"; 1262 } 1263 1264 foreach ( $this->mButtons as $button ) { 1265 $attrs = [ 1266 'type' => 'submit', 1267 'name' => $button['name'], 1268 'value' => $button['value'] 1269 ]; 1270 1271 if ( isset( $button['label-message'] ) ) { 1272 $label = $this->getMessage( $button['label-message'] )->parse(); 1273 } elseif ( isset( $button['label'] ) ) { 1274 $label = htmlspecialchars( $button['label'] ); 1275 } elseif ( isset( $button['label-raw'] ) ) { 1276 $label = $button['label-raw']; 1277 } else { 1278 $label = htmlspecialchars( $button['value'] ); 1279 } 1280 1281 if ( $button['attribs'] ) { 1282 $attrs += $button['attribs']; 1283 } 1284 1285 if ( isset( $button['id'] ) ) { 1286 $attrs['id'] = $button['id']; 1287 } 1288 1289 if ( $useMediaWikiUIEverywhere ) { 1290 $attrs['class'] = isset( $attrs['class'] ) ? (array)$attrs['class'] : []; 1291 $attrs['class'][] = 'mw-ui-button'; 1292 } 1293 1294 $buttons .= Html::rawElement( 'button', $attrs, $label ) . "\n"; 1295 } 1296 1297 if ( !$buttons ) { 1298 return ''; 1299 } 1300 1301 return Html::rawElement( 'span', 1302 [ 'class' => 'mw-htmlform-submit-buttons' ], "\n$buttons" ) . "\n"; 1303 } 1304 1305 /** 1306 * Get the whole body of the form. 1307 * @stable to override 1308 * @return string 1309 */ 1310 public function getBody() { 1311 return $this->displaySection( $this->mFieldTree, $this->mTableId ); 1312 } 1313 1314 /** 1315 * Returns a formatted list of errors or warnings from the given elements. 1316 * @stable to override 1317 * 1318 * @param string|array|Status $elements The set of errors/warnings to process. 1319 * @param string $elementsType Should warnings or errors be returned. This is meant 1320 * for Status objects, all other valid types are always considered as errors. 1321 * @return string 1322 */ 1323 public function getErrorsOrWarnings( $elements, $elementsType ) { 1324 if ( !in_array( $elementsType, [ 'error', 'warning' ], true ) ) { 1325 throw new DomainException( $elementsType . ' is not a valid type.' ); 1326 } 1327 $elementstr = false; 1328 if ( $elements instanceof Status ) { 1329 list( $errorStatus, $warningStatus ) = $elements->splitByErrorType(); 1330 $status = $elementsType === 'error' ? $errorStatus : $warningStatus; 1331 if ( $status->isGood() ) { 1332 $elementstr = ''; 1333 } else { 1334 $elementstr = $status 1335 ->getMessage() 1336 ->setContext( $this ) 1337 ->setInterfaceMessageFlag( true ) 1338 ->parse(); 1339 } 1340 } elseif ( is_array( $elements ) && $elementsType === 'error' ) { 1341 $elementstr = $this->formatErrors( $elements ); 1342 } elseif ( $elementsType === 'error' ) { 1343 $elementstr = $elements; 1344 } 1345 1346 return $elementstr 1347 ? Html::rawElement( 'div', [ 'class' => $elementsType . 'box' ], $elementstr ) 1348 : ''; 1349 } 1350 1351 /** 1352 * Format a stack of error messages into a single HTML string 1353 * 1354 * @param array $errors Array of message keys/values 1355 * 1356 * @return string HTML, a "<ul>" list of errors 1357 */ 1358 public function formatErrors( $errors ) { 1359 $errorstr = ''; 1360 1361 foreach ( $errors as $error ) { 1362 $errorstr .= Html::rawElement( 1363 'li', 1364 [], 1365 $this->getMessage( $error )->parse() 1366 ); 1367 } 1368 1369 $errorstr = Html::rawElement( 'ul', [], $errorstr ); 1370 1371 return $errorstr; 1372 } 1373 1374 /** 1375 * Set the text for the submit button 1376 * 1377 * @param string $t Plaintext 1378 * 1379 * @return HTMLForm $this for chaining calls (since 1.20) 1380 */ 1381 public function setSubmitText( $t ) { 1382 $this->mSubmitText = $t; 1383 1384 return $this; 1385 } 1386 1387 /** 1388 * Identify that the submit button in the form has a destructive action 1389 * @since 1.24 1390 * 1391 * @return HTMLForm $this for chaining calls (since 1.28) 1392 */ 1393 public function setSubmitDestructive() { 1394 $this->mSubmitFlags = [ 'destructive', 'primary' ]; 1395 1396 return $this; 1397 } 1398 1399 /** 1400 * Set the text for the submit button to a message 1401 * @since 1.19 1402 * 1403 * @param string|Message $msg Message key or Message object 1404 * 1405 * @return HTMLForm $this for chaining calls (since 1.20) 1406 */ 1407 public function setSubmitTextMsg( $msg ) { 1408 if ( !$msg instanceof Message ) { 1409 $msg = $this->msg( $msg ); 1410 } 1411 $this->setSubmitText( $msg->text() ); 1412 1413 return $this; 1414 } 1415 1416 /** 1417 * Get the text for the submit button, either customised or a default. 1418 * @return string 1419 */ 1420 public function getSubmitText() { 1421 return $this->mSubmitText ?: $this->msg( 'htmlform-submit' )->text(); 1422 } 1423 1424 /** 1425 * @param string $name Submit button name 1426 * 1427 * @return HTMLForm $this for chaining calls (since 1.20) 1428 */ 1429 public function setSubmitName( $name ) { 1430 $this->mSubmitName = $name; 1431 1432 return $this; 1433 } 1434 1435 /** 1436 * @param string $name Tooltip for the submit button 1437 * 1438 * @return HTMLForm $this for chaining calls (since 1.20) 1439 */ 1440 public function setSubmitTooltip( $name ) { 1441 $this->mSubmitTooltip = $name; 1442 1443 return $this; 1444 } 1445 1446 /** 1447 * Set the id for the submit button. 1448 * 1449 * @param string $t 1450 * 1451 * @todo FIXME: Integrity of $t is *not* validated 1452 * @return HTMLForm $this for chaining calls (since 1.20) 1453 */ 1454 public function setSubmitID( $t ) { 1455 $this->mSubmitID = $t; 1456 1457 return $this; 1458 } 1459 1460 /** 1461 * Set an internal identifier for this form. It will be submitted as a hidden form field, allowing 1462 * HTMLForm to determine whether the form was submitted (or merely viewed). Setting this serves 1463 * two purposes: 1464 * 1465 * - If you use two or more forms on one page, it allows HTMLForm to identify which of the forms 1466 * was submitted, and not attempt to validate the other ones. 1467 * - If you use checkbox or multiselect fields inside a form using the GET method, it allows 1468 * HTMLForm to distinguish between the initial page view and a form submission with all 1469 * checkboxes or select options unchecked. 1470 * 1471 * @since 1.28 1472 * @param string $ident 1473 * @return $this 1474 */ 1475 public function setFormIdentifier( $ident ) { 1476 $this->mFormIdentifier = $ident; 1477 1478 return $this; 1479 } 1480 1481 /** 1482 * Stop a default submit button being shown for this form. This implies that an 1483 * alternate submit method must be provided manually. 1484 * 1485 * @since 1.22 1486 * 1487 * @param bool $suppressSubmit Set to false to re-enable the button again 1488 * 1489 * @return HTMLForm $this for chaining calls 1490 */ 1491 public function suppressDefaultSubmit( $suppressSubmit = true ) { 1492 $this->mShowSubmit = !$suppressSubmit; 1493 1494 return $this; 1495 } 1496 1497 /** 1498 * Show a cancel button (or prevent it). The button is not shown by default. 1499 * @param bool $show 1500 * @return HTMLForm $this for chaining calls 1501 * @since 1.27 1502 */ 1503 public function showCancel( $show = true ) { 1504 $this->mShowCancel = $show; 1505 return $this; 1506 } 1507 1508 /** 1509 * Sets the target where the user is redirected to after clicking cancel. 1510 * @param Title|string $target Target as a Title object or an URL 1511 * @return HTMLForm $this for chaining calls 1512 * @since 1.27 1513 */ 1514 public function setCancelTarget( $target ) { 1515 $this->mCancelTarget = $target; 1516 return $this; 1517 } 1518 1519 /** 1520 * Set the id of the \<table\> or outermost \<div\> element. 1521 * 1522 * @since 1.22 1523 * 1524 * @param string $id New value of the id attribute, or "" to remove 1525 * 1526 * @return HTMLForm $this for chaining calls 1527 */ 1528 public function setTableId( $id ) { 1529 $this->mTableId = $id; 1530 1531 return $this; 1532 } 1533 1534 /** 1535 * @param string $id DOM id for the form 1536 * 1537 * @return HTMLForm $this for chaining calls (since 1.20) 1538 */ 1539 public function setId( $id ) { 1540 $this->mId = $id; 1541 1542 return $this; 1543 } 1544 1545 /** 1546 * @param string $name 'name' attribute for the form 1547 * @return HTMLForm $this for chaining calls 1548 */ 1549 public function setName( $name ) { 1550 $this->mName = $name; 1551 1552 return $this; 1553 } 1554 1555 /** 1556 * Prompt the whole form to be wrapped in a "<fieldset>", with 1557 * this text as its "<legend>" element. 1558 * 1559 * @param string|bool $legend If false, no wrapper or legend will be displayed. 1560 * If true, a wrapper will be displayed, but no legend. 1561 * If a string, a wrapper will be displayed with that string as a legend. 1562 * The string will be escaped before being output (this doesn't support HTML). 1563 * 1564 * @return HTMLForm $this for chaining calls (since 1.20) 1565 */ 1566 public function setWrapperLegend( $legend ) { 1567 $this->mWrapperLegend = $legend; 1568 1569 return $this; 1570 } 1571 1572 /** 1573 * For internal use only. Use is discouraged, and should only be used where 1574 * support for gadgets/user scripts is warranted. 1575 * @param array $attributes 1576 * @internal 1577 * @return HTMLForm $this for chaining calls 1578 */ 1579 public function setWrapperAttributes( $attributes ) { 1580 $this->mWrapperAttributes = $attributes; 1581 1582 return $this; 1583 } 1584 1585 /** 1586 * Prompt the whole form to be wrapped in a "<fieldset>", with 1587 * this message as its "<legend>" element. 1588 * @since 1.19 1589 * 1590 * @param string|Message $msg Message key or Message object 1591 * 1592 * @return HTMLForm $this for chaining calls (since 1.20) 1593 */ 1594 public function setWrapperLegendMsg( $msg ) { 1595 if ( !$msg instanceof Message ) { 1596 $msg = $this->msg( $msg ); 1597 } 1598 $this->setWrapperLegend( $msg->text() ); 1599 1600 return $this; 1601 } 1602 1603 /** 1604 * Set the prefix for various default messages 1605 * @todo Currently only used for the "<fieldset>" legend on forms 1606 * with multiple sections; should be used elsewhere? 1607 * 1608 * @param string $p 1609 * 1610 * @return HTMLForm $this for chaining calls (since 1.20) 1611 */ 1612 public function setMessagePrefix( $p ) { 1613 $this->mMessagePrefix = $p; 1614 1615 return $this; 1616 } 1617 1618 /** 1619 * Set the title for form submission 1620 * 1621 * @param Title $t Title of page the form is on/should be posted to 1622 * 1623 * @return HTMLForm $this for chaining calls (since 1.20) 1624 */ 1625 public function setTitle( $t ) { 1626 $this->mTitle = $t; 1627 1628 return $this; 1629 } 1630 1631 /** 1632 * Get the title 1633 * @return Title 1634 */ 1635 public function getTitle() { 1636 return $this->mTitle === false 1637 ? $this->getContext()->getTitle() 1638 : $this->mTitle; 1639 } 1640 1641 /** 1642 * Set the method used to submit the form 1643 * 1644 * @param string $method 1645 * 1646 * @return HTMLForm $this for chaining calls (since 1.20) 1647 */ 1648 public function setMethod( $method = 'post' ) { 1649 $this->mMethod = strtolower( $method ); 1650 1651 return $this; 1652 } 1653 1654 /** 1655 * @return string Always lowercase 1656 */ 1657 public function getMethod() { 1658 return $this->mMethod; 1659 } 1660 1661 /** 1662 * Wraps the given $section into an user-visible fieldset. 1663 * @stable to override 1664 * 1665 * @param string $legend Legend text for the fieldset 1666 * @param string $section The section content in plain Html 1667 * @param array $attributes Additional attributes for the fieldset 1668 * @param bool $isRoot Section is at the root of the tree 1669 * @return string The fieldset's Html 1670 */ 1671 protected function wrapFieldSetSection( $legend, $section, $attributes, $isRoot ) { 1672 return Xml::fieldset( $legend, $section, $attributes ) . "\n"; 1673 } 1674 1675 /** 1676 * @todo Document 1677 * @stable to override 1678 * 1679 * @param array[]|HTMLFormField[] $fields Array of fields (either arrays or 1680 * objects). 1681 * @param string $sectionName ID attribute of the "<table>" tag for this 1682 * section, ignored if empty. 1683 * @param string $fieldsetIDPrefix ID prefix for the "<fieldset>" tag of 1684 * each subsection, ignored if empty. 1685 * @param bool &$hasUserVisibleFields Whether the section had user-visible fields. 1686 * @throws LogicException When called on uninitialized field data, e.g. When 1687 * HTMLForm::displayForm was called without calling HTMLForm::prepareForm 1688 * first. 1689 * 1690 * @return string 1691 */ 1692 public function displaySection( $fields, 1693 $sectionName = '', 1694 $fieldsetIDPrefix = '', 1695 &$hasUserVisibleFields = false 1696 ) { 1697 if ( $this->mFieldData === null ) { 1698 throw new LogicException( 'HTMLForm::displaySection() called on uninitialized field data. ' 1699 . 'You probably called displayForm() without calling prepareForm() first.' ); 1700 } 1701 1702 $displayFormat = $this->getDisplayFormat(); 1703 1704 $html = []; 1705 $subsectionHtml = ''; 1706 $hasLabel = false; 1707 1708 // Conveniently, PHP method names are case-insensitive. 1709 // For grep: this can call getDiv, getRaw, getInline, getVForm, getOOUI 1710 $getFieldHtmlMethod = $displayFormat === 'table' ? 'getTableRow' : ( 'get' . $displayFormat ); 1711 1712 foreach ( $fields as $key => $value ) { 1713 if ( $value instanceof HTMLFormField ) { 1714 $v = array_key_exists( $key, $this->mFieldData ) 1715 ? $this->mFieldData[$key] 1716 : $value->getDefault(); 1717 1718 $retval = $value->$getFieldHtmlMethod( $v ); 1719 1720 // check, if the form field should be added to 1721 // the output. 1722 if ( $value->hasVisibleOutput() ) { 1723 $html[] = $retval; 1724 1725 $labelValue = trim( $value->getLabel() ); 1726 if ( $labelValue !== "\u{00A0}" && $labelValue !== ' ' && $labelValue !== '' ) { 1727 $hasLabel = true; 1728 } 1729 1730 $hasUserVisibleFields = true; 1731 } 1732 } elseif ( is_array( $value ) ) { 1733 $subsectionHasVisibleFields = false; 1734 $section = 1735 $this->displaySection( $value, 1736 "mw-htmlform-$key", 1737 "$fieldsetIDPrefix$key-", 1738 $subsectionHasVisibleFields ); 1739 $legend = null; 1740 1741 if ( $subsectionHasVisibleFields === true ) { 1742 // Display the section with various niceties. 1743 $hasUserVisibleFields = true; 1744 1745 $legend = $this->getLegend( $key ); 1746 1747 $section = $this->getHeaderText( $key ) . 1748 $section . 1749 $this->getFooterText( $key ); 1750 1751 $attributes = []; 1752 if ( $fieldsetIDPrefix ) { 1753 $attributes['id'] = Sanitizer::escapeIdForAttribute( "$fieldsetIDPrefix$key" ); 1754 } 1755 $subsectionHtml .= $this->wrapFieldSetSection( 1756 $legend, $section, $attributes, $fields === $this->mFieldTree 1757 ); 1758 } else { 1759 // Just return the inputs, nothing fancy. 1760 $subsectionHtml .= $section; 1761 } 1762 } 1763 } 1764 1765 $html = $this->formatSection( $html, $sectionName, $hasLabel ); 1766 1767 if ( $subsectionHtml ) { 1768 if ( $this->mSubSectionBeforeFields ) { 1769 return $subsectionHtml . "\n" . $html; 1770 } else { 1771 return $html . "\n" . $subsectionHtml; 1772 } 1773 } else { 1774 return $html; 1775 } 1776 } 1777 1778 /** 1779 * Put a form section together from the individual fields' HTML, merging it and wrapping. 1780 * @stable to override 1781 * @param array $fieldsHtml 1782 * @param string $sectionName 1783 * @param bool $anyFieldHasLabel 1784 * @return string HTML 1785 */ 1786 protected function formatSection( array $fieldsHtml, $sectionName, $anyFieldHasLabel ) { 1787 if ( !$fieldsHtml ) { 1788 // Do not generate any wrappers for empty sections. Sections may be empty if they only have 1789 // subsections, but no fields. A legend will still be added in wrapFieldSetSection(). 1790 return ''; 1791 } 1792 1793 $displayFormat = $this->getDisplayFormat(); 1794 $html = implode( '', $fieldsHtml ); 1795 1796 if ( $displayFormat === 'raw' ) { 1797 return $html; 1798 } 1799 1800 $classes = []; 1801 1802 if ( !$anyFieldHasLabel ) { // Avoid strange spacing when no labels exist 1803 $classes[] = 'mw-htmlform-nolabel'; 1804 } 1805 1806 $attribs = [ 1807 'class' => implode( ' ', $classes ), 1808 ]; 1809 1810 if ( $sectionName ) { 1811 $attribs['id'] = Sanitizer::escapeIdForAttribute( $sectionName ); 1812 } 1813 1814 if ( $displayFormat === 'table' ) { 1815 return Html::rawElement( 'table', 1816 $attribs, 1817 Html::rawElement( 'tbody', [], "\n$html\n" ) ) . "\n"; 1818 } elseif ( $displayFormat === 'inline' ) { 1819 return Html::rawElement( 'span', $attribs, "\n$html\n" ); 1820 } else { 1821 return Html::rawElement( 'div', $attribs, "\n$html\n" ); 1822 } 1823 } 1824 1825 /** 1826 * Construct the form fields from the Descriptor array 1827 */ 1828 public function loadData() { 1829 $fieldData = []; 1830 1831 foreach ( $this->mFlatFields as $fieldname => $field ) { 1832 $request = $this->getRequest(); 1833 if ( $field->skipLoadData( $request ) ) { 1834 continue; 1835 } elseif ( !empty( $field->mParams['disabled'] ) ) { 1836 $fieldData[$fieldname] = $field->getDefault(); 1837 } else { 1838 $fieldData[$fieldname] = $field->loadDataFromRequest( $request ); 1839 } 1840 } 1841 1842 # Filter data. 1843 foreach ( $fieldData as $name => &$value ) { 1844 $field = $this->mFlatFields[$name]; 1845 $value = $field->filter( $value, $this->mFlatFields ); 1846 } 1847 1848 $this->mFieldData = $fieldData; 1849 } 1850 1851 /** 1852 * Stop a reset button being shown for this form 1853 * 1854 * @param bool $suppressReset Set to false to re-enable the button again 1855 * 1856 * @return HTMLForm $this for chaining calls (since 1.20) 1857 */ 1858 public function suppressReset( $suppressReset = true ) { 1859 $this->mShowReset = !$suppressReset; 1860 1861 return $this; 1862 } 1863 1864 /** 1865 * Overload this if you want to apply special filtration routines 1866 * to the form as a whole, after it's submitted but before it's 1867 * processed. 1868 * @stable to override 1869 * 1870 * @param array $data 1871 * 1872 * @return array 1873 */ 1874 public function filterDataForSubmit( $data ) { 1875 return $data; 1876 } 1877 1878 /** 1879 * Get a string to go in the "<legend>" of a section fieldset. 1880 * Override this if you want something more complicated. 1881 * @stable to override 1882 * 1883 * @param string $key 1884 * 1885 * @return string Plain text (not HTML-escaped) 1886 */ 1887 public function getLegend( $key ) { 1888 return $this->msg( $this->mMessagePrefix ? "{$this->mMessagePrefix}-$key" : $key )->text(); 1889 } 1890 1891 /** 1892 * Set the value for the action attribute of the form. 1893 * When set to false (which is the default state), the set title is used. 1894 * 1895 * @since 1.19 1896 * 1897 * @param string|bool $action 1898 * 1899 * @return HTMLForm $this for chaining calls (since 1.20) 1900 */ 1901 public function setAction( $action ) { 1902 $this->mAction = $action; 1903 1904 return $this; 1905 } 1906 1907 /** 1908 * Get the value for the action attribute of the form. 1909 * 1910 * @since 1.22 1911 * 1912 * @return string 1913 */ 1914 public function getAction() { 1915 // If an action is alredy provided, return it 1916 if ( $this->mAction !== false ) { 1917 return $this->mAction; 1918 } 1919 1920 $articlePath = $this->getConfig()->get( 'ArticlePath' ); 1921 // Check whether we are in GET mode and the ArticlePath contains a "?" 1922 // meaning that getLocalURL() would return something like "index.php?title=...". 1923 // As browser remove the query string before submitting GET forms, 1924 // it means that the title would be lost. In such case use wfScript() instead 1925 // and put title in an hidden field (see getHiddenFields()). 1926 if ( strpos( $articlePath, '?' ) !== false && $this->getMethod() === 'get' ) { 1927 return wfScript(); 1928 } 1929 1930 return $this->getTitle()->getLocalURL(); 1931 } 1932 1933 /** 1934 * Set the value for the autocomplete attribute of the form. A typical value is "off". 1935 * When set to null (which is the default state), the attribute get not set. 1936 * 1937 * @since 1.27 1938 * 1939 * @param string|null $autocomplete 1940 * 1941 * @return HTMLForm $this for chaining calls 1942 */ 1943 public function setAutocomplete( $autocomplete ) { 1944 $this->mAutocomplete = $autocomplete; 1945 1946 return $this; 1947 } 1948 1949 /** 1950 * Turns a *-message parameter (which could be a MessageSpecifier, or a message name, or a 1951 * name + parameters array) into a Message. 1952 * @param mixed $value 1953 * @return Message 1954 */ 1955 protected function getMessage( $value ) { 1956 return Message::newFromSpecifier( $value )->setContext( $this ); 1957 } 1958 1959 /** 1960 * Whether this form, with its current fields, requires the user agent to have JavaScript enabled 1961 * for the client-side HTML5 form validation to work correctly. If this function returns true, a 1962 * 'novalidate' attribute will be added on the `<form>` element. It will be removed if the user 1963 * agent has JavaScript support, in htmlform.js. 1964 * 1965 * @return bool 1966 * @since 1.29 1967 */ 1968 public function needsJSForHtml5FormValidation() { 1969 foreach ( $this->mFlatFields as $fieldname => $field ) { 1970 if ( $field->needsJSForHtml5FormValidation() ) { 1971 return true; 1972 } 1973 } 1974 return false; 1975 } 1976} 1977