diff options
Diffstat (limited to 'includes/htmlform')
24 files changed, 4149 insertions, 0 deletions
diff --git a/includes/htmlform/HTMLApiField.php b/includes/htmlform/HTMLApiField.php new file mode 100644 index 00000000..f988e622 --- /dev/null +++ b/includes/htmlform/HTMLApiField.php @@ -0,0 +1,19 @@ +<?php + +class HTMLApiField extends HTMLFormField { + public function getTableRow( $value ) { + return ''; + } + + public function getDiv( $value ) { + return $this->getTableRow( $value ); + } + + public function getRaw( $value ) { + return $this->getTableRow( $value ); + } + + public function getInputHTML( $value ) { + return ''; + } +} diff --git a/includes/htmlform/HTMLAutoCompleteSelectField.php b/includes/htmlform/HTMLAutoCompleteSelectField.php new file mode 100644 index 00000000..49053628 --- /dev/null +++ b/includes/htmlform/HTMLAutoCompleteSelectField.php @@ -0,0 +1,165 @@ +<?php + +/** + * Text field for selecting a value from a large list of possible values, with + * auto-completion and optionally with a select dropdown for selecting common + * options. + * + * If one of 'options-messages', 'options', or 'options-message' is provided + * and non-empty, the select dropdown will be shown. An 'other' key will be + * appended using message 'htmlform-selectorother-other' if not already + * present. + * + * Besides the parameters recognized by HTMLTextField, the following are + * recognized: + * options-messages - As for HTMLSelectField + * options - As for HTMLSelectField + * options-message - As for HTMLSelectField + * autocomplete - Associative array mapping display text to values. + * autocomplete-messages - Like autocomplete, but keys are message names. + * require-match - Boolean, if true the value must be in the options or the + * autocomplete. + * other-message - Message to use instead of htmlform-selectorother-other for + * the 'other' message. + * other - Raw text to use for the 'other' message + * + */ +class HTMLAutoCompleteSelectField extends HTMLTextField { + protected $autocomplete = array(); + + function __construct( $params ) { + $params += array( + 'require-match' => false, + ); + + parent::__construct( $params ); + + if ( array_key_exists( 'autocomplete-messages', $this->mParams ) ) { + foreach ( $this->mParams['autocomplete-messages'] as $key => $value ) { + $key = $this->msg( $key )->plain(); + $this->autocomplete[$key] = strval( $value ); + } + } elseif ( array_key_exists( 'autocomplete', $this->mParams ) ) { + foreach ( $this->mParams['autocomplete'] as $key => $value ) { + $this->autocomplete[$key] = strval( $value ); + } + } + if ( !is_array( $this->autocomplete ) || !$this->autocomplete ) { + throw new MWException( 'HTMLAutoCompleteSelectField called without any autocompletions' ); + } + + $this->getOptions(); + if ( $this->mOptions && !in_array( 'other', $this->mOptions, true ) ) { + if ( isset( $params['other-message'] ) ) { + $msg = wfMessage( $params['other-message'] )->text(); + } elseif ( isset( $params['other'] ) ) { + $msg = $params['other']; + } else { + $msg = wfMessage( 'htmlform-selectorother-other' )->text(); + } + $this->mOptions[$msg] = 'other'; + } + } + + function loadDataFromRequest( $request ) { + if ( $request->getCheck( $this->mName ) ) { + $val = $request->getText( $this->mName . '-select', 'other' ); + + if ( $val === 'other' ) { + $val = $request->getText( $this->mName ); + if ( isset( $this->autocomplete[$val] ) ) { + $val = $this->autocomplete[$val]; + } + } + + return $val; + } else { + return $this->getDefault(); + } + } + + function validate( $value, $alldata ) { + $p = parent::validate( $value, $alldata ); + + if ( $p !== true ) { + return $p; + } + + $validOptions = HTMLFormField::flattenOptions( $this->getOptions() ); + + if ( in_array( strval( $value ), $validOptions, true ) ) { + return true; + } elseif ( in_array( strval( $value ), $this->autocomplete, true ) ) { + return true; + } elseif ( $this->mParams['require-match'] ) { + return $this->msg( 'htmlform-select-badoption' )->parse(); + } + + return true; + } + + function getAttributes( array $list ) { + $attribs = array( + 'type' => 'text', + 'data-autocomplete' => FormatJson::encode( array_keys( $this->autocomplete ) ), + ) + parent::getAttributes( $list ); + + if ( $this->getOptions() ) { + $attribs['data-hide-if'] = FormatJson::encode( + array( '!==', $this->mName . '-select', 'other' ) + ); + } + + return $attribs; + } + + function getInputHTML( $value ) { + $oldClass = $this->mClass; + $this->mClass = (array)$this->mClass; + + $valInSelect = false; + $ret = ''; + + if ( $this->getOptions() ) { + if ( $value !== false ) { + $value = strval( $value ); + $valInSelect = in_array( + $value, HTMLFormField::flattenOptions( $this->getOptions() ), true + ); + } + + $selected = $valInSelect ? $value : 'other'; + $select = new XmlSelect( $this->mName . '-select', $this->mID . '-select', $selected ); + $select->addOptions( $this->getOptions() ); + $select->setAttribute( 'class', 'mw-htmlform-select-or-other' ); + + if ( !empty( $this->mParams['disabled'] ) ) { + $select->setAttribute( 'disabled', 'disabled' ); + } + + if ( isset( $this->mParams['tabindex'] ) ) { + $select->setAttribute( 'tabindex', $this->mParams['tabindex'] ); + } + + $ret = $select->getHTML() . "<br />\n"; + + $this->mClass[] = 'mw-htmlform-hide-if'; + } + + if ( $valInSelect ) { + $value = ''; + } else { + $key = array_search( strval( $value ), $this->autocomplete, true ); + if ( $key !== false ) { + $value = $key; + } + } + + $this->mClass[] = 'mw-htmlform-autocomplete'; + $ret .= parent::getInputHTML( $valInSelect ? '' : $value ); + $this->mClass = $oldClass; + + return $ret; + } + +} diff --git a/includes/htmlform/HTMLButtonField.php b/includes/htmlform/HTMLButtonField.php new file mode 100644 index 00000000..09c0ad97 --- /dev/null +++ b/includes/htmlform/HTMLButtonField.php @@ -0,0 +1,42 @@ +<?php + +/** + * Adds a generic button inline to the form. Does not do anything, you must add + * click handling code in JavaScript. Use a HTMLSubmitField if you merely + * wish to add a submit button to a form. + * + * @since 1.22 + */ +class HTMLButtonField extends HTMLFormField { + protected $buttonType = 'button'; + + public function __construct( $info ) { + $info['nodata'] = true; + parent::__construct( $info ); + } + + public function getInputHTML( $value ) { + $attr = array( + 'class' => 'mw-htmlform-submit ' . $this->mClass, + 'id' => $this->mID, + ) + $this->getAttributes( array( 'disabled', 'tabindex' ) ); + + return Html::input( $this->mName, $value, $this->buttonType, $attr ); + } + + protected function needsLabel() { + return false; + } + + /** + * Button cannot be invalid + * + * @param string $value + * @param array $alldata + * + * @return bool + */ + public function validate( $value, $alldata ) { + return true; + } +} diff --git a/includes/htmlform/HTMLCheckField.php b/includes/htmlform/HTMLCheckField.php new file mode 100644 index 00000000..5f70362a --- /dev/null +++ b/includes/htmlform/HTMLCheckField.php @@ -0,0 +1,91 @@ +<?php + +/** + * A checkbox field + */ +class HTMLCheckField extends HTMLFormField { + function getInputHTML( $value ) { + global $wgUseMediaWikiUIEverywhere; + + if ( !empty( $this->mParams['invert'] ) ) { + $value = !$value; + } + + $attr = $this->getTooltipAndAccessKey(); + $attr['id'] = $this->mID; + + $attr += $this->getAttributes( array( 'disabled', 'tabindex' ) ); + + if ( $this->mClass !== '' ) { + $attr['class'] = $this->mClass; + } + + if ( $this->mParent->isVForm() ) { + // Nest checkbox inside label. + return Html::rawElement( 'label', + array( + 'class' => 'mw-ui-checkbox-label' + ), + Xml::check( $this->mName, $value, $attr ) . $this->mLabel ); + } else { + $chkLabel = Xml::check( $this->mName, $value, $attr ) + . ' ' + . Html::rawElement( 'label', array( 'for' => $this->mID ), $this->mLabel ); + + if ( $wgUseMediaWikiUIEverywhere ) { + $chkLabel = Html::rawElement( + 'div', + array( 'class' => 'mw-ui-checkbox' ), + $chkLabel + ); + } + + return $chkLabel; + } + } + + /** + * For a checkbox, the label goes on the right hand side, and is + * added in getInputHTML(), rather than HTMLFormField::getRow() + * @return string + */ + function getLabel() { + return ' '; + } + + /** + * checkboxes don't need a label. + * @return bool + */ + protected function needsLabel() { + return false; + } + + /** + * @param WebRequest $request + * + * @return string + */ + function loadDataFromRequest( $request ) { + $invert = false; + if ( isset( $this->mParams['invert'] ) && $this->mParams['invert'] ) { + $invert = true; + } + + // GetCheck won't work like we want for checks. + // Fetch the value in either one of the two following case: + // - we have a valid token (form got posted or GET forged by the user) + // - checkbox name has a value (false or true), ie is not null + if ( $request->getCheck( 'wpEditToken' ) || $request->getVal( $this->mName ) !== null ) { + // XOR has the following truth table, which is what we want + // INVERT VALUE | OUTPUT + // true true | false + // false true | true + // false false | false + // true false | true + return $request->getBool( $this->mName ) xor $invert; + } else { + return $this->getDefault(); + } + } +} diff --git a/includes/htmlform/HTMLCheckMatrix.php b/includes/htmlform/HTMLCheckMatrix.php new file mode 100644 index 00000000..6c538fdd --- /dev/null +++ b/includes/htmlform/HTMLCheckMatrix.php @@ -0,0 +1,250 @@ +<?php + +/** + * A checkbox matrix + * Operates similarly to HTMLMultiSelectField, but instead of using an array of + * options, uses an array of rows and an array of columns to dynamically + * construct a matrix of options. The tags used to identify a particular cell + * are of the form "columnName-rowName" + * + * Options: + * - columns + * - Required list of columns in the matrix. + * - rows + * - Required list of rows in the matrix. + * - force-options-on + * - Accepts array of column-row tags to be displayed as enabled but unavailable to change + * - force-options-off + * - Accepts array of column-row tags to be displayed as disabled but unavailable to change. + * - tooltips + * - Optional array mapping row label to tooltip content + * - tooltip-class + * - Optional CSS class used on tooltip container span. Defaults to mw-icon-question. + */ +class HTMLCheckMatrix extends HTMLFormField implements HTMLNestedFilterable { + static private $requiredParams = array( + // Required by underlying HTMLFormField + 'fieldname', + // Required by HTMLCheckMatrix + 'rows', + 'columns' + ); + + public function __construct( $params ) { + $missing = array_diff( self::$requiredParams, array_keys( $params ) ); + if ( $missing ) { + throw new HTMLFormFieldRequiredOptionsException( $this, $missing ); + } + parent::__construct( $params ); + } + + function validate( $value, $alldata ) { + $rows = $this->mParams['rows']; + $columns = $this->mParams['columns']; + + // Make sure user-defined validation callback is run + $p = parent::validate( $value, $alldata ); + if ( $p !== true ) { + return $p; + } + + // Make sure submitted value is an array + if ( !is_array( $value ) ) { + return false; + } + + // If all options are valid, array_intersect of the valid options + // and the provided options will return the provided options. + $validOptions = array(); + foreach ( $rows as $rowTag ) { + foreach ( $columns as $columnTag ) { + $validOptions[] = $columnTag . '-' . $rowTag; + } + } + $validValues = array_intersect( $value, $validOptions ); + if ( count( $validValues ) == count( $value ) ) { + return true; + } else { + return $this->msg( 'htmlform-select-badoption' )->parse(); + } + } + + /** + * Build a table containing a matrix of checkbox options. + * The value of each option is a combination of the row tag and column tag. + * mParams['rows'] is an array with row labels as keys and row tags as values. + * mParams['columns'] is an array with column labels as keys and column tags as values. + * + * @param array $value Array of the options that should be checked + * + * @return string + */ + function getInputHTML( $value ) { + $html = ''; + $tableContents = ''; + $rows = $this->mParams['rows']; + $columns = $this->mParams['columns']; + + $attribs = $this->getAttributes( array( 'disabled', 'tabindex' ) ); + + // Build the column headers + $headerContents = Html::rawElement( 'td', array(), ' ' ); + foreach ( $columns as $columnLabel => $columnTag ) { + $headerContents .= Html::rawElement( 'td', array(), $columnLabel ); + } + $tableContents .= Html::rawElement( 'tr', array(), "\n$headerContents\n" ); + + $tooltipClass = 'mw-icon-question'; + if ( isset( $this->mParams['tooltip-class'] ) ) { + $tooltipClass = $this->mParams['tooltip-class']; + } + + // Build the options matrix + foreach ( $rows as $rowLabel => $rowTag ) { + // Append tooltip if configured + if ( isset( $this->mParams['tooltips'][$rowLabel] ) ) { + $tooltipAttribs = array( + 'class' => "mw-htmlform-tooltip $tooltipClass", + 'title' => $this->mParams['tooltips'][$rowLabel], + ); + $rowLabel .= ' ' . Html::element( 'span', $tooltipAttribs, '' ); + } + $rowContents = Html::rawElement( 'td', array(), $rowLabel ); + foreach ( $columns as $columnTag ) { + $thisTag = "$columnTag-$rowTag"; + // Construct the checkbox + $thisId = "{$this->mID}-$thisTag"; + $thisAttribs = array( + 'id' => $thisId, + 'value' => $thisTag, + ); + $checked = in_array( $thisTag, (array)$value, true ); + if ( $this->isTagForcedOff( $thisTag ) ) { + $checked = false; + $thisAttribs['disabled'] = 1; + } elseif ( $this->isTagForcedOn( $thisTag ) ) { + $checked = true; + $thisAttribs['disabled'] = 1; + } + $chkBox = Xml::check( "{$this->mName}[]", $checked, $attribs + $thisAttribs ); + if ( $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) { + $chkBox = Html::openElement( 'div', array( 'class' => 'mw-ui-checkbox' ) ) . + $chkBox . + Html::element( 'label', array( 'for' => $thisId ) ) . + Html::closeElement( 'div' ); + } + $rowContents .= Html::rawElement( + 'td', + array(), + $chkBox + ); + } + $tableContents .= Html::rawElement( 'tr', array(), "\n$rowContents\n" ); + } + + // Put it all in a table + $html .= Html::rawElement( 'table', + array( 'class' => 'mw-htmlform-matrix' ), + Html::rawElement( 'tbody', array(), "\n$tableContents\n" ) ) . "\n"; + + return $html; + } + + protected function isTagForcedOff( $tag ) { + return isset( $this->mParams['force-options-off'] ) + && in_array( $tag, $this->mParams['force-options-off'] ); + } + + protected function isTagForcedOn( $tag ) { + return isset( $this->mParams['force-options-on'] ) + && in_array( $tag, $this->mParams['force-options-on'] ); + } + + /** + * Get the complete table row for the input, including help text, + * labels, and whatever. + * We override this function since the label should always be on a separate + * line above the options in the case of a checkbox matrix, i.e. it's always + * a "vertical-label". + * + * @param string $value The value to set the input to + * + * @return string Complete HTML table row + */ + function getTableRow( $value ) { + list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value ); + $inputHtml = $this->getInputHTML( $value ); + $fieldType = get_class( $this ); + $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() ); + $cellAttributes = array( 'colspan' => 2 ); + + $label = $this->getLabelHtml( $cellAttributes ); + + $field = Html::rawElement( + 'td', + array( 'class' => 'mw-input' ) + $cellAttributes, + $inputHtml . "\n$errors" + ); + + $html = Html::rawElement( 'tr', array( 'class' => 'mw-htmlform-vertical-label' ), $label ); + $html .= Html::rawElement( 'tr', + array( 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass" ), + $field ); + + return $html . $helptext; + } + + /** + * @param WebRequest $request + * + * @return array + */ + function loadDataFromRequest( $request ) { + if ( $this->mParent->getMethod() == 'post' ) { + if ( $request->wasPosted() ) { + // Checkboxes are not added to the request arrays if they're not checked, + // so it's perfectly possible for there not to be an entry at all + return $request->getArray( $this->mName, array() ); + } else { + // That's ok, the user has not yet submitted the form, so show the defaults + return $this->getDefault(); + } + } else { + // This is the impossible case: if we look at $_GET and see no data for our + // field, is it because the user has not yet submitted the form, or that they + // have submitted it with all the options unchecked. We will have to assume the + // latter, which basically means that you can't specify 'positive' defaults + // for GET forms. + return $request->getArray( $this->mName, array() ); + } + } + + function getDefault() { + if ( isset( $this->mDefault ) ) { + return $this->mDefault; + } else { + return array(); + } + } + + function filterDataForSubmit( $data ) { + $columns = HTMLFormField::flattenOptions( $this->mParams['columns'] ); + $rows = HTMLFormField::flattenOptions( $this->mParams['rows'] ); + $res = array(); + foreach ( $columns as $column ) { + foreach ( $rows as $row ) { + // Make sure option hasn't been forced + $thisTag = "$column-$row"; + if ( $this->isTagForcedOff( $thisTag ) ) { + $res[$thisTag] = false; + } elseif ( $this->isTagForcedOn( $thisTag ) ) { + $res[$thisTag] = true; + } else { + $res[$thisTag] = in_array( $thisTag, $data ); + } + } + } + + return $res; + } +} diff --git a/includes/htmlform/HTMLEditTools.php b/includes/htmlform/HTMLEditTools.php new file mode 100644 index 00000000..77924ef2 --- /dev/null +++ b/includes/htmlform/HTMLEditTools.php @@ -0,0 +1,51 @@ +<?php + +class HTMLEditTools extends HTMLFormField { + public function getInputHTML( $value ) { + return ''; + } + + public function getTableRow( $value ) { + $msg = $this->formatMsg(); + + return + '<tr><td></td><td class="mw-input">' . + '<div class="mw-editTools">' . + $msg->parseAsBlock() . + "</div></td></tr>\n"; + } + + /** + * @param string $value + * @return string + * @since 1.20 + */ + public function getDiv( $value ) { + $msg = $this->formatMsg(); + + return '<div class="mw-editTools">' . $msg->parseAsBlock() . '</div>'; + } + + /** + * @param string $value + * @return string + * @since 1.20 + */ + public function getRaw( $value ) { + return $this->getDiv( $value ); + } + + protected function formatMsg() { + if ( empty( $this->mParams['message'] ) ) { + $msg = $this->msg( 'edittools' ); + } else { + $msg = $this->msg( $this->mParams['message'] ); + if ( $msg->isDisabled() ) { + $msg = $this->msg( 'edittools' ); + } + } + $msg->inContentLanguage(); + + return $msg; + } +} diff --git a/includes/htmlform/HTMLFloatField.php b/includes/htmlform/HTMLFloatField.php new file mode 100644 index 00000000..3b38fbe8 --- /dev/null +++ b/includes/htmlform/HTMLFloatField.php @@ -0,0 +1,46 @@ +<?php + +/** + * A field that will contain a numeric value + */ +class HTMLFloatField extends HTMLTextField { + function getSize() { + return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 20; + } + + function validate( $value, $alldata ) { + $p = parent::validate( $value, $alldata ); + + if ( $p !== true ) { + return $p; + } + + $value = trim( $value ); + + # http://dev.w3.org/html5/spec/common-microsyntaxes.html#real-numbers + # with the addition that a leading '+' sign is ok. + if ( !preg_match( '/^((\+|\-)?\d+(\.\d+)?(E(\+|\-)?\d+)?)?$/i', $value ) ) { + return $this->msg( 'htmlform-float-invalid' )->parseAsBlock(); + } + + # The "int" part of these message names is rather confusing. + # They make equal sense for all numbers. + if ( isset( $this->mParams['min'] ) ) { + $min = $this->mParams['min']; + + if ( $min > $value ) { + return $this->msg( 'htmlform-int-toolow', $min )->parseAsBlock(); + } + } + + if ( isset( $this->mParams['max'] ) ) { + $max = $this->mParams['max']; + + if ( $max < $value ) { + return $this->msg( 'htmlform-int-toohigh', $max )->parseAsBlock(); + } + } + + return true; + } +} diff --git a/includes/htmlform/HTMLForm.php b/includes/htmlform/HTMLForm.php new file mode 100644 index 00000000..d582da3b --- /dev/null +++ b/includes/htmlform/HTMLForm.php @@ -0,0 +1,1472 @@ +<?php + +/** + * HTML form generation and submission handling. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + */ + +/** + * Object handling generic submission, CSRF protection, layout and + * other logic for UI forms. in a reusable manner. + * + * In order to generate the form, the HTMLForm object takes an array + * structure detailing the form fields available. Each element of the + * array is a basic property-list, including the type of field, the + * label it is to be given in the form, callbacks for validation and + * 'filtering', and other pertinent information. + * + * Field types are implemented as subclasses of the generic HTMLFormField + * object, and typically implement at least getInputHTML, which generates + * the HTML for the input field to be placed in the table. + * + * You can find extensive documentation on the www.mediawiki.org wiki: + * - https://www.mediawiki.org/wiki/HTMLForm + * - https://www.mediawiki.org/wiki/HTMLForm/tutorial + * + * The constructor input is an associative array of $fieldname => $info, + * where $info is an Associative Array with any of the following: + * + * 'class' -- the subclass of HTMLFormField that will be used + * to create the object. *NOT* the CSS class! + * 'type' -- roughly translates into the <select> type attribute. + * if 'class' is not specified, this is used as a map + * through HTMLForm::$typeMappings to get the class name. + * 'default' -- default value when the form is displayed + * 'id' -- HTML id attribute + * 'cssclass' -- CSS class + * 'csshelpclass' -- CSS class used to style help text + * 'options' -- associative array mapping labels to values. + * Some field types support multi-level arrays. + * 'options-messages' -- associative array mapping message keys to values. + * Some field types support multi-level arrays. + * 'options-message' -- message key to be parsed to extract the list of + * options (like 'ipbreason-dropdown'). + * 'label-message' -- message key for a message to use as the label. + * can be an array of msg key and then parameters to + * the message. + * 'label' -- alternatively, a raw text message. Overridden by + * label-message + * 'help' -- message text for a message to use as a help text. + * 'help-message' -- message key for a message to use as a help text. + * can be an array of msg key and then parameters to + * the message. + * Overwrites 'help-messages' and 'help'. + * 'help-messages' -- array of message key. As above, each item can + * be an array of msg key and then parameters. + * Overwrites 'help'. + * 'required' -- passed through to the object, indicating that it + * is a required field. + * 'size' -- the length of text fields + * 'filter-callback -- a function name to give you the chance to + * massage the inputted value before it's processed. + * @see HTMLForm::filter() + * 'validation-callback' -- a function name to give you the chance + * to impose extra validation on the field input. + * @see HTMLForm::validate() + * 'name' -- By default, the 'name' attribute of the input field + * is "wp{$fieldname}". If you want a different name + * (eg one without the "wp" prefix), specify it here and + * it will be used without modification. + * + * Since 1.20, you can chain mutators to ease the form generation: + * @par Example: + * @code + * $form = new HTMLForm( $someFields ); + * $form->setMethod( 'get' ) + * ->setWrapperLegendMsg( 'message-key' ) + * ->prepareForm() + * ->displayForm( '' ); + * @endcode + * Note that you will have prepareForm and displayForm at the end. Other + * methods call done after that would simply not be part of the form :( + * + * @todo Document 'section' / 'subsection' stuff + */ +class HTMLForm extends ContextSource { + // A mapping of 'type' inputs onto standard HTMLFormField subclasses + public static $typeMappings = array( + 'api' => 'HTMLApiField', + 'text' => 'HTMLTextField', + 'textarea' => 'HTMLTextAreaField', + 'select' => 'HTMLSelectField', + 'radio' => 'HTMLRadioField', + 'multiselect' => 'HTMLMultiSelectField', + 'limitselect' => 'HTMLSelectLimitField', + 'check' => 'HTMLCheckField', + 'toggle' => 'HTMLCheckField', + 'int' => 'HTMLIntField', + 'float' => 'HTMLFloatField', + 'info' => 'HTMLInfoField', + 'selectorother' => 'HTMLSelectOrOtherField', + 'selectandother' => 'HTMLSelectAndOtherField', + 'submit' => 'HTMLSubmitField', + 'hidden' => 'HTMLHiddenField', + 'edittools' => 'HTMLEditTools', + 'checkmatrix' => 'HTMLCheckMatrix', + 'cloner' => 'HTMLFormFieldCloner', + 'autocompleteselect' => 'HTMLAutoCompleteSelectField', + // HTMLTextField will output the correct type="" attribute automagically. + // There are about four zillion other HTML5 input types, like range, but + // we don't use those at the moment, so no point in adding all of them. + 'email' => 'HTMLTextField', + 'password' => 'HTMLTextField', + 'url' => 'HTMLTextField', + ); + + public $mFieldData; + + protected $mMessagePrefix; + + /** @var HTMLFormField[] */ + protected $mFlatFields; + + protected $mFieldTree; + protected $mShowReset = false; + protected $mShowSubmit = true; + protected $mSubmitModifierClass = 'mw-ui-constructive'; + + protected $mSubmitCallback; + protected $mValidationErrorMessage; + + protected $mPre = ''; + protected $mHeader = ''; + protected $mFooter = ''; + protected $mSectionHeaders = array(); + protected $mSectionFooters = array(); + protected $mPost = ''; + protected $mId; + protected $mTableId = ''; + + protected $mSubmitID; + protected $mSubmitName; + protected $mSubmitText; + protected $mSubmitTooltip; + + protected $mTitle; + protected $mMethod = 'post'; + protected $mWasSubmitted = false; + + /** + * Form action URL. false means we will use the URL to set Title + * @since 1.19 + * @var bool|string + */ + protected $mAction = false; + + protected $mUseMultipart = false; + protected $mHiddenFields = array(); + protected $mButtons = array(); + + protected $mWrapperLegend = false; + + /** + * Salt for the edit token. + * @var string|array + */ + protected $mTokenSalt = ''; + + /** + * If true, sections that contain both fields and subsections will + * render their subsections before their fields. + * + * Subclasses may set this to false to render subsections after fields + * instead. + */ + protected $mSubSectionBeforeFields = true; + + /** + * Format in which to display form. For viable options, + * @see $availableDisplayFormats + * @var string + */ + protected $displayFormat = 'table'; + + /** + * Available formats in which to display the form + * @var array + */ + protected $availableDisplayFormats = array( + 'table', + 'div', + 'raw', + 'vform', + ); + + /** + * Build a new HTMLForm from an array of field attributes + * + * @param array $descriptor Array of Field constructs, as described above + * @param IContextSource $context Available since 1.18, will become compulsory in 1.18. + * Obviates the need to call $form->setTitle() + * @param string $messagePrefix A prefix to go in front of default messages + */ + public function __construct( $descriptor, /*IContextSource*/ $context = null, + $messagePrefix = '' + ) { + if ( $context instanceof IContextSource ) { + $this->setContext( $context ); + $this->mTitle = false; // We don't need them to set a title + $this->mMessagePrefix = $messagePrefix; + } elseif ( is_null( $context ) && $messagePrefix !== '' ) { + $this->mMessagePrefix = $messagePrefix; + } elseif ( is_string( $context ) && $messagePrefix === '' ) { + // B/C since 1.18 + // it's actually $messagePrefix + $this->mMessagePrefix = $context; + } + + // Expand out into a tree. + $loadedDescriptor = array(); + $this->mFlatFields = array(); + + foreach ( $descriptor as $fieldname => $info ) { + $section = isset( $info['section'] ) + ? $info['section'] + : ''; + + if ( isset( $info['type'] ) && $info['type'] == 'file' ) { + $this->mUseMultipart = true; + } + + $field = self::loadInputFromParameters( $fieldname, $info ); + // FIXME During field's construct, the parent form isn't available! + // could add a 'parent' name-value to $info, could add a third parameter. + $field->mParent = $this; + + // vform gets too much space if empty labels generate HTML. + if ( $this->isVForm() ) { + $field->setShowEmptyLabel( false ); + } + + $setSection =& $loadedDescriptor; + if ( $section ) { + $sectionParts = explode( '/', $section ); + + while ( count( $sectionParts ) ) { + $newName = array_shift( $sectionParts ); + + if ( !isset( $setSection[$newName] ) ) { + $setSection[$newName] = array(); + } + + $setSection =& $setSection[$newName]; + } + } + + $setSection[$fieldname] = $field; + $this->mFlatFields[$fieldname] = $field; + } + + $this->mFieldTree = $loadedDescriptor; + } + + /** + * Set format in which to display the form + * + * @param string $format The name of the format to use, must be one of + * $this->availableDisplayFormats + * + * @throws MWException + * @since 1.20 + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function setDisplayFormat( $format ) { + if ( !in_array( $format, $this->availableDisplayFormats ) ) { + throw new MWException( 'Display format must be one of ' . + print_r( $this->availableDisplayFormats, true ) ); + } + $this->displayFormat = $format; + + return $this; + } + + /** + * Getter for displayFormat + * @since 1.20 + * @return string + */ + public function getDisplayFormat() { + $format = $this->displayFormat; + if ( !$this->getConfig()->get( 'HTMLFormAllowTableFormat' ) && $format === 'table' ) { + $format = 'div'; + } + return $format; + } + + /** + * Test if displayFormat is 'vform' + * @since 1.22 + * @return bool + */ + public function isVForm() { + return $this->displayFormat === 'vform'; + } + + /** + * Get the HTMLFormField subclass for this descriptor. + * + * The descriptor can be passed either 'class' which is the name of + * a HTMLFormField subclass, or a shorter 'type' which is an alias. + * This makes sure the 'class' is always set, and also is returned by + * this function for ease. + * + * @since 1.23 + * + * @param string $fieldname Name of the field + * @param array $descriptor Input Descriptor, as described above + * + * @throws MWException + * @return string Name of a HTMLFormField subclass + */ + public static function getClassFromDescriptor( $fieldname, &$descriptor ) { + if ( isset( $descriptor['class'] ) ) { + $class = $descriptor['class']; + } elseif ( isset( $descriptor['type'] ) ) { + $class = self::$typeMappings[$descriptor['type']]; + $descriptor['class'] = $class; + } else { + $class = null; + } + + if ( !$class ) { + throw new MWException( "Descriptor with no class for $fieldname: " + . print_r( $descriptor, true ) ); + } + + return $class; + } + + /** + * Initialise a new Object for the field + * + * @param string $fieldname Name of the field + * @param array $descriptor Input Descriptor, as described above + * + * @throws MWException + * @return HTMLFormField Instance of a subclass of HTMLFormField + */ + public static function loadInputFromParameters( $fieldname, $descriptor ) { + $class = self::getClassFromDescriptor( $fieldname, $descriptor ); + + $descriptor['fieldname'] = $fieldname; + + # @todo This will throw a fatal error whenever someone try to use + # 'class' to feed a CSS class instead of 'cssclass'. Would be + # great to avoid the fatal error and show a nice error. + $obj = new $class( $descriptor ); + + return $obj; + } + + /** + * Prepare form for submission. + * + * @attention When doing method chaining, that should be the very last + * method call before displayForm(). + * + * @throws MWException + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function prepareForm() { + # Check if we have the info we need + if ( !$this->mTitle instanceof Title && $this->mTitle !== false ) { + throw new MWException( "You must call setTitle() on an HTMLForm" ); + } + + # Load data from the request. + $this->loadData(); + + return $this; + } + + /** + * Try submitting, with edit token check first + * @return Status|bool + */ + function tryAuthorizedSubmit() { + $result = false; + + $submit = false; + if ( $this->getMethod() != 'post' ) { + $submit = true; // no session check needed + } elseif ( $this->getRequest()->wasPosted() ) { + $editToken = $this->getRequest()->getVal( 'wpEditToken' ); + if ( $this->getUser()->isLoggedIn() || $editToken != null ) { + // Session tokens for logged-out users have no security value. + // However, if the user gave one, check it in order to give a nice + // "session expired" error instead of "permission denied" or such. + $submit = $this->getUser()->matchEditToken( $editToken, $this->mTokenSalt ); + } else { + $submit = true; + } + } + + if ( $submit ) { + $this->mWasSubmitted = true; + $result = $this->trySubmit(); + } + + return $result; + } + + /** + * The here's-one-I-made-earlier option: do the submission if + * posted, or display the form with or without funky validation + * errors + * @return bool|Status Whether submission was successful. + */ + function show() { + $this->prepareForm(); + + $result = $this->tryAuthorizedSubmit(); + if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) { + return $result; + } + + $this->displayForm( $result ); + + return false; + } + + /** + * Validate all the fields, and call the submission callback + * function if everything is kosher. + * @throws MWException + * @return bool|string|array|Status + * - Bool true or a good Status object indicates success, + * - Bool false indicates no submission was attempted, + * - Anything else indicates failure. The value may be a fatal Status + * object, an HTML string, or an array of arrays (message keys and + * params) or strings (message keys) + */ + function trySubmit() { + $this->mWasSubmitted = true; + + # Check for cancelled submission + foreach ( $this->mFlatFields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } + if ( $field->cancelSubmit( $this->mFieldData[$fieldname], $this->mFieldData ) ) { + $this->mWasSubmitted = false; + return false; + } + } + + # Check for validation + foreach ( $this->mFlatFields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } + if ( $field->isHidden( $this->mFieldData ) ) { + continue; + } + if ( $field->validate( + $this->mFieldData[$fieldname], + $this->mFieldData ) + !== true + ) { + return isset( $this->mValidationErrorMessage ) + ? $this->mValidationErrorMessage + : array( 'htmlform-invalid-input' ); + } + } + + $callback = $this->mSubmitCallback; + if ( !is_callable( $callback ) ) { + throw new MWException( 'HTMLForm: no submit callback provided. Use ' . + 'setSubmitCallback() to set one.' ); + } + + $data = $this->filterDataForSubmit( $this->mFieldData ); + + $res = call_user_func( $callback, $data, $this ); + if ( $res === false ) { + $this->mWasSubmitted = false; + } + + return $res; + } + + /** + * Test whether the form was considered to have been submitted or not, i.e. + * whether the last call to tryAuthorizedSubmit or trySubmit returned + * non-false. + * + * This will return false until HTMLForm::tryAuthorizedSubmit or + * HTMLForm::trySubmit is called. + * + * @since 1.23 + * @return bool + */ + function wasSubmitted() { + return $this->mWasSubmitted; + } + + /** + * Set a callback to a function to do something with the form + * once it's been successfully validated. + * + * @param callable $cb The function will be passed the output from + * HTMLForm::filterDataForSubmit and this HTMLForm object, and must + * return as documented for HTMLForm::trySubmit + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setSubmitCallback( $cb ) { + $this->mSubmitCallback = $cb; + + return $this; + } + + /** + * Set a message to display on a validation error. + * + * @param string|array $msg String or Array of valid inputs to wfMessage() + * (so each entry can be either a String or Array) + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setValidationErrorMessage( $msg ) { + $this->mValidationErrorMessage = $msg; + + return $this; + } + + /** + * Set the introductory message, overwriting any existing message. + * + * @param string $msg Complete text of message to display + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setIntro( $msg ) { + $this->setPreText( $msg ); + + return $this; + } + + /** + * Set the introductory message, overwriting any existing message. + * @since 1.19 + * + * @param string $msg Complete text of message to display + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setPreText( $msg ) { + $this->mPre = $msg; + + return $this; + } + + /** + * Add introductory text. + * + * @param string $msg Complete text of message to display + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function addPreText( $msg ) { + $this->mPre .= $msg; + + return $this; + } + + /** + * Add header text, inside the form. + * + * @param string $msg Complete text of message to display + * @param string|null $section The section to add the header to + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function addHeaderText( $msg, $section = null ) { + if ( is_null( $section ) ) { + $this->mHeader .= $msg; + } else { + if ( !isset( $this->mSectionHeaders[$section] ) ) { + $this->mSectionHeaders[$section] = ''; + } + $this->mSectionHeaders[$section] .= $msg; + } + + return $this; + } + + /** + * Set header text, inside the form. + * @since 1.19 + * + * @param string $msg Complete text of message to display + * @param string|null $section The section to add the header to + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setHeaderText( $msg, $section = null ) { + if ( is_null( $section ) ) { + $this->mHeader = $msg; + } else { + $this->mSectionHeaders[$section] = $msg; + } + + return $this; + } + + /** + * Add footer text, inside the form. + * + * @param string $msg Complete text of message to display + * @param string|null $section The section to add the footer text to + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function addFooterText( $msg, $section = null ) { + if ( is_null( $section ) ) { + $this->mFooter .= $msg; + } else { + if ( !isset( $this->mSectionFooters[$section] ) ) { + $this->mSectionFooters[$section] = ''; + } + $this->mSectionFooters[$section] .= $msg; + } + + return $this; + } + + /** + * Set footer text, inside the form. + * @since 1.19 + * + * @param string $msg Complete text of message to display + * @param string|null $section The section to add the footer text to + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setFooterText( $msg, $section = null ) { + if ( is_null( $section ) ) { + $this->mFooter = $msg; + } else { + $this->mSectionFooters[$section] = $msg; + } + + return $this; + } + + /** + * Add text to the end of the display. + * + * @param string $msg Complete text of message to display + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function addPostText( $msg ) { + $this->mPost .= $msg; + + return $this; + } + + /** + * Set text at the end of the display. + * + * @param string $msg Complete text of message to display + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setPostText( $msg ) { + $this->mPost = $msg; + + return $this; + } + + /** + * Add a hidden field to the output + * + * @param string $name Field name. This will be used exactly as entered + * @param string $value Field value + * @param array $attribs + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function addHiddenField( $name, $value, $attribs = array() ) { + $attribs += array( 'name' => $name ); + $this->mHiddenFields[] = array( $value, $attribs ); + + return $this; + } + + /** + * Add an array of hidden fields to the output + * + * @since 1.22 + * + * @param array $fields Associative array of fields to add; + * mapping names to their values + * + * @return HTMLForm $this for chaining calls + */ + public function addHiddenFields( array $fields ) { + foreach ( $fields as $name => $value ) { + $this->mHiddenFields[] = array( $value, array( 'name' => $name ) ); + } + + return $this; + } + + /** + * Add a button to the form + * + * @param string $name Field name. + * @param string $value Field value + * @param string $id DOM id for the button (default: null) + * @param array $attribs + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function addButton( $name, $value, $id = null, $attribs = null ) { + $this->mButtons[] = compact( 'name', 'value', 'id', 'attribs' ); + + return $this; + } + + /** + * Set the salt for the edit token. + * + * Only useful when the method is "post". + * + * @since 1.24 + * @param string|array $salt Salt to use + * @return HTMLForm $this For chaining calls + */ + public function setTokenSalt( $salt ) { + $this->mTokenSalt = $salt; + + return $this; + } + + /** + * Display the form (sending to the context's OutputPage object), with an + * appropriate error message or stack of messages, and any validation errors, etc. + * + * @attention You should call prepareForm() before calling this function. + * Moreover, when doing method chaining this should be the very last method + * call just after prepareForm(). + * + * @param bool|string|array|Status $submitResult Output from HTMLForm::trySubmit() + * + * @return void Nothing, should be last call + */ + function displayForm( $submitResult ) { + $this->getOutput()->addHTML( $this->getHTML( $submitResult ) ); + } + + /** + * Returns the raw HTML generated by the form + * + * @param bool|string|array|Status $submitResult Output from HTMLForm::trySubmit() + * + * @return string + */ + function getHTML( $submitResult ) { + # For good measure (it is the default) + $this->getOutput()->preventClickjacking(); + $this->getOutput()->addModules( 'mediawiki.htmlform' ); + if ( $this->isVForm() ) { + $this->getOutput()->addModuleStyles( array( + 'mediawiki.ui', + 'mediawiki.ui.button', + ) ); + // @todo Should vertical form set setWrapperLegend( false ) + // to hide ugly fieldsets? + } + + $html = '' + . $this->getErrors( $submitResult ) + . $this->mHeader + . $this->getBody() + . $this->getHiddenFields() + . $this->getButtons() + . $this->mFooter; + + $html = $this->wrapForm( $html ); + + return '' . $this->mPre . $html . $this->mPost; + } + + /** + * Wrap the form innards in an actual "<form>" element + * + * @param string $html HTML contents to wrap. + * + * @return string Wrapped HTML. + */ + function wrapForm( $html ) { + + # Include a <fieldset> wrapper for style, if requested. + if ( $this->mWrapperLegend !== false ) { + $html = Xml::fieldset( $this->mWrapperLegend, $html ); + } + # Use multipart/form-data + $encType = $this->mUseMultipart + ? 'multipart/form-data' + : 'application/x-www-form-urlencoded'; + # Attributes + $attribs = array( + 'action' => $this->getAction(), + 'method' => $this->getMethod(), + 'class' => array( 'visualClear' ), + 'enctype' => $encType, + ); + if ( !empty( $this->mId ) ) { + $attribs['id'] = $this->mId; + } + + if ( $this->isVForm() ) { + array_push( $attribs['class'], 'mw-ui-vform', 'mw-ui-container' ); + } + + return Html::rawElement( 'form', $attribs, $html ); + } + + /** + * Get the hidden fields that should go inside the form. + * @return string HTML. + */ + function getHiddenFields() { + $html = ''; + if ( $this->getMethod() == 'post' ) { + $html .= Html::hidden( + 'wpEditToken', + $this->getUser()->getEditToken( $this->mTokenSalt ), + array( 'id' => 'wpEditToken' ) + ) . "\n"; + $html .= Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) . "\n"; + } + + $articlePath = $this->getConfig()->get( 'ArticlePath' ); + if ( strpos( $articlePath, '?' ) !== false && $this->getMethod() == 'get' ) { + $html .= Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) . "\n"; + } + + foreach ( $this->mHiddenFields as $data ) { + list( $value, $attribs ) = $data; + $html .= Html::hidden( $attribs['name'], $value, $attribs ) . "\n"; + } + + return $html; + } + + /** + * Get the submit and (potentially) reset buttons. + * @return string HTML. + */ + function getButtons() { + $buttons = ''; + $useMediaWikiUIEverywhere = $this->getConfig()->get( 'UseMediaWikiUIEverywhere' ); + + if ( $this->mShowSubmit ) { + $attribs = array(); + + if ( isset( $this->mSubmitID ) ) { + $attribs['id'] = $this->mSubmitID; + } + + if ( isset( $this->mSubmitName ) ) { + $attribs['name'] = $this->mSubmitName; + } + + if ( isset( $this->mSubmitTooltip ) ) { + $attribs += Linker::tooltipAndAccesskeyAttribs( $this->mSubmitTooltip ); + } + + $attribs['class'] = array( 'mw-htmlform-submit' ); + + if ( $this->isVForm() || $useMediaWikiUIEverywhere ) { + array_push( $attribs['class'], 'mw-ui-button', $this->mSubmitModifierClass ); + } + + if ( $this->isVForm() ) { + // mw-ui-block is necessary because the buttons aren't necessarily in an + // immediate child div of the vform. + // @todo Let client specify if the primary submit button is progressive or destructive + array_push( + $attribs['class'], + 'mw-ui-big', + 'mw-ui-block' + ); + } + + $buttons .= Xml::submitButton( $this->getSubmitText(), $attribs ) . "\n"; + } + + if ( $this->mShowReset ) { + $buttons .= Html::element( + 'input', + array( + 'type' => 'reset', + 'value' => $this->msg( 'htmlform-reset' )->text() + ) + ) . "\n"; + } + + foreach ( $this->mButtons as $button ) { + $attrs = array( + 'type' => 'submit', + 'name' => $button['name'], + 'value' => $button['value'] + ); + + if ( $button['attribs'] ) { + $attrs += $button['attribs']; + } + + if ( isset( $button['id'] ) ) { + $attrs['id'] = $button['id']; + } + + if ( $this->isVForm() || $useMediaWikiUIEverywhere ) { + if ( isset( $attrs['class'] ) ) { + $attrs['class'] .= ' mw-ui-button'; + } else { + $attrs['class'] = 'mw-ui-button'; + } + if ( $this->isVForm() ) { + $attrs['class'] .= ' mw-ui-big mw-ui-block'; + } + } + + $buttons .= Html::element( 'input', $attrs ) . "\n"; + } + + $html = Html::rawElement( 'span', + array( 'class' => 'mw-htmlform-submit-buttons' ), "\n$buttons" ) . "\n"; + + // Buttons are top-level form elements in table and div layouts, + // but vform wants all elements inside divs to get spaced-out block + // styling. + if ( $this->mShowSubmit && $this->isVForm() ) { + $html = Html::rawElement( 'div', null, "\n$html" ) . "\n"; + } + + return $html; + } + + /** + * Get the whole body of the form. + * @return string + */ + function getBody() { + return $this->displaySection( $this->mFieldTree, $this->mTableId ); + } + + /** + * Format and display an error message stack. + * + * @param string|array|Status $errors + * + * @return string + */ + function getErrors( $errors ) { + if ( $errors instanceof Status ) { + if ( $errors->isOK() ) { + $errorstr = ''; + } else { + $errorstr = $this->getOutput()->parse( $errors->getWikiText() ); + } + } elseif ( is_array( $errors ) ) { + $errorstr = $this->formatErrors( $errors ); + } else { + $errorstr = $errors; + } + + return $errorstr + ? Html::rawElement( 'div', array( 'class' => 'error' ), $errorstr ) + : ''; + } + + /** + * Format a stack of error messages into a single HTML string + * + * @param array $errors Array of message keys/values + * + * @return string HTML, a "<ul>" list of errors + */ + public static function formatErrors( $errors ) { + $errorstr = ''; + + foreach ( $errors as $error ) { + if ( is_array( $error ) ) { + $msg = array_shift( $error ); + } else { + $msg = $error; + $error = array(); + } + + $errorstr .= Html::rawElement( + 'li', + array(), + wfMessage( $msg, $error )->parse() + ); + } + + $errorstr = Html::rawElement( 'ul', array(), $errorstr ); + + return $errorstr; + } + + /** + * Set the text for the submit button + * + * @param string $t Plaintext + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setSubmitText( $t ) { + $this->mSubmitText = $t; + + return $this; + } + + /** + * Identify that the submit button in the form has a destructive action + * + */ + public function setSubmitDestructive() { + $this->mSubmitModifierClass = 'mw-ui-destructive'; + } + + /** + * Set the text for the submit button to a message + * @since 1.19 + * + * @param string|Message $msg Message key or Message object + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function setSubmitTextMsg( $msg ) { + if ( !$msg instanceof Message ) { + $msg = $this->msg( $msg ); + } + $this->setSubmitText( $msg->text() ); + + return $this; + } + + /** + * Get the text for the submit button, either customised or a default. + * @return string + */ + function getSubmitText() { + return $this->mSubmitText + ? $this->mSubmitText + : $this->msg( 'htmlform-submit' )->text(); + } + + /** + * @param string $name Submit button name + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function setSubmitName( $name ) { + $this->mSubmitName = $name; + + return $this; + } + + /** + * @param string $name Tooltip for the submit button + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function setSubmitTooltip( $name ) { + $this->mSubmitTooltip = $name; + + return $this; + } + + /** + * Set the id for the submit button. + * + * @param string $t + * + * @todo FIXME: Integrity of $t is *not* validated + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setSubmitID( $t ) { + $this->mSubmitID = $t; + + return $this; + } + + /** + * Stop a default submit button being shown for this form. This implies that an + * alternate submit method must be provided manually. + * + * @since 1.22 + * + * @param bool $suppressSubmit Set to false to re-enable the button again + * + * @return HTMLForm $this for chaining calls + */ + function suppressDefaultSubmit( $suppressSubmit = true ) { + $this->mShowSubmit = !$suppressSubmit; + + return $this; + } + + /** + * Set the id of the \<table\> or outermost \<div\> element. + * + * @since 1.22 + * + * @param string $id New value of the id attribute, or "" to remove + * + * @return HTMLForm $this for chaining calls + */ + public function setTableId( $id ) { + $this->mTableId = $id; + + return $this; + } + + /** + * @param string $id DOM id for the form + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function setId( $id ) { + $this->mId = $id; + + return $this; + } + + /** + * Prompt the whole form to be wrapped in a "<fieldset>", with + * this text as its "<legend>" element. + * + * @param string|bool $legend HTML to go inside the "<legend>" element, or + * false for no <legend> + * Will be escaped + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function setWrapperLegend( $legend ) { + $this->mWrapperLegend = $legend; + + return $this; + } + + /** + * Prompt the whole form to be wrapped in a "<fieldset>", with + * this message as its "<legend>" element. + * @since 1.19 + * + * @param string|Message $msg Message key or Message object + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function setWrapperLegendMsg( $msg ) { + if ( !$msg instanceof Message ) { + $msg = $this->msg( $msg ); + } + $this->setWrapperLegend( $msg->text() ); + + return $this; + } + + /** + * Set the prefix for various default messages + * @todo Currently only used for the "<fieldset>" legend on forms + * with multiple sections; should be used elsewhere? + * + * @param string $p + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setMessagePrefix( $p ) { + $this->mMessagePrefix = $p; + + return $this; + } + + /** + * Set the title for form submission + * + * @param Title $t Title of page the form is on/should be posted to + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function setTitle( $t ) { + $this->mTitle = $t; + + return $this; + } + + /** + * Get the title + * @return Title + */ + function getTitle() { + return $this->mTitle === false + ? $this->getContext()->getTitle() + : $this->mTitle; + } + + /** + * Set the method used to submit the form + * + * @param string $method + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function setMethod( $method = 'post' ) { + $this->mMethod = $method; + + return $this; + } + + public function getMethod() { + return $this->mMethod; + } + + /** + * @todo Document + * + * @param array[]|HTMLFormField[] $fields Array of fields (either arrays or + * objects). + * @param string $sectionName ID attribute of the "<table>" tag for this + * section, ignored if empty. + * @param string $fieldsetIDPrefix ID prefix for the "<fieldset>" tag of + * each subsection, ignored if empty. + * @param bool &$hasUserVisibleFields Whether the section had user-visible fields. + * + * @return string + */ + public function displaySection( $fields, + $sectionName = '', + $fieldsetIDPrefix = '', + &$hasUserVisibleFields = false ) { + $displayFormat = $this->getDisplayFormat(); + + $html = ''; + $subsectionHtml = ''; + $hasLabel = false; + + switch ( $displayFormat ) { + case 'table': + $getFieldHtmlMethod = 'getTableRow'; + break; + case 'vform': + // Close enough to a div. + $getFieldHtmlMethod = 'getDiv'; + break; + case 'div': + $getFieldHtmlMethod = 'getDiv'; + break; + default: + $getFieldHtmlMethod = 'get' . ucfirst( $displayFormat ); + } + + foreach ( $fields as $key => $value ) { + if ( $value instanceof HTMLFormField ) { + $v = empty( $value->mParams['nodata'] ) + ? $this->mFieldData[$key] + : $value->getDefault(); + $html .= $value->$getFieldHtmlMethod( $v ); + + $labelValue = trim( $value->getLabel() ); + if ( $labelValue != ' ' && $labelValue !== '' ) { + $hasLabel = true; + } + + if ( get_class( $value ) !== 'HTMLHiddenField' && + get_class( $value ) !== 'HTMLApiField' + ) { + $hasUserVisibleFields = true; + } + } elseif ( is_array( $value ) ) { + $subsectionHasVisibleFields = false; + $section = + $this->displaySection( $value, + "mw-htmlform-$key", + "$fieldsetIDPrefix$key-", + $subsectionHasVisibleFields ); + $legend = null; + + if ( $subsectionHasVisibleFields === true ) { + // Display the section with various niceties. + $hasUserVisibleFields = true; + + $legend = $this->getLegend( $key ); + + if ( isset( $this->mSectionHeaders[$key] ) ) { + $section = $this->mSectionHeaders[$key] . $section; + } + if ( isset( $this->mSectionFooters[$key] ) ) { + $section .= $this->mSectionFooters[$key]; + } + + $attributes = array(); + if ( $fieldsetIDPrefix ) { + $attributes['id'] = Sanitizer::escapeId( "$fieldsetIDPrefix$key" ); + } + $subsectionHtml .= Xml::fieldset( $legend, $section, $attributes ) . "\n"; + } else { + // Just return the inputs, nothing fancy. + $subsectionHtml .= $section; + } + } + } + + if ( $displayFormat !== 'raw' ) { + $classes = array(); + + if ( !$hasLabel ) { // Avoid strange spacing when no labels exist + $classes[] = 'mw-htmlform-nolabel'; + } + + $attribs = array( + 'class' => implode( ' ', $classes ), + ); + + if ( $sectionName ) { + $attribs['id'] = Sanitizer::escapeId( $sectionName ); + } + + if ( $displayFormat === 'table' ) { + $html = Html::rawElement( 'table', + $attribs, + Html::rawElement( 'tbody', array(), "\n$html\n" ) ) . "\n"; + } elseif ( $displayFormat === 'div' || $displayFormat === 'vform' ) { + $html = Html::rawElement( 'div', $attribs, "\n$html\n" ); + } + } + + if ( $this->mSubSectionBeforeFields ) { + return $subsectionHtml . "\n" . $html; + } else { + return $html . "\n" . $subsectionHtml; + } + } + + /** + * Construct the form fields from the Descriptor array + */ + function loadData() { + $fieldData = array(); + + foreach ( $this->mFlatFields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } elseif ( !empty( $field->mParams['disabled'] ) ) { + $fieldData[$fieldname] = $field->getDefault(); + } else { + $fieldData[$fieldname] = $field->loadDataFromRequest( $this->getRequest() ); + } + } + + # Filter data. + foreach ( $fieldData as $name => &$value ) { + $field = $this->mFlatFields[$name]; + $value = $field->filter( $value, $this->mFlatFields ); + } + + $this->mFieldData = $fieldData; + } + + /** + * Stop a reset button being shown for this form + * + * @param bool $suppressReset Set to false to re-enable the button again + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + function suppressReset( $suppressReset = true ) { + $this->mShowReset = !$suppressReset; + + return $this; + } + + /** + * Overload this if you want to apply special filtration routines + * to the form as a whole, after it's submitted but before it's + * processed. + * + * @param array $data + * + * @return array + */ + function filterDataForSubmit( $data ) { + return $data; + } + + /** + * Get a string to go in the "<legend>" of a section fieldset. + * Override this if you want something more complicated. + * + * @param string $key + * + * @return string + */ + public function getLegend( $key ) { + return $this->msg( "{$this->mMessagePrefix}-$key" )->text(); + } + + /** + * Set the value for the action attribute of the form. + * When set to false (which is the default state), the set title is used. + * + * @since 1.19 + * + * @param string|bool $action + * + * @return HTMLForm $this for chaining calls (since 1.20) + */ + public function setAction( $action ) { + $this->mAction = $action; + + return $this; + } + + /** + * Get the value for the action attribute of the form. + * + * @since 1.22 + * + * @return string + */ + public function getAction() { + // If an action is alredy provided, return it + if ( $this->mAction !== false ) { + return $this->mAction; + } + + $articlePath = $this->getConfig()->get( 'ArticlePath' ); + // Check whether we are in GET mode and the ArticlePath contains a "?" + // meaning that getLocalURL() would return something like "index.php?title=...". + // As browser remove the query string before submitting GET forms, + // it means that the title would be lost. In such case use wfScript() instead + // and put title in an hidden field (see getHiddenFields()). + if ( strpos( $articlePath, '?' ) !== false && $this->getMethod() === 'get' ) { + return wfScript(); + } + + return $this->getTitle()->getLocalURL(); + } +} diff --git a/includes/htmlform/HTMLFormField.php b/includes/htmlform/HTMLFormField.php new file mode 100644 index 00000000..4cf23942 --- /dev/null +++ b/includes/htmlform/HTMLFormField.php @@ -0,0 +1,893 @@ +<?php + +/** + * The parent class to generate form fields. Any field type should + * be a subclass of this. + */ +abstract class HTMLFormField { + public $mParams; + + protected $mValidationCallback; + protected $mFilterCallback; + protected $mName; + protected $mLabel; # String label. Set on construction + protected $mID; + protected $mClass = ''; + protected $mHelpClass = false; + protected $mDefault; + protected $mOptions = false; + protected $mOptionsLabelsNotFromMessage = false; + protected $mHideIf = null; + + /** + * @var bool If true will generate an empty div element with no label + * @since 1.22 + */ + protected $mShowEmptyLabels = true; + + /** + * @var HTMLForm + */ + public $mParent; + + /** + * This function must be implemented to return the HTML to generate + * the input object itself. It should not implement the surrounding + * table cells/rows, or labels/help messages. + * + * @param string $value The value to set the input to; eg a default + * text for a text input. + * + * @return string Valid HTML. + */ + abstract function getInputHTML( $value ); + + /** + * Get a translated interface message + * + * This is a wrapper around $this->mParent->msg() if $this->mParent is set + * and wfMessage() otherwise. + * + * Parameters are the same as wfMessage(). + * + * @return Message + */ + function msg() { + $args = func_get_args(); + + if ( $this->mParent ) { + $callback = array( $this->mParent, 'msg' ); + } else { + $callback = 'wfMessage'; + } + + return call_user_func_array( $callback, $args ); + } + + + /** + * Fetch a field value from $alldata for the closest field matching a given + * name. + * + * This is complex because it needs to handle array fields like the user + * would expect. The general algorithm is to look for $name as a sibling + * of $this, then a sibling of $this's parent, and so on. Keeping in mind + * that $name itself might be referencing an array. + * + * @param array $alldata + * @param string $name + * @return string + */ + protected function getNearestFieldByName( $alldata, $name ) { + $tmp = $this->mName; + $thisKeys = array(); + while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $tmp, $m ) ) { + array_unshift( $thisKeys, $m[2] ); + $tmp = $m[1]; + } + if ( substr( $tmp, 0, 2 ) == 'wp' && + !isset( $alldata[$tmp] ) && + isset( $alldata[substr( $tmp, 2 )] ) + ) { + // Adjust for name mangling. + $tmp = substr( $tmp, 2 ); + } + array_unshift( $thisKeys, $tmp ); + + $tmp = $name; + $nameKeys = array(); + while ( preg_match( '/^(.+)\[([^\]]+)\]$/', $tmp, $m ) ) { + array_unshift( $nameKeys, $m[2] ); + $tmp = $m[1]; + } + array_unshift( $nameKeys, $tmp ); + + $testValue = ''; + for ( $i = count( $thisKeys ) - 1; $i >= 0; $i-- ) { + $keys = array_merge( array_slice( $thisKeys, 0, $i ), $nameKeys ); + $data = $alldata; + while ( $keys ) { + $key = array_shift( $keys ); + if ( !is_array( $data ) || !isset( $data[$key] ) ) { + continue 2; + } + $data = $data[$key]; + } + $testValue = (string)$data; + break; + } + + return $testValue; + } + + /** + * Helper function for isHidden to handle recursive data structures. + * + * @param array $alldata + * @param array $params + * @return bool + */ + protected function isHiddenRecurse( array $alldata, array $params ) { + $origParams = $params; + $op = array_shift( $params ); + + try { + switch ( $op ) { + case 'AND': + foreach ( $params as $i => $p ) { + if ( !is_array( $p ) ) { + throw new MWException( + "Expected array, found " . gettype( $p ) . " at index $i" + ); + } + if ( !$this->isHiddenRecurse( $alldata, $p ) ) { + return false; + } + } + return true; + + case 'OR': + foreach ( $params as $p ) { + if ( !is_array( $p ) ) { + throw new MWException( + "Expected array, found " . gettype( $p ) . " at index $i" + ); + } + if ( $this->isHiddenRecurse( $alldata, $p ) ) { + return true; + } + } + return false; + + case 'NAND': + foreach ( $params as $i => $p ) { + if ( !is_array( $p ) ) { + throw new MWException( + "Expected array, found " . gettype( $p ) . " at index $i" + ); + } + if ( !$this->isHiddenRecurse( $alldata, $p ) ) { + return true; + } + } + return false; + + case 'NOR': + foreach ( $params as $p ) { + if ( !is_array( $p ) ) { + throw new MWException( + "Expected array, found " . gettype( $p ) . " at index $i" + ); + } + if ( $this->isHiddenRecurse( $alldata, $p ) ) { + return false; + } + } + return true; + + case 'NOT': + if ( count( $params ) !== 1 ) { + throw new MWException( "NOT takes exactly one parameter" ); + } + $p = $params[0]; + if ( !is_array( $p ) ) { + throw new MWException( + "Expected array, found " . gettype( $p ) . " at index 0" + ); + } + return !$this->isHiddenRecurse( $alldata, $p ); + + case '===': + case '!==': + if ( count( $params ) !== 2 ) { + throw new MWException( "$op takes exactly two parameters" ); + } + list( $field, $value ) = $params; + if ( !is_string( $field ) || !is_string( $value ) ) { + throw new MWException( "Parameters for $op must be strings" ); + } + $testValue = $this->getNearestFieldByName( $alldata, $field ); + switch ( $op ) { + case '===': + return ( $value === $testValue ); + case '!==': + return ( $value !== $testValue ); + } + + default: + throw new MWException( "Unknown operation" ); + } + } catch ( MWException $ex ) { + throw new MWException( + "Invalid hide-if specification for $this->mName: " . + $ex->getMessage() . " in " . var_export( $origParams, true ), + 0, $ex + ); + } + } + + /** + * Test whether this field is supposed to be hidden, based on the values of + * the other form fields. + * + * @since 1.23 + * @param array $alldata The data collected from the form + * @return bool + */ + function isHidden( $alldata ) { + if ( !$this->mHideIf ) { + return false; + } + + return $this->isHiddenRecurse( $alldata, $this->mHideIf ); + } + + /** + * Override this function if the control can somehow trigger a form + * submission that shouldn't actually submit the HTMLForm. + * + * @since 1.23 + * @param string|array $value The value the field was submitted with + * @param array $alldata The data collected from the form + * + * @return bool True to cancel the submission + */ + function cancelSubmit( $value, $alldata ) { + return false; + } + + /** + * Override this function to add specific validation checks on the + * field input. Don't forget to call parent::validate() to ensure + * that the user-defined callback mValidationCallback is still run + * + * @param string|array $value The value the field was submitted with + * @param array $alldata The data collected from the form + * + * @return bool|string True on success, or String error to display, or + * false to fail validation without displaying an error. + */ + function validate( $value, $alldata ) { + if ( $this->isHidden( $alldata ) ) { + return true; + } + + if ( isset( $this->mParams['required'] ) + && $this->mParams['required'] !== false + && $value === '' + ) { + return $this->msg( 'htmlform-required' )->parse(); + } + + if ( isset( $this->mValidationCallback ) ) { + return call_user_func( $this->mValidationCallback, $value, $alldata, $this->mParent ); + } + + return true; + } + + function filter( $value, $alldata ) { + if ( isset( $this->mFilterCallback ) ) { + $value = call_user_func( $this->mFilterCallback, $value, $alldata, $this->mParent ); + } + + return $value; + } + + /** + * Should this field have a label, or is there no input element with the + * appropriate id for the label to point to? + * + * @return bool True to output a label, false to suppress + */ + protected function needsLabel() { + return true; + } + + /** + * Tell the field whether to generate a separate label element if its label + * is blank. + * + * @since 1.22 + * + * @param bool $show Set to false to not generate a label. + * @return void + */ + public function setShowEmptyLabel( $show ) { + $this->mShowEmptyLabels = $show; + } + + /** + * Get the value that this input has been set to from a posted form, + * or the input's default value if it has not been set. + * + * @param WebRequest $request + * @return string The value + */ + function loadDataFromRequest( $request ) { + if ( $request->getCheck( $this->mName ) ) { + return $request->getText( $this->mName ); + } else { + return $this->getDefault(); + } + } + + /** + * Initialise the object + * + * @param array $params Associative Array. See HTMLForm doc for syntax. + * + * @since 1.22 The 'label' attribute no longer accepts raw HTML, use 'label-raw' instead + * @throws MWException + */ + function __construct( $params ) { + $this->mParams = $params; + + # Generate the label from a message, if possible + if ( isset( $params['label-message'] ) ) { + $msgInfo = $params['label-message']; + + if ( is_array( $msgInfo ) ) { + $msg = array_shift( $msgInfo ); + } else { + $msg = $msgInfo; + $msgInfo = array(); + } + + $this->mLabel = wfMessage( $msg, $msgInfo )->parse(); + } elseif ( isset( $params['label'] ) ) { + if ( $params['label'] === ' ' ) { + // Apparently some things set   directly and in an odd format + $this->mLabel = ' '; + } else { + $this->mLabel = htmlspecialchars( $params['label'] ); + } + } elseif ( isset( $params['label-raw'] ) ) { + $this->mLabel = $params['label-raw']; + } + + $this->mName = "wp{$params['fieldname']}"; + if ( isset( $params['name'] ) ) { + $this->mName = $params['name']; + } + + $validName = Sanitizer::escapeId( $this->mName ); + $validName = str_replace( array( '.5B', '.5D' ), array( '[', ']' ), $validName ); + if ( $this->mName != $validName && !isset( $params['nodata'] ) ) { + throw new MWException( "Invalid name '{$this->mName}' passed to " . __METHOD__ ); + } + + $this->mID = "mw-input-{$this->mName}"; + + if ( isset( $params['default'] ) ) { + $this->mDefault = $params['default']; + } + + if ( isset( $params['id'] ) ) { + $id = $params['id']; + $validId = Sanitizer::escapeId( $id ); + + if ( $id != $validId ) { + throw new MWException( "Invalid id '$id' passed to " . __METHOD__ ); + } + + $this->mID = $id; + } + + if ( isset( $params['cssclass'] ) ) { + $this->mClass = $params['cssclass']; + } + + if ( isset( $params['csshelpclass'] ) ) { + $this->mHelpClass = $params['csshelpclass']; + } + + if ( isset( $params['validation-callback'] ) ) { + $this->mValidationCallback = $params['validation-callback']; + } + + if ( isset( $params['filter-callback'] ) ) { + $this->mFilterCallback = $params['filter-callback']; + } + + if ( isset( $params['flatlist'] ) ) { + $this->mClass .= ' mw-htmlform-flatlist'; + } + + if ( isset( $params['hidelabel'] ) ) { + $this->mShowEmptyLabels = false; + } + + if ( isset( $params['hide-if'] ) ) { + $this->mHideIf = $params['hide-if']; + } + } + + /** + * Get the complete table row for the input, including help text, + * labels, and whatever. + * + * @param string $value The value to set the input to. + * + * @return string Complete HTML table row. + */ + function getTableRow( $value ) { + list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value ); + $inputHtml = $this->getInputHTML( $value ); + $fieldType = get_class( $this ); + $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() ); + $cellAttributes = array(); + $rowAttributes = array(); + $rowClasses = ''; + + if ( !empty( $this->mParams['vertical-label'] ) ) { + $cellAttributes['colspan'] = 2; + $verticalLabel = true; + } else { + $verticalLabel = false; + } + + $label = $this->getLabelHtml( $cellAttributes ); + + $field = Html::rawElement( + 'td', + array( 'class' => 'mw-input' ) + $cellAttributes, + $inputHtml . "\n$errors" + ); + + if ( $this->mHideIf ) { + $rowAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf ); + $rowClasses .= ' mw-htmlform-hide-if'; + } + + if ( $verticalLabel ) { + $html = Html::rawElement( 'tr', + $rowAttributes + array( 'class' => "mw-htmlform-vertical-label $rowClasses" ), $label ); + $html .= Html::rawElement( 'tr', + $rowAttributes + array( + 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses" + ), + $field ); + } else { + $html = + Html::rawElement( 'tr', + $rowAttributes + array( + 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses" + ), + $label . $field ); + } + + return $html . $helptext; + } + + /** + * Get the complete div for the input, including help text, + * labels, and whatever. + * @since 1.20 + * + * @param string $value The value to set the input to. + * + * @return string Complete HTML table row. + */ + public function getDiv( $value ) { + list( $errors, $errorClass ) = $this->getErrorsAndErrorClass( $value ); + $inputHtml = $this->getInputHTML( $value ); + $fieldType = get_class( $this ); + $helptext = $this->getHelpTextHtmlDiv( $this->getHelpText() ); + $cellAttributes = array(); + $label = $this->getLabelHtml( $cellAttributes ); + + $outerDivClass = array( + 'mw-input', + 'mw-htmlform-nolabel' => ( $label === '' ) + ); + + $field = Html::rawElement( + 'div', + array( 'class' => $outerDivClass ) + $cellAttributes, + $inputHtml . "\n$errors" + ); + $divCssClasses = array( "mw-htmlform-field-$fieldType", $this->mClass, $errorClass ); + if ( $this->mParent->isVForm() ) { + $divCssClasses[] = 'mw-ui-vform-field'; + } + + $wrapperAttributes = array( + 'class' => $divCssClasses, + ); + if ( $this->mHideIf ) { + $wrapperAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf ); + $wrapperAttributes['class'][] = ' mw-htmlform-hide-if'; + } + $html = Html::rawElement( 'div', $wrapperAttributes, $label . $field ); + $html .= $helptext; + + return $html; + } + + /** + * Get the complete raw fields for the input, including help text, + * labels, and whatever. + * @since 1.20 + * + * @param string $value The value to set the input to. + * + * @return string Complete HTML table row. + */ + public function getRaw( $value ) { + list( $errors, ) = $this->getErrorsAndErrorClass( $value ); + $inputHtml = $this->getInputHTML( $value ); + $helptext = $this->getHelpTextHtmlRaw( $this->getHelpText() ); + $cellAttributes = array(); + $label = $this->getLabelHtml( $cellAttributes ); + + $html = "\n$errors"; + $html .= $label; + $html .= $inputHtml; + $html .= $helptext; + + return $html; + } + + /** + * Generate help text HTML in table format + * @since 1.20 + * + * @param string|null $helptext + * @return string + */ + public function getHelpTextHtmlTable( $helptext ) { + if ( is_null( $helptext ) ) { + return ''; + } + + $rowAttributes = array(); + if ( $this->mHideIf ) { + $rowAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf ); + $rowAttributes['class'] = 'mw-htmlform-hide-if'; + } + + $tdClasses = array( 'htmlform-tip' ); + if ( $this->mHelpClass !== false ) { + $tdClasses[] = $this->mHelpClass; + } + $row = Html::rawElement( 'td', array( 'colspan' => 2, 'class' => $tdClasses ), $helptext ); + $row = Html::rawElement( 'tr', $rowAttributes, $row ); + + return $row; + } + + /** + * Generate help text HTML in div format + * @since 1.20 + * + * @param string|null $helptext + * + * @return string + */ + public function getHelpTextHtmlDiv( $helptext ) { + if ( is_null( $helptext ) ) { + return ''; + } + + $wrapperAttributes = array( + 'class' => 'htmlform-tip', + ); + if ( $this->mHelpClass !== false ) { + $wrapperAttributes['class'] .= " {$this->mHelpClass}"; + } + if ( $this->mHideIf ) { + $wrapperAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf ); + $wrapperAttributes['class'] .= ' mw-htmlform-hide-if'; + } + $div = Html::rawElement( 'div', $wrapperAttributes, $helptext ); + + return $div; + } + + /** + * Generate help text HTML formatted for raw output + * @since 1.20 + * + * @param string|null $helptext + * @return string + */ + public function getHelpTextHtmlRaw( $helptext ) { + return $this->getHelpTextHtmlDiv( $helptext ); + } + + /** + * Determine the help text to display + * @since 1.20 + * @return string + */ + public function getHelpText() { + $helptext = null; + + if ( isset( $this->mParams['help-message'] ) ) { + $this->mParams['help-messages'] = array( $this->mParams['help-message'] ); + } + + if ( isset( $this->mParams['help-messages'] ) ) { + foreach ( $this->mParams['help-messages'] as $name ) { + $helpMessage = (array)$name; + $msg = $this->msg( array_shift( $helpMessage ), $helpMessage ); + + if ( $msg->exists() ) { + if ( is_null( $helptext ) ) { + $helptext = ''; + } else { + $helptext .= $this->msg( 'word-separator' )->escaped(); // some space + } + $helptext .= $msg->parse(); // Append message + } + } + } elseif ( isset( $this->mParams['help'] ) ) { + $helptext = $this->mParams['help']; + } + + return $helptext; + } + + /** + * Determine form errors to display and their classes + * @since 1.20 + * + * @param string $value The value of the input + * @return array + */ + public function getErrorsAndErrorClass( $value ) { + $errors = $this->validate( $value, $this->mParent->mFieldData ); + + if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) { + $errors = ''; + $errorClass = ''; + } else { + $errors = self::formatErrors( $errors ); + $errorClass = 'mw-htmlform-invalid-input'; + } + + return array( $errors, $errorClass ); + } + + function getLabel() { + return is_null( $this->mLabel ) ? '' : $this->mLabel; + } + + function getLabelHtml( $cellAttributes = array() ) { + # Don't output a for= attribute for labels with no associated input. + # Kind of hacky here, possibly we don't want these to be <label>s at all. + $for = array(); + + if ( $this->needsLabel() ) { + $for['for'] = $this->mID; + } + + $labelValue = trim( $this->getLabel() ); + $hasLabel = false; + if ( $labelValue !== ' ' && $labelValue !== '' ) { + $hasLabel = true; + } + + $displayFormat = $this->mParent->getDisplayFormat(); + $html = ''; + + if ( $displayFormat === 'table' ) { + $html = + Html::rawElement( 'td', + array( 'class' => 'mw-label' ) + $cellAttributes, + Html::rawElement( 'label', $for, $labelValue ) ); + } elseif ( $hasLabel || $this->mShowEmptyLabels ) { + if ( $displayFormat === 'div' ) { + $html = + Html::rawElement( 'div', + array( 'class' => 'mw-label' ) + $cellAttributes, + Html::rawElement( 'label', $for, $labelValue ) ); + } else { + $html = Html::rawElement( 'label', $for, $labelValue ); + } + } + + return $html; + } + + function getDefault() { + if ( isset( $this->mDefault ) ) { + return $this->mDefault; + } else { + return null; + } + } + + /** + * Returns the attributes required for the tooltip and accesskey. + * + * @return array Attributes + */ + public function getTooltipAndAccessKey() { + if ( empty( $this->mParams['tooltip'] ) ) { + return array(); + } + + return Linker::tooltipAndAccesskeyAttribs( $this->mParams['tooltip'] ); + } + + /** + * Returns the given attributes from the parameters + * + * @param array $list List of attributes to get + * @return array Attributes + */ + public function getAttributes( array $list ) { + static $boolAttribs = array( 'disabled', 'required', 'autofocus', 'multiple', 'readonly' ); + + $ret = array(); + + foreach ( $list as $key ) { + if ( in_array( $key, $boolAttribs ) ) { + if ( !empty( $this->mParams[$key] ) ) { + $ret[$key] = ''; + } + } elseif ( isset( $this->mParams[$key] ) ) { + $ret[$key] = $this->mParams[$key]; + } + } + + return $ret; + } + + /** + * Given an array of msg-key => value mappings, returns an array with keys + * being the message texts. It also forces values to strings. + * + * @param array $options + * @return array + */ + private function lookupOptionsKeys( $options ) { + $ret = array(); + foreach ( $options as $key => $value ) { + $key = $this->msg( $key )->plain(); + $ret[$key] = is_array( $value ) + ? $this->lookupOptionsKeys( $value ) + : strval( $value ); + } + return $ret; + } + + /** + * Recursively forces values in an array to strings, because issues arise + * with integer 0 as a value. + * + * @param array $array + * @return array + */ + static function forceToStringRecursive( $array ) { + if ( is_array( $array ) ) { + return array_map( array( __CLASS__, 'forceToStringRecursive' ), $array ); + } else { + return strval( $array ); + } + } + + /** + * Fetch the array of options from the field's parameters. In order, this + * checks 'options-messages', 'options', then 'options-message'. + * + * @return array|null Options array + */ + public function getOptions() { + if ( $this->mOptions === false ) { + if ( array_key_exists( 'options-messages', $this->mParams ) ) { + $this->mOptions = $this->lookupOptionsKeys( $this->mParams['options-messages'] ); + } elseif ( array_key_exists( 'options', $this->mParams ) ) { + $this->mOptionsLabelsNotFromMessage = true; + $this->mOptions = self::forceToStringRecursive( $this->mParams['options'] ); + } elseif ( array_key_exists( 'options-message', $this->mParams ) ) { + /** @todo This is copied from Xml::listDropDown(), deprecate/avoid duplication? */ + $message = $this->msg( $this->mParams['options-message'] )->inContentLanguage()->plain(); + + $optgroup = false; + $this->mOptions = array(); + foreach ( explode( "\n", $message ) as $option ) { + $value = trim( $option ); + if ( $value == '' ) { + continue; + } elseif ( substr( $value, 0, 1 ) == '*' && substr( $value, 1, 1 ) != '*' ) { + # A new group is starting... + $value = trim( substr( $value, 1 ) ); + $optgroup = $value; + } elseif ( substr( $value, 0, 2 ) == '**' ) { + # groupmember + $opt = trim( substr( $value, 2 ) ); + if ( $optgroup === false ) { + $this->mOptions[$opt] = $opt; + } else { + $this->mOptions[$optgroup][$opt] = $opt; + } + } else { + # groupless reason list + $optgroup = false; + $this->mOptions[$option] = $option; + } + } + } else { + $this->mOptions = null; + } + } + + return $this->mOptions; + } + + /** + * flatten an array of options to a single array, for instance, + * a set of "<options>" inside "<optgroups>". + * + * @param array $options Associative Array with values either Strings or Arrays + * @return array Flattened input + */ + public static function flattenOptions( $options ) { + $flatOpts = array(); + + foreach ( $options as $value ) { + if ( is_array( $value ) ) { + $flatOpts = array_merge( $flatOpts, self::flattenOptions( $value ) ); + } else { + $flatOpts[] = $value; + } + } + + return $flatOpts; + } + + /** + * Formats one or more errors as accepted by field validation-callback. + * + * @param string|Message|array $errors Array of strings or Message instances + * @return string HTML + * @since 1.18 + */ + protected static function formatErrors( $errors ) { + if ( is_array( $errors ) && count( $errors ) === 1 ) { + $errors = array_shift( $errors ); + } + + if ( is_array( $errors ) ) { + $lines = array(); + foreach ( $errors as $error ) { + if ( $error instanceof Message ) { + $lines[] = Html::rawElement( 'li', array(), $error->parse() ); + } else { + $lines[] = Html::rawElement( 'li', array(), $error ); + } + } + + return Html::rawElement( 'ul', array( 'class' => 'error' ), implode( "\n", $lines ) ); + } else { + if ( $errors instanceof Message ) { + $errors = $errors->parse(); + } + + return Html::rawElement( 'span', array( 'class' => 'error' ), $errors ); + } + } +} diff --git a/includes/htmlform/HTMLFormFieldCloner.php b/includes/htmlform/HTMLFormFieldCloner.php new file mode 100644 index 00000000..029911cd --- /dev/null +++ b/includes/htmlform/HTMLFormFieldCloner.php @@ -0,0 +1,382 @@ +<?php + +/** + * A container for HTMLFormFields that allows for multiple copies of the set of + * fields to be displayed to and entered by the user. + * + * Recognized parameters, besides the general ones, include: + * fields - HTMLFormField descriptors for the subfields this cloner manages. + * The format is just like for the HTMLForm. A field with key 'delete' is + * special: it must have type = submit and will serve to delete the group + * of fields. + * required - If specified, at least one group of fields must be submitted. + * format - HTMLForm display format to use when displaying the subfields: + * 'table', 'div', or 'raw'. + * row-legend - If non-empty, each group of subfields will be enclosed in a + * fieldset. The value is the name of a message key to use as the legend. + * create-button-message - Message key to use as the text of the button to + * add an additional group of fields. + * delete-button-message - Message key to use as the text of automatically- + * generated 'delete' button. Ignored if 'delete' is included in 'fields'. + * + * In the generated HTML, the subfields will be named along the lines of + * "clonerName[index][fieldname]", with ids "clonerId--index--fieldid". 'index' + * may be a number or an arbitrary string, and may likely change when the page + * is resubmitted. Cloners may be nested, resulting in field names along the + * lines of "cloner1Name[index1][cloner2Name][index2][fieldname]" and + * corresponding ids. + * + * Use of cloner may result in submissions of the page that are not submissions + * of the HTMLForm, when non-JavaScript clients use the create or remove buttons. + * + * The result is an array, with values being arrays mapping subfield names to + * their values. On non-HTMLForm-submission page loads, there may also be + * additional (string) keys present with other types of values. + * + * @since 1.23 + */ +class HTMLFormFieldCloner extends HTMLFormField { + private static $counter = 0; + + /** + * @var string String uniquely identifying this cloner instance and + * unlikely to exist otherwise in the generated HTML, while still being + * valid as part of an HTML id. + */ + protected $uniqueId; + + public function __construct( $params ) { + $this->uniqueId = get_class( $this ) . ++self::$counter . 'x'; + parent::__construct( $params ); + + if ( empty( $this->mParams['fields'] ) || !is_array( $this->mParams['fields'] ) ) { + throw new MWException( 'HTMLFormFieldCloner called without any fields' ); + } + + // Make sure the delete button, if explicitly specified, is sane + if ( isset( $this->mParams['fields']['delete'] ) ) { + $class = 'mw-htmlform-cloner-delete-button'; + $info = $this->mParams['fields']['delete'] + array( + 'cssclass' => $class + ); + unset( $info['name'], $info['class'] ); + + if ( !isset( $info['type'] ) || $info['type'] !== 'submit' ) { + throw new MWException( + 'HTMLFormFieldCloner delete field, if specified, must be of type "submit"' + ); + } + + if ( !in_array( $class, explode( ' ', $info['cssclass'] ) ) ) { + $info['cssclass'] .= " $class"; + } + + $this->mParams['fields']['delete'] = $info; + } + } + + /** + * Create the HTMLFormFields that go inside this element, using the + * specified key. + * + * @param string $key Array key under which these fields should be named + * @return HTMLFormField[] + */ + protected function createFieldsForKey( $key ) { + $fields = array(); + foreach ( $this->mParams['fields'] as $fieldname => $info ) { + $name = "{$this->mName}[$key][$fieldname]"; + if ( isset( $info['name'] ) ) { + $info['name'] = "{$this->mName}[$key][{$info['name']}]"; + } else { + $info['name'] = $name; + } + if ( isset( $info['id'] ) ) { + $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--{$info['id']}" ); + } else { + $info['id'] = Sanitizer::escapeId( "{$this->mID}--$key--$fieldname" ); + } + $field = HTMLForm::loadInputFromParameters( $name, $info ); + $field->mParent = $this->mParent; + $fields[$fieldname] = $field; + } + return $fields; + } + + /** + * Re-key the specified values array to match the names applied by + * createFieldsForKey(). + * + * @param string $key Array key under which these fields should be named + * @param array $values Values array from the request + * @return array + */ + protected function rekeyValuesArray( $key, $values ) { + $data = array(); + foreach ( $values as $fieldname => $value ) { + $name = "{$this->mName}[$key][$fieldname]"; + $data[$name] = $value; + } + return $data; + } + + protected function needsLabel() { + return false; + } + + public function loadDataFromRequest( $request ) { + // It's possible that this might be posted with no fields. Detect that + // by looking for an edit token. + if ( !$request->getCheck( 'wpEditToken' ) && $request->getArray( $this->mName ) === null ) { + return $this->getDefault(); + } + + $values = $request->getArray( $this->mName ); + if ( $values === null ) { + $values = array(); + } + + $ret = array(); + foreach ( $values as $key => $value ) { + if ( $key === 'create' || isset( $value['delete'] ) ) { + $ret['nonjs'] = 1; + continue; + } + + // Add back in $request->getValues() so things that look for e.g. + // wpEditToken don't fail. + $data = $this->rekeyValuesArray( $key, $value ) + $request->getValues(); + + $fields = $this->createFieldsForKey( $key ); + $subrequest = new DerivativeRequest( $request, $data, $request->wasPosted() ); + $row = array(); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } elseif ( !empty( $field->mParams['disabled'] ) ) { + $row[$fieldname] = $field->getDefault(); + } else { + $row[$fieldname] = $field->loadDataFromRequest( $subrequest ); + } + } + $ret[] = $row; + } + + if ( isset( $values['create'] ) ) { + // Non-JS client clicked the "create" button. + $fields = $this->createFieldsForKey( $this->uniqueId ); + $row = array(); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } else { + $row[$fieldname] = $field->getDefault(); + } + } + $ret[] = $row; + } + + return $ret; + } + + public function getDefault() { + $ret = parent::getDefault(); + + // The default default is one entry with all subfields at their + // defaults. + if ( $ret === null ) { + $fields = $this->createFieldsForKey( $this->uniqueId ); + $row = array(); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } else { + $row[$fieldname] = $field->getDefault(); + } + } + $ret = array( $row ); + } + + return $ret; + } + + public function cancelSubmit( $values, $alldata ) { + if ( isset( $values['nonjs'] ) ) { + return true; + } + + foreach ( $values as $key => $value ) { + $fields = $this->createFieldsForKey( $key ); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } + if ( $field->cancelSubmit( $value[$fieldname], $alldata ) ) { + return true; + } + } + } + + return parent::cancelSubmit( $values, $alldata ); + } + + public function validate( $values, $alldata ) { + if ( isset( $this->mParams['required'] ) + && $this->mParams['required'] !== false + && !$values + ) { + return $this->msg( 'htmlform-cloner-required' )->parseAsBlock(); + } + + if ( isset( $values['nonjs'] ) ) { + // The submission was a non-JS create/delete click, so fail + // validation in case cancelSubmit() somehow didn't already handle + // it. + return false; + } + + foreach ( $values as $key => $value ) { + $fields = $this->createFieldsForKey( $key ); + foreach ( $fields as $fieldname => $field ) { + if ( !empty( $field->mParams['nodata'] ) ) { + continue; + } + $ok = $field->validate( $value[$fieldname], $alldata ); + if ( $ok !== true ) { + return false; + } + } + } + + return parent::validate( $values, $alldata ); + } + + /** + * Get the input HTML for the specified key. + * + * @param string $key Array key under which the fields should be named + * @param array $values + * @return string + */ + protected function getInputHTMLForKey( $key, $values ) { + $displayFormat = isset( $this->mParams['format'] ) + ? $this->mParams['format'] + : $this->mParent->getDisplayFormat(); + + switch ( $displayFormat ) { + case 'table': + $getFieldHtmlMethod = 'getTableRow'; + break; + case 'vform': + // Close enough to a div. + $getFieldHtmlMethod = 'getDiv'; + break; + default: + $getFieldHtmlMethod = 'get' . ucfirst( $displayFormat ); + } + + $html = ''; + $hasLabel = false; + + $fields = $this->createFieldsForKey( $key ); + foreach ( $fields as $fieldname => $field ) { + $v = ( empty( $field->mParams['nodata'] ) && $values !== null ) + ? $values[$fieldname] + : $field->getDefault(); + $html .= $field->$getFieldHtmlMethod( $v ); + + $labelValue = trim( $field->getLabel() ); + if ( $labelValue != ' ' && $labelValue !== '' ) { + $hasLabel = true; + } + } + + if ( !isset( $fields['delete'] ) ) { + $name = "{$this->mName}[$key][delete]"; + $label = isset( $this->mParams['delete-button-message'] ) + ? $this->mParams['delete-button-message'] + : 'htmlform-cloner-delete'; + $field = HTMLForm::loadInputFromParameters( $name, array( + 'type' => 'submit', + 'name' => $name, + 'id' => Sanitizer::escapeId( "{$this->mID}--$key--delete" ), + 'cssclass' => 'mw-htmlform-cloner-delete-button', + 'default' => $this->msg( $label )->text(), + ) ); + $field->mParent = $this->mParent; + $v = $field->getDefault(); + + if ( $displayFormat === 'table' ) { + $html .= $field->$getFieldHtmlMethod( $v ); + } else { + $html .= $field->getInputHTML( $v ); + } + } + + if ( $displayFormat !== 'raw' ) { + $classes = array( + 'mw-htmlform-cloner-row', + ); + + if ( !$hasLabel ) { // Avoid strange spacing when no labels exist + $classes[] = 'mw-htmlform-nolabel'; + } + + $attribs = array( + 'class' => implode( ' ', $classes ), + ); + + if ( $displayFormat === 'table' ) { + $html = Html::rawElement( 'table', + $attribs, + Html::rawElement( 'tbody', array(), "\n$html\n" ) ) . "\n"; + } elseif ( $displayFormat === 'div' || $displayFormat === 'vform' ) { + $html = Html::rawElement( 'div', $attribs, "\n$html\n" ); + } + } + + if ( !empty( $this->mParams['row-legend'] ) ) { + $legend = $this->msg( $this->mParams['row-legend'] )->text(); + $html = Xml::fieldset( $legend, $html ); + } + + return $html; + } + + public function getInputHTML( $values ) { + $html = ''; + + foreach ( (array)$values as $key => $value ) { + if ( $key === 'nonjs' ) { + continue; + } + $html .= Html::rawElement( 'li', array( 'class' => 'mw-htmlform-cloner-li' ), + $this->getInputHTMLForKey( $key, $value ) + ); + } + + $template = $this->getInputHTMLForKey( $this->uniqueId, null ); + $html = Html::rawElement( 'ul', array( + 'id' => "mw-htmlform-cloner-list-{$this->mID}", + 'class' => 'mw-htmlform-cloner-ul', + 'data-template' => $template, + 'data-unique-id' => $this->uniqueId, + ), $html ); + + $name = "{$this->mName}[create]"; + $label = isset( $this->mParams['create-button-message'] ) + ? $this->mParams['create-button-message'] + : 'htmlform-cloner-create'; + $field = HTMLForm::loadInputFromParameters( $name, array( + 'type' => 'submit', + 'name' => $name, + 'id' => Sanitizer::escapeId( "{$this->mID}--create" ), + 'cssclass' => 'mw-htmlform-cloner-create-button', + 'default' => $this->msg( $label )->text(), + ) ); + $field->mParent = $this->mParent; + $html .= $field->getInputHTML( $field->getDefault() ); + + return $html; + } +} diff --git a/includes/htmlform/HTMLFormFieldRequiredOptionsException.php b/includes/htmlform/HTMLFormFieldRequiredOptionsException.php new file mode 100644 index 00000000..76f52866 --- /dev/null +++ b/includes/htmlform/HTMLFormFieldRequiredOptionsException.php @@ -0,0 +1,9 @@ +<?php + +class HTMLFormFieldRequiredOptionsException extends MWException { + public function __construct( HTMLFormField $field, array $missing ) { + parent::__construct( sprintf( "Form type `%s` expected the following parameters to be set: %s", + get_class( $field ), + implode( ', ', $missing ) ) ); + } +} diff --git a/includes/htmlform/HTMLHiddenField.php b/includes/htmlform/HTMLHiddenField.php new file mode 100644 index 00000000..e32c0bb2 --- /dev/null +++ b/includes/htmlform/HTMLHiddenField.php @@ -0,0 +1,44 @@ +<?php + +class HTMLHiddenField extends HTMLFormField { + public function __construct( $params ) { + parent::__construct( $params ); + + # Per HTML5 spec, hidden fields cannot be 'required' + # http://www.w3.org/TR/html5/forms.html#hidden-state-%28type=hidden%29 + unset( $this->mParams['required'] ); + } + + public function getTableRow( $value ) { + $params = array(); + if ( $this->mID ) { + $params['id'] = $this->mID; + } + + $this->mParent->addHiddenField( $this->mName, $this->mDefault, $params ); + + return ''; + } + + /** + * @param string $value + * @return string + * @since 1.20 + */ + public function getDiv( $value ) { + return $this->getTableRow( $value ); + } + + /** + * @param string $value + * @return string + * @since 1.20 + */ + public function getRaw( $value ) { + return $this->getTableRow( $value ); + } + + public function getInputHTML( $value ) { + return ''; + } +} diff --git a/includes/htmlform/HTMLInfoField.php b/includes/htmlform/HTMLInfoField.php new file mode 100644 index 00000000..a422047a --- /dev/null +++ b/includes/htmlform/HTMLInfoField.php @@ -0,0 +1,54 @@ +<?php + +/** + * An information field (text blob), not a proper input. + */ +class HTMLInfoField extends HTMLFormField { + public function __construct( $info ) { + $info['nodata'] = true; + + parent::__construct( $info ); + } + + public function getInputHTML( $value ) { + return !empty( $this->mParams['raw'] ) ? $value : htmlspecialchars( $value ); + } + + public function getTableRow( $value ) { + if ( !empty( $this->mParams['rawrow'] ) ) { + return $value; + } + + return parent::getTableRow( $value ); + } + + /** + * @param string $value + * @return string + * @since 1.20 + */ + public function getDiv( $value ) { + if ( !empty( $this->mParams['rawrow'] ) ) { + return $value; + } + + return parent::getDiv( $value ); + } + + /** + * @param string $value + * @return string + * @since 1.20 + */ + public function getRaw( $value ) { + if ( !empty( $this->mParams['rawrow'] ) ) { + return $value; + } + + return parent::getRaw( $value ); + } + + protected function needsLabel() { + return false; + } +} diff --git a/includes/htmlform/HTMLIntField.php b/includes/htmlform/HTMLIntField.php new file mode 100644 index 00000000..28876e2c --- /dev/null +++ b/includes/htmlform/HTMLIntField.php @@ -0,0 +1,27 @@ +<?php + +/** + * A field that must contain a number + */ +class HTMLIntField extends HTMLFloatField { + function validate( $value, $alldata ) { + $p = parent::validate( $value, $alldata ); + + if ( $p !== true ) { + return $p; + } + + # http://dev.w3.org/html5/spec/common-microsyntaxes.html#signed-integers + # with the addition that a leading '+' sign is ok. Note that leading zeros + # are fine, and will be left in the input, which is useful for things like + # phone numbers when you know that they are integers (the HTML5 type=tel + # input does not require its value to be numeric). If you want a tidier + # value to, eg, save in the DB, clean it up with intval(). + if ( !preg_match( '/^((\+|\-)?\d+)?$/', trim( $value ) ) + ) { + return $this->msg( 'htmlform-int-invalid' )->parseAsBlock(); + } + + return true; + } +} diff --git a/includes/htmlform/HTMLMultiSelectField.php b/includes/htmlform/HTMLMultiSelectField.php new file mode 100644 index 00000000..1b71ab95 --- /dev/null +++ b/includes/htmlform/HTMLMultiSelectField.php @@ -0,0 +1,122 @@ +<?php + +/** + * Multi-select field + */ +class HTMLMultiSelectField extends HTMLFormField implements HTMLNestedFilterable { + function validate( $value, $alldata ) { + $p = parent::validate( $value, $alldata ); + + if ( $p !== true ) { + return $p; + } + + if ( !is_array( $value ) ) { + return false; + } + + # If all options are valid, array_intersect of the valid options + # and the provided options will return the provided options. + $validOptions = HTMLFormField::flattenOptions( $this->getOptions() ); + + $validValues = array_intersect( $value, $validOptions ); + if ( count( $validValues ) == count( $value ) ) { + return true; + } else { + return $this->msg( 'htmlform-select-badoption' )->parse(); + } + } + + function getInputHTML( $value ) { + $value = HTMLFormField::forceToStringRecursive( $value ); + $html = $this->formatOptions( $this->getOptions(), $value ); + + return $html; + } + + function formatOptions( $options, $value ) { + $html = ''; + + $attribs = $this->getAttributes( array( 'disabled', 'tabindex' ) ); + $elementFunc = array( 'Html', $this->mOptionsLabelsNotFromMessage ? 'rawElement' : 'element' ); + + foreach ( $options as $label => $info ) { + if ( is_array( $info ) ) { + $html .= Html::rawElement( 'h1', array(), $label ) . "\n"; + $html .= $this->formatOptions( $info, $value ); + } else { + $thisAttribs = array( 'id' => "{$this->mID}-$info", 'value' => $info ); + + // @todo: Make this use checkLabel for consistency purposes + $checkbox = Xml::check( + $this->mName . '[]', + in_array( $info, $value, true ), + $attribs + $thisAttribs + ); + $checkbox .= ' ' . call_user_func( $elementFunc, + 'label', + array( 'for' => "{$this->mID}-$info" ), + $label + ); + + $html .= ' ' . Html::rawElement( + 'div', + array( 'class' => 'mw-htmlform-flatlist-item' ), + $checkbox + ); + } + } + + return $html; + } + + /** + * @param WebRequest $request + * + * @return string + */ + function loadDataFromRequest( $request ) { + if ( $this->mParent->getMethod() == 'post' ) { + if ( $request->wasPosted() ) { + # Checkboxes are just not added to the request arrays if they're not checked, + # so it's perfectly possible for there not to be an entry at all + return $request->getArray( $this->mName, array() ); + } else { + # That's ok, the user has not yet submitted the form, so show the defaults + return $this->getDefault(); + } + } else { + # This is the impossible case: if we look at $_GET and see no data for our + # field, is it because the user has not yet submitted the form, or that they + # have submitted it with all the options unchecked? We will have to assume the + # latter, which basically means that you can't specify 'positive' defaults + # for GET forms. + # @todo FIXME... + return $request->getArray( $this->mName, array() ); + } + } + + function getDefault() { + if ( isset( $this->mDefault ) ) { + return $this->mDefault; + } else { + return array(); + } + } + + function filterDataForSubmit( $data ) { + $data = HTMLFormField::forceToStringRecursive( $data ); + $options = HTMLFormField::flattenOptions( $this->getOptions() ); + + $res = array(); + foreach ( $options as $opt ) { + $res["$opt"] = in_array( $opt, $data, true ); + } + + return $res; + } + + protected function needsLabel() { + return false; + } +} diff --git a/includes/htmlform/HTMLNestedFilterable.php b/includes/htmlform/HTMLNestedFilterable.php new file mode 100644 index 00000000..2c09ea4e --- /dev/null +++ b/includes/htmlform/HTMLNestedFilterable.php @@ -0,0 +1,11 @@ +<?php + +interface HTMLNestedFilterable { + /** + * Support for seperating multi-option preferences into multiple preferences + * Due to lack of array support. + * + * @param array $data + */ + function filterDataForSubmit( $data ); +} diff --git a/includes/htmlform/HTMLRadioField.php b/includes/htmlform/HTMLRadioField.php new file mode 100644 index 00000000..8765407b --- /dev/null +++ b/includes/htmlform/HTMLRadioField.php @@ -0,0 +1,71 @@ +<?php + +/** + * Radio checkbox fields. + */ +class HTMLRadioField extends HTMLFormField { + function validate( $value, $alldata ) { + $p = parent::validate( $value, $alldata ); + + if ( $p !== true ) { + return $p; + } + + if ( !is_string( $value ) && !is_int( $value ) ) { + return false; + } + + $validOptions = HTMLFormField::flattenOptions( $this->getOptions() ); + + if ( in_array( strval( $value ), $validOptions, true ) ) { + return true; + } else { + return $this->msg( 'htmlform-select-badoption' )->parse(); + } + } + + /** + * This returns a block of all the radio options, in one cell. + * @see includes/HTMLFormField#getInputHTML() + * + * @param string $value + * + * @return string + */ + function getInputHTML( $value ) { + $html = $this->formatOptions( $this->getOptions(), strval( $value ) ); + + return $html; + } + + function formatOptions( $options, $value ) { + $html = ''; + + $attribs = $this->getAttributes( array( 'disabled', 'tabindex' ) ); + $elementFunc = array( 'Html', $this->mOptionsLabelsNotFromMessage ? 'rawElement' : 'element' ); + + # @todo Should this produce an unordered list perhaps? + foreach ( $options as $label => $info ) { + if ( is_array( $info ) ) { + $html .= Html::rawElement( 'h1', array(), $label ) . "\n"; + $html .= $this->formatOptions( $info, $value ); + } else { + $id = Sanitizer::escapeId( $this->mID . "-$info" ); + $radio = Xml::radio( $this->mName, $info, $info === $value, $attribs + array( 'id' => $id ) ); + $radio .= ' ' . call_user_func( $elementFunc, 'label', array( 'for' => $id ), $label ); + + $html .= ' ' . Html::rawElement( + 'div', + array( 'class' => 'mw-htmlform-flatlist-item' ), + $radio + ); + } + } + + return $html; + } + + protected function needsLabel() { + return false; + } +} diff --git a/includes/htmlform/HTMLSelectAndOtherField.php b/includes/htmlform/HTMLSelectAndOtherField.php new file mode 100644 index 00000000..65176dd7 --- /dev/null +++ b/includes/htmlform/HTMLSelectAndOtherField.php @@ -0,0 +1,126 @@ +<?php + +/** + * Double field with a dropdown list constructed from a system message in the format + * * Optgroup header + * ** <option value> + * * New Optgroup header + * Plus a text field underneath for an additional reason. The 'value' of the field is + * "<select>: <extra reason>", or "<extra reason>" if nothing has been selected in the + * select dropdown. + * @todo FIXME: If made 'required', only the text field should be compulsory. + */ +class HTMLSelectAndOtherField extends HTMLSelectField { + function __construct( $params ) { + if ( array_key_exists( 'other', $params ) ) { + } elseif ( array_key_exists( 'other-message', $params ) ) { + $params['other'] = wfMessage( $params['other-message'] )->plain(); + } else { + $params['other'] = wfMessage( 'htmlform-selectorother-other' )->plain(); + } + + parent::__construct( $params ); + + if ( $this->getOptions() === null ) { + # Sulk + throw new MWException( 'HTMLSelectAndOtherField called without any options' ); + } + if ( !in_array( 'other', $this->mOptions, true ) ) { + // Have 'other' always as first element + $this->mOptions = array( $params['other'] => 'other' ) + $this->mOptions; + } + $this->mFlatOptions = self::flattenOptions( $this->getOptions() ); + + } + + function getInputHTML( $value ) { + $select = parent::getInputHTML( $value[1] ); + + $textAttribs = array( + 'id' => $this->mID . '-other', + 'size' => $this->getSize(), + ); + + if ( $this->mClass !== '' ) { + $textAttribs['class'] = $this->mClass; + } + + $allowedParams = array( + 'required', + 'autofocus', + 'multiple', + 'disabled', + 'tabindex' + ); + + $textAttribs += $this->getAttributes( $allowedParams ); + + $textbox = Html::input( $this->mName . '-other', $value[2], 'text', $textAttribs ); + + return "$select<br />\n$textbox"; + } + + /** + * @param WebRequest $request + * + * @return array("<overall message>","<select value>","<text field value>") + */ + function loadDataFromRequest( $request ) { + if ( $request->getCheck( $this->mName ) ) { + + $list = $request->getText( $this->mName ); + $text = $request->getText( $this->mName . '-other' ); + + if ( $list == 'other' ) { + $final = $text; + } elseif ( !in_array( $list, $this->mFlatOptions, true ) ) { + # User has spoofed the select form to give an option which wasn't + # in the original offer. Sulk... + $final = $text; + } elseif ( $text == '' ) { + $final = $list; + } else { + $final = $list . $this->msg( 'colon-separator' )->inContentLanguage()->text() . $text; + } + } else { + $final = $this->getDefault(); + + $list = 'other'; + $text = $final; + foreach ( $this->mFlatOptions as $option ) { + $match = $option . $this->msg( 'colon-separator' )->inContentLanguage()->text(); + if ( strpos( $text, $match ) === 0 ) { + $list = $option; + $text = substr( $text, strlen( $match ) ); + break; + } + } + } + + return array( $final, $list, $text ); + } + + function getSize() { + return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 45; + } + + function validate( $value, $alldata ) { + # HTMLSelectField forces $value to be one of the options in the select + # field, which is not useful here. But we do want the validation further up + # the chain + $p = parent::validate( $value[1], $alldata ); + + if ( $p !== true ) { + return $p; + } + + if ( isset( $this->mParams['required'] ) + && $this->mParams['required'] !== false + && $value[1] === '' + ) { + return $this->msg( 'htmlform-required' )->parse(); + } + + return true; + } +} diff --git a/includes/htmlform/HTMLSelectField.php b/includes/htmlform/HTMLSelectField.php new file mode 100644 index 00000000..a198037a --- /dev/null +++ b/includes/htmlform/HTMLSelectField.php @@ -0,0 +1,44 @@ +<?php + +/** + * A select dropdown field. Basically a wrapper for Xmlselect class + */ +class HTMLSelectField extends HTMLFormField { + function validate( $value, $alldata ) { + $p = parent::validate( $value, $alldata ); + + if ( $p !== true ) { + return $p; + } + + $validOptions = HTMLFormField::flattenOptions( $this->getOptions() ); + + if ( in_array( strval( $value ), $validOptions, true ) ) { + return true; + } else { + return $this->msg( 'htmlform-select-badoption' )->parse(); + } + } + + function getInputHTML( $value ) { + $select = new XmlSelect( $this->mName, $this->mID, strval( $value ) ); + + if ( !empty( $this->mParams['disabled'] ) ) { + $select->setAttribute( 'disabled', 'disabled' ); + } + + $allowedParams = array( 'tabindex', 'size' ); + $customParams = $this->getAttributes( $allowedParams ); + foreach ( $customParams as $name => $value ) { + $select->setAttribute( $name, $value ); + } + + if ( $this->mClass !== '' ) { + $select->setAttribute( 'class', $this->mClass ); + } + + $select->addOptions( $this->getOptions() ); + + return $select->getHTML(); + } +} diff --git a/includes/htmlform/HTMLSelectLimitField.php b/includes/htmlform/HTMLSelectLimitField.php new file mode 100644 index 00000000..e7f1c047 --- /dev/null +++ b/includes/htmlform/HTMLSelectLimitField.php @@ -0,0 +1,35 @@ +<?php + +/** + * A limit dropdown, which accepts any valid number + */ +class HTMLSelectLimitField extends HTMLSelectField { + /** + * Basically don't do any validation. If it's a number that's fine. Also, + * add it to the list if it's not there already + * + * @param string $value + * @param array $alldata + * @return bool + */ + function validate( $value, $alldata ) { + if ( $value == '' ) { + return true; + } + + // Let folks pick an explicit limit not from our list, as long as it's a real numbr. + if ( !in_array( $value, $this->mParams['options'] ) + && $value == intval( $value ) + && $value > 0 + ) { + // This adds the explicitly requested limit value to the drop-down, + // then makes sure it's sorted correctly so when we output the list + // later, the custom option doesn't just show up last. + $this->mParams['options'][$this->mParent->getLanguage()->formatNum( $value )] = + intval( $value ); + asort( $this->mParams['options'] ); + } + + return true; + } +} diff --git a/includes/htmlform/HTMLSelectOrOtherField.php b/includes/htmlform/HTMLSelectOrOtherField.php new file mode 100644 index 00000000..cbf7d122 --- /dev/null +++ b/includes/htmlform/HTMLSelectOrOtherField.php @@ -0,0 +1,83 @@ +<?php + +/** + * Select dropdown field, with an additional "other" textbox. + */ +class HTMLSelectOrOtherField extends HTMLTextField { + function __construct( $params ) { + parent::__construct( $params ); + $this->getOptions(); + if ( !in_array( 'other', $this->mOptions, true ) ) { + $msg = + isset( $params['other'] ) + ? $params['other'] + : wfMessage( 'htmlform-selectorother-other' )->text(); + // Have 'other' always as first element + $this->mOptions = array( $msg => 'other' ) + $this->mOptions; + } + + } + + function getInputHTML( $value ) { + $valInSelect = false; + + if ( $value !== false ) { + $value = strval( $value ); + $valInSelect = in_array( + $value, HTMLFormField::flattenOptions( $this->getOptions() ), true + ); + } + + $selected = $valInSelect ? $value : 'other'; + + $select = new XmlSelect( $this->mName, $this->mID, $selected ); + $select->addOptions( $this->getOptions() ); + + $select->setAttribute( 'class', 'mw-htmlform-select-or-other' ); + + $tbAttribs = array( 'id' => $this->mID . '-other', 'size' => $this->getSize() ); + + if ( !empty( $this->mParams['disabled'] ) ) { + $select->setAttribute( 'disabled', 'disabled' ); + $tbAttribs['disabled'] = 'disabled'; + } + + if ( isset( $this->mParams['tabindex'] ) ) { + $select->setAttribute( 'tabindex', $this->mParams['tabindex'] ); + $tbAttribs['tabindex'] = $this->mParams['tabindex']; + } + + $select = $select->getHTML(); + + if ( isset( $this->mParams['maxlength'] ) ) { + $tbAttribs['maxlength'] = $this->mParams['maxlength']; + } + + if ( $this->mClass !== '' ) { + $tbAttribs['class'] = $this->mClass; + } + + $textbox = Html::input( $this->mName . '-other', $valInSelect ? '' : $value, 'text', $tbAttribs ); + + return "$select<br />\n$textbox"; + } + + /** + * @param WebRequest $request + * + * @return string + */ + function loadDataFromRequest( $request ) { + if ( $request->getCheck( $this->mName ) ) { + $val = $request->getText( $this->mName ); + + if ( $val === 'other' ) { + $val = $request->getText( $this->mName . '-other' ); + } + + return $val; + } else { + return $this->getDefault(); + } + } +} diff --git a/includes/htmlform/HTMLSubmitField.php b/includes/htmlform/HTMLSubmitField.php new file mode 100644 index 00000000..653c08c0 --- /dev/null +++ b/includes/htmlform/HTMLSubmitField.php @@ -0,0 +1,9 @@ +<?php + +/** + * Add a submit button inline in the form (as opposed to + * HTMLForm::addButton(), which will add it at the end). + */ +class HTMLSubmitField extends HTMLButtonField { + protected $buttonType = 'submit'; +} diff --git a/includes/htmlform/HTMLTextAreaField.php b/includes/htmlform/HTMLTextAreaField.php new file mode 100644 index 00000000..21173d2a --- /dev/null +++ b/includes/htmlform/HTMLTextAreaField.php @@ -0,0 +1,38 @@ +<?php + +class HTMLTextAreaField extends HTMLFormField { + const DEFAULT_COLS = 80; + const DEFAULT_ROWS = 25; + + function getCols() { + return isset( $this->mParams['cols'] ) ? $this->mParams['cols'] : static::DEFAULT_COLS; + } + + function getRows() { + return isset( $this->mParams['rows'] ) ? $this->mParams['rows'] : static::DEFAULT_ROWS; + } + + function getInputHTML( $value ) { + $attribs = array( + 'id' => $this->mID, + 'cols' => $this->getCols(), + 'rows' => $this->getRows(), + ) + $this->getTooltipAndAccessKey(); + + if ( $this->mClass !== '' ) { + $attribs['class'] = $this->mClass; + } + + $allowedParams = array( + 'placeholder', + 'tabindex', + 'disabled', + 'readonly', + 'required', + 'autofocus' + ); + + $attribs += $this->getAttributes( $allowedParams ); + return Html::textarea( $this->mName, $value, $attribs ); + } +} diff --git a/includes/htmlform/HTMLTextField.php b/includes/htmlform/HTMLTextField.php new file mode 100644 index 00000000..10bc67f0 --- /dev/null +++ b/includes/htmlform/HTMLTextField.php @@ -0,0 +1,65 @@ +<?php + +class HTMLTextField extends HTMLFormField { + function getSize() { + return isset( $this->mParams['size'] ) ? $this->mParams['size'] : 45; + } + + function getInputHTML( $value ) { + $attribs = array( + 'id' => $this->mID, + 'name' => $this->mName, + 'size' => $this->getSize(), + 'value' => $value, + ) + $this->getTooltipAndAccessKey(); + + if ( $this->mClass !== '' ) { + $attribs['class'] = $this->mClass; + } + + # @todo Enforce pattern, step, required, readonly on the server side as + # well + $allowedParams = array( + 'min', + 'max', + 'pattern', + 'title', + 'step', + 'placeholder', + 'list', + 'maxlength', + 'tabindex', + 'disabled', + 'required', + 'autofocus', + 'multiple', + 'readonly' + ); + + $attribs += $this->getAttributes( $allowedParams ); + + # Implement tiny differences between some field variants + # here, rather than creating a new class for each one which + # is essentially just a clone of this one. + $type = 'text'; + if ( isset( $this->mParams['type'] ) ) { + switch ( $this->mParams['type'] ) { + case 'int': + $type = 'number'; + break; + case 'float': + $type = 'number'; + $attribs['step'] = 'any'; + break; + # Pass through + case 'email': + case 'password': + case 'file': + case 'url': + $type = $this->mParams['type']; + break; + } + } + return Html::input( $this->mName, $value, $type, $attribs ); + } +} |