Merge "Fix API login after I7c957e1e"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 2 May 2014 15:53:59 +0000 (15:53 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 2 May 2014 15:53:59 +0000 (15:53 +0000)
RELEASE-NOTES-1.24
includes/AutoLoader.php
includes/htmlform/HTMLForm.php
includes/htmlform/HTMLFormField.php
includes/htmlform/HTMLFormFieldCloner.php [new file with mode: 0644]
languages/i18n/en.json
languages/i18n/qqq.json
maintenance/language/messages.inc
resources/src/mediawiki/mediawiki.htmlform.js

index aaabb6b..fc4a357 100644 (file)
@@ -13,6 +13,12 @@ production.
 === New features in 1.24 ===
 * Added a new hook, "WhatLinksHereProps", to allow extensions to annotate
   WhatLinksHere entries.
+* HTMLForm's HTMLTextField now supports the 'url' type.
+* HTMLForm fields may now be dynamically hidden based on the values of other
+  fields in the form.
+* HTMLForm now supports multiple copies of an input field or set of input
+  fields, e.g. the form may request "one or more usernames" without having to
+  have the user enter delimited list of names into a text field.
 
 === Bug fixes in 1.24 ===
 * (bug 62258) A bug was fixed in File::getUnscaledThumb when a height
index d7416ec..bfee420 100644 (file)
@@ -89,6 +89,7 @@ $wgAutoloadLocalClasses = array(
        'HTMLButtonField' => 'includes/htmlform/HTMLButtonField.php',
        'HTMLCheckField' => 'includes/htmlform/HTMLCheckField.php',
        'HTMLCheckMatrix' => 'includes/htmlform/HTMLCheckMatrix.php',
+       'HTMLFormFieldCloner' => 'includes/htmlform/HTMLFormFieldCloner.php',
        'HTMLEditTools' => 'includes/htmlform/HTMLEditTools.php',
        'HTMLFloatField' => 'includes/htmlform/HTMLFloatField.php',
        'HTMLForm' => 'includes/htmlform/HTMLForm.php',
index 019eb2b..01f3ab7 100644 (file)
@@ -117,6 +117,7 @@ class HTMLForm extends ContextSource {
                'hidden' => 'HTMLHiddenField',
                'edittools' => 'HTMLEditTools',
                'checkmatrix' => 'HTMLCheckMatrix',
+               'cloner' => 'HTMLFormFieldCloner',
                // 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.
@@ -155,6 +156,7 @@ class HTMLForm extends ContextSource {
 
        protected $mTitle;
        protected $mMethod = 'post';
+       protected $mWasSubmitted = false;
 
        /**
         * Form action URL. false means we will use the URL to set Title
@@ -411,6 +413,7 @@ class HTMLForm extends ContextSource {
                }
 
                if ( $submit ) {
+                       $this->mWasSubmitted = true;
                        $result = $this->trySubmit();
                }
 
@@ -445,6 +448,19 @@ class HTMLForm extends ContextSource {
         *     display.
         */
        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'] ) ) {
@@ -470,10 +486,28 @@ class HTMLForm extends ContextSource {
                $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.
index fdb3924..0e1860b 100644 (file)
@@ -16,6 +16,7 @@ abstract class HTMLFormField {
        protected $mDefault;
        protected $mOptions = false;
        protected $mOptionsLabelsNotFromMessage = false;
+       protected $mHideIf = null;
 
        /**
         * @var bool If true will generate an empty div element with no label
@@ -62,17 +63,214 @@ abstract class HTMLFormField {
                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 = $data;
+                       break;
+               }
+
+               return $testValue;
+       }
+
+       /**
+        * Helper function for isHidden to handle recursive data structures.
+        *
+        * @param array $alldata
+        * @param array $params
+        * @return boolean
+        */
+       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 $value The value the field was submitted with
+        * @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.
+        * @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 === ''
@@ -173,6 +371,7 @@ abstract class HTMLFormField {
                }
 
                $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__ );
                }
@@ -213,6 +412,10 @@ abstract class HTMLFormField {
                if ( isset( $params['hidelabel'] ) ) {
                        $this->mShowEmptyLabels = false;
                }
+
+               if ( isset( $params['hide-if'] ) ) {
+                       $this->mHideIf = $params['hide-if'];
+               }
        }
 
        /**
@@ -229,6 +432,8 @@ abstract class HTMLFormField {
                $fieldType = get_class( $this );
                $helptext = $this->getHelpTextHtmlTable( $this->getHelpText() );
                $cellAttributes = array();
+               $rowAttributes = array();
+               $rowClasses = '';
 
                if ( !empty( $this->mParams['vertical-label'] ) ) {
                        $cellAttributes['colspan'] = 2;
@@ -245,15 +450,25 @@ abstract class HTMLFormField {
                        $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', array( 'class' => 'mw-htmlform-vertical-label' ), $label );
+                       $html = Html::rawElement( 'tr',
+                               $rowAttributes + array( 'class' => "mw-htmlform-vertical-label $rowClasses" ), $label );
                        $html .= Html::rawElement( 'tr',
-                               array( 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass" ),
+                               $rowAttributes + array(
+                                       'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
+                               ),
                                $field );
                } else {
                        $html =
                                Html::rawElement( 'tr',
-                                       array( 'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass" ),
+                                       $rowAttributes + array(
+                                               'class' => "mw-htmlform-field-$fieldType {$this->mClass} $errorClass $rowClasses"
+                                       ),
                                        $label . $field );
                }
 
@@ -291,7 +506,15 @@ abstract class HTMLFormField {
                if ( $this->mParent->isVForm() ) {
                        $divCssClasses[] = 'mw-ui-vform-div';
                }
-               $html = Html::rawElement( 'div', array( 'class' => $divCssClasses ), $label . $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;
@@ -333,8 +556,14 @@ abstract class HTMLFormField {
                        return '';
                }
 
+               $rowAttributes = array();
+               if ( $this->mHideIf ) {
+                       $rowAttributes['data-hide-if'] = FormatJson::encode( $this->mHideIf );
+                       $rowAttributes['class'] = 'mw-htmlform-hide-if';
+               }
+
                $row = Html::rawElement( 'td', array( 'colspan' => 2, 'class' => 'htmlform-tip' ), $helptext );
-               $row = Html::rawElement( 'tr', array(), $row );
+               $row = Html::rawElement( 'tr', $rowAttributes, $row );
 
                return $row;
        }
@@ -352,7 +581,14 @@ abstract class HTMLFormField {
                        return '';
                }
 
-               $div = Html::rawElement( 'div', array( 'class' => 'htmlform-tip' ), $helptext );
+               $wrapperAttributes = array(
+                       'class' => 'htmlform-tip',
+               );
+               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;
        }
@@ -411,9 +647,7 @@ abstract class HTMLFormField {
        public function getErrorsAndErrorClass( $value ) {
                $errors = $this->validate( $value, $this->mParent->mFieldData );
 
-               if ( $errors === true ||
-                       ( !$this->mParent->getRequest()->wasPosted() && $this->mParent->getMethod() === 'post' )
-               ) {
+               if ( is_bool( $errors ) || !$this->mParent->wasSubmitted() ) {
                        $errors = '';
                        $errorClass = '';
                } else {
diff --git a/includes/htmlform/HTMLFormFieldCloner.php b/includes/htmlform/HTMLFormFieldCloner.php
new file mode 100644 (file)
index 0000000..597a03f
--- /dev/null
@@ -0,0 +1,380 @@
+<?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 HTMLFormFields[]
+        */
+       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 != '&#160;' && $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(),
+                       ) );
+                       $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(),
+               ) );
+               $html .= $field->getInputHTML( $field->getDefault() );
+
+               return $html;
+       }
+}
index 54d550a..1f5fe37 100644 (file)
     "htmlform-no": "No",
     "htmlform-yes": "Yes",
     "htmlform-chosen-placeholder": "Select an option",
+       "htmlform-cloner-create": "Add more",
+       "htmlform-cloner-delete": "Remove",
+       "htmlform-cloner-required": "At least one value is required.",
     "sqlite-has-fts": "$1 with full-text search support",
     "sqlite-no-fts": "$1 without full-text search support",
     "logentry-delete-delete": "$1 {{GENDER:$2|deleted}} page $3",
index 58a2a64..028d155 100644 (file)
        "htmlform-no": "Used in form, such as with radio buttons, for generic yes / no questions.\n{{Identical|No}}",
        "htmlform-yes": "Used in form, such as with radio buttons, for generic yes / no questions.\n{{Identical|Yes}}",
        "htmlform-chosen-placeholder": "Used as initial placeholder text in select multiple \"chosen\" input boxes",
+       "htmlform-cloner-create": "Used as the text for the button that adds a row to a multi-input HTML form element\n\nSee also:\n* {{msg-mw|htmlform-cloner-delete}}\n{{msg-mw|htmlform-cloner-required}}",
+       "htmlform-cloner-delete": "Used as the text for the button that removes a row from a multi-input HTML form element\n\nSee also:\n* {{msg-mw|htmlform-cloner-create}}\n{{msg-mw|htmlform-cloner-required}}",
+       "htmlform-cloner-required": "Used as an error message in HTML forms.\n\nSee also:\n* {{msg-mw|htmlform-required}}\n{{msg-mw|htmlform-cloner-create}}\n{{msg-mw|htmlform-cloner-delete}}",
        "sqlite-has-fts": "Shown on [[Special:Version]].\nParameters:\n* $1 - version",
        "sqlite-no-fts": "Shown on [[Special:Version]].\nParameters:\n* $1 - version",
        "logentry-delete-delete": "{{Logentry|[[Special:Log/delete]]}}",
index 500d7de..62d1fa6 100644 (file)
@@ -3851,6 +3851,9 @@ $wgMessageStructure = array(
                'htmlform-no',
                'htmlform-yes',
                'htmlform-chosen-placeholder',
+               'htmlform-cloner-create',
+               'htmlform-cloner-delete',
+               'htmlform-cloner-required',
        ),
        'sqlite' => array(
                'sqlite-has-fts',
index 5ba1a54..f7aa7f8 100644 (file)
@@ -5,6 +5,177 @@
  */
 ( function ( mw, $ ) {
 
+       var cloneCounter = 0;
+
+       /**
+        * Helper function for hide-if to find the nearby form field.
+        *
+        * Find the closest match for the given name, "closest" being the minimum
+        * level of parents to go to find a form field matching the given name or
+        * ending in array keys matching the given name (e.g. "baz" matches
+        * "foo[bar][baz]").
+        *
+        * @param {jQuery} element
+        * @param {string} name
+        * @return {jQuery|null}
+        */
+       function hideIfGetField( $el, name ) {
+               var sel, $found, $p;
+
+               sel = '[name="' + name + '"],' +
+                       '[name="wp' + name + '"],' +
+                       '[name$="' + name.replace( /^([^\[]+)/, '[$1]' ) + '"]';
+               for ( $p = $el.parent(); $p.length > 0; $p = $p.parent() ) {
+                       $found = $p.find( sel );
+                       if ( $found.length > 0 ) {
+                               return $found;
+                       }
+               }
+               return null;
+       }
+
+       /**
+        * Helper function for hide-if to return a test function and list of
+        * dependent fields for a hide-if specification.
+        *
+        * @param {jQuery} element
+        * @param {Array} hide-if spec
+        * @return {Array} 2 elements: jQuery of dependent fields, and test function
+        */
+       function hideIfParse( $el, spec ) {
+               var op, i, l, v, $field, $fields, func, funcs, getVal;
+
+               op = spec[0];
+               l = spec.length;
+               switch ( op ) {
+                       case 'AND':
+                       case 'OR':
+                       case 'NAND':
+                       case 'NOR':
+                               funcs = [];
+                               $fields = $();
+                               for ( i = 1; i < l; i++ ) {
+                                       if ( !$.isArray( spec[i] ) ) {
+                                               throw new Error( op + ' parameters must be arrays' );
+                                       }
+                                       v = hideIfParse( $el, spec[i] );
+                                       $fields = $fields.add( v[0] );
+                                       funcs.push( v[1] );
+                               }
+
+                               l = funcs.length;
+                               switch ( op ) {
+                                       case 'AND':
+                                               func = function () {
+                                                       var i;
+                                                       for ( i = 0; i < l; i++ ) {
+                                                               if ( !funcs[i]() ) {
+                                                                       return false;
+                                                               }
+                                                       }
+                                                       return true;
+                                               };
+                                               break;
+
+                                       case 'OR':
+                                               func = function () {
+                                                       var i;
+                                                       for ( i = 0; i < l; i++ ) {
+                                                               if ( funcs[i]() ) {
+                                                                       return true;
+                                                               }
+                                                       }
+                                                       return false;
+                                               };
+                                               break;
+
+                                       case 'NAND':
+                                               func = function () {
+                                                       var i;
+                                                       for ( i = 0; i < l; i++ ) {
+                                                               if ( !funcs[i]() ) {
+                                                                       return true;
+                                                               }
+                                                       }
+                                                       return false;
+                                               };
+                                               break;
+
+                                       case 'NOR':
+                                               func = function () {
+                                                       var i;
+                                                       for ( i = 0; i < l; i++ ) {
+                                                               if ( funcs[i]() ) {
+                                                                       return false;
+                                                               }
+                                                       }
+                                                       return true;
+                                               };
+                                               break;
+                               }
+
+                               return [ $fields, func ];
+
+                       case 'NOT':
+                               if ( l !== 2 ) {
+                                       throw new Error( 'NOT takes exactly one parameter' );
+                               }
+                               if ( !$.isArray( spec[1] ) ) {
+                                       throw new Error( 'NOT parameters must be arrays' );
+                               }
+                               v = hideIfParse( $el, spec[1] );
+                               $fields = v[0];
+                               func = v[1];
+                               return [ $fields, function () {
+                                       return !func();
+                               } ];
+
+                       case '===':
+                       case '!==':
+                               if ( l !== 3 ) {
+                                       throw new Error( op + ' takes exactly two parameters' );
+                               }
+                               $field = hideIfGetField( $el, spec[1] );
+                               if ( !$field ) {
+                                       return [ $(), function () {
+                                               return false;
+                                       } ];
+                               }
+                               v = spec[2];
+
+                               if ( $field.first().attr( 'type' ) === 'radio' ||
+                                       $field.first().attr( 'type' ) === 'checkbox'
+                               ) {
+                                       getVal = function () {
+                                               var $selected = $field.filter( ':checked' );
+                                               return $selected.length > 0 ? $selected.val() : '';
+                                       };
+                               } else {
+                                       getVal = function () {
+                                               return $field.val();
+                                       };
+                               }
+
+                               switch ( op ) {
+                                       case '===':
+                                               func = function () {
+                                                       return getVal() === v;
+                                               };
+                                               break;
+                                       case '!==':
+                                               func = function () {
+                                                       return getVal() !== v;
+                                               };
+                                               break;
+                               }
+
+                               return [ $field, func ];
+
+                       default:
+                               throw new Error( 'Unrecognized operation \'' + op + '\'' );
+               }
+       }
+
        /**
         * jQuery plugin to fade or snap to visible state.
         *
                        } );
        };
 
-       $( function () {
+       function enhance( $root ) {
 
                // Animate the SelectOrOther fields, to only show the text field when
                // 'other' is selected.
-               $( '.mw-htmlform-select-or-other' ).liveAndTestAtStart( function ( instant ) {
-                       var $other = $( '#' + $( this ).attr( 'id' ) + '-other' );
+               $root.find( '.mw-htmlform-select-or-other' ).liveAndTestAtStart( function ( instant ) {
+                       var $other = $root.find( '#' + $( this ).attr( 'id' ) + '-other' );
                        $other = $other.add( $other.siblings( 'br' ) );
                        if ( $( this ).val() === 'other' ) {
                                $other.goIn( instant );
                        }
                } );
 
-       } );
+               // Set up hide-if elements
+               $root.find( '.mw-htmlform-hide-if' ).each( function () {
+                       var $el = $( this ),
+                               spec = $el.data( 'hideIf' ),
+                               v, $fields, test, func;
 
-       function addMulti( $oldContainer, $container ) {
-               var name = $oldContainer.find( 'input:first-child' ).attr( 'name' ),
-                       oldClass = ( ' ' + $oldContainer.attr( 'class' ) + ' ' ).replace( /(mw-htmlform-field-HTMLMultiSelectField|mw-chosen)/g, '' ),
-                       $select = $( '<select>' ),
-                       dataPlaceholder = mw.message( 'htmlform-chosen-placeholder' );
-               oldClass = $.trim( oldClass );
-               $select.attr( {
-                       name: name,
-                       multiple: 'multiple',
-                       'data-placeholder': dataPlaceholder.plain(),
-                       'class': 'htmlform-chzn-select mw-input ' + oldClass
-               } );
-               $oldContainer.find( 'input' ).each( function () {
-                       var $oldInput = $( this ),
-                       checked = $oldInput.prop( 'checked' ),
-                       $option = $( '<option>' );
-                       $option.prop( 'value', $oldInput.prop( 'value' ) );
-                       if ( checked ) {
-                               $option.prop( 'selected', true );
+                       if ( !spec ) {
+                               return;
                        }
-                       $option.text( $oldInput.prop( 'value' ) );
-                       $select.append( $option );
+
+                       v = hideIfParse( $el, spec );
+                       $fields = v[0];
+                       test = v[1];
+                       func = function () {
+                               if ( test() ) {
+                                       $el.hide();
+                               } else {
+                                       $el.show();
+                               }
+                       };
+                       $fields.change( func );
+                       func();
                } );
-               $container.append( $select );
-       }
 
-       function convertCheckboxesToMulti( $oldContainer, type ) {
-               var $fieldLabel = $( '<td>' ),
-               $td = $( '<td>' ),
-               $fieldLabelText = $( '<label>' ),
-               $container;
-               if ( type === 'tr' ) {
-                       addMulti( $oldContainer, $td );
-                       $container = $( '<tr>' );
-                       $container.append( $td );
-               } else if ( type === 'div' ) {
-                       $fieldLabel = $( '<div>' );
-                       $container = $( '<div>' );
-                       addMulti( $oldContainer, $container );
+               function addMulti( $oldContainer, $container ) {
+                       var name = $oldContainer.find( 'input:first-child' ).attr( 'name' ),
+                               oldClass = ( ' ' + $oldContainer.attr( 'class' ) + ' ' ).replace( /(mw-htmlform-field-HTMLMultiSelectField|mw-chosen)/g, '' ),
+                               $select = $( '<select>' ),
+                               dataPlaceholder = mw.message( 'htmlform-chosen-placeholder' );
+                       oldClass = $.trim( oldClass );
+                       $select.attr( {
+                               name: name,
+                               multiple: 'multiple',
+                               'data-placeholder': dataPlaceholder.plain(),
+                               'class': 'htmlform-chzn-select mw-input ' + oldClass
+                       } );
+                       $oldContainer.find( 'input' ).each( function () {
+                               var $oldInput = $( this ),
+                               checked = $oldInput.prop( 'checked' ),
+                               $option = $( '<option>' );
+                               $option.prop( 'value', $oldInput.prop( 'value' ) );
+                               if ( checked ) {
+                                       $option.prop( 'selected', true );
+                               }
+                               $option.text( $oldInput.prop( 'value' ) );
+                               $select.append( $option );
+                       } );
+                       $container.append( $select );
                }
-               $fieldLabel.attr( 'class', 'mw-label' );
-               $fieldLabelText.text( $oldContainer.find( '.mw-label label' ).text() );
-               $fieldLabel.append( $fieldLabelText );
-               $container.prepend( $fieldLabel );
-               $oldContainer.replaceWith( $container );
-               return $container;
-       }
 
-       if ( $( '.mw-chosen' ).length ) {
-               mw.loader.using( 'jquery.chosen', function () {
-                       $( '.mw-chosen' ).each( function () {
-                               var type = this.nodeName.toLowerCase(),
-                                       $converted = convertCheckboxesToMulti( $( this ), type );
-                               $converted.find( '.htmlform-chzn-select' ).chosen( { width: 'auto' } );
+               function convertCheckboxesToMulti( $oldContainer, type ) {
+                       var $fieldLabel = $( '<td>' ),
+                       $td = $( '<td>' ),
+                       $fieldLabelText = $( '<label>' ),
+                       $container;
+                       if ( type === 'tr' ) {
+                               addMulti( $oldContainer, $td );
+                               $container = $( '<tr>' );
+                               $container.append( $td );
+                       } else if ( type === 'div' ) {
+                               $fieldLabel = $( '<div>' );
+                               $container = $( '<div>' );
+                               addMulti( $oldContainer, $container );
+                       }
+                       $fieldLabel.attr( 'class', 'mw-label' );
+                       $fieldLabelText.text( $oldContainer.find( '.mw-label label' ).text() );
+                       $fieldLabel.append( $fieldLabelText );
+                       $container.prepend( $fieldLabel );
+                       $oldContainer.replaceWith( $container );
+                       return $container;
+               }
+
+               if ( $root.find( '.mw-chosen' ).length ) {
+                       mw.loader.using( 'jquery.chosen', function () {
+                               $root.find( '.mw-chosen' ).each( function () {
+                                       var type = this.nodeName.toLowerCase(),
+                                               $converted = convertCheckboxesToMulti( $( this ), type );
+                                       $converted.find( '.htmlform-chzn-select' ).chosen( { width: 'auto' } );
+                               } );
                        } );
-               } );
-       }
+               }
 
-       $( function () {
-               var $matrixTooltips = $( '.mw-htmlform-matrix .mw-htmlform-tooltip' );
+               var $matrixTooltips = $root.find( '.mw-htmlform-matrix .mw-htmlform-tooltip' );
                if ( $matrixTooltips.length ) {
                        mw.loader.using( 'jquery.tipsy', function () {
                                $matrixTooltips.tipsy( { gravity: 's' } );
                        } );
                }
+
+               // Add/remove cloner clones without having to resubmit the form
+               $root.find( '.mw-htmlform-cloner-delete-button' ).click( function ( ev ) {
+                       ev.preventDefault();
+                       $( this ).closest( 'li.mw-htmlform-cloner-li' ).remove();
+               } );
+
+               $root.find( '.mw-htmlform-cloner-create-button' ).click( function ( ev ) {
+                       var $ul, $li, html;
+
+                       ev.preventDefault();
+
+                       $ul = $( this ).prev( 'ul.mw-htmlform-cloner-ul' );
+
+                       html = $ul.data( 'template' ).replace(
+                               $ul.data( 'uniqueId' ), 'clone' + ( ++cloneCounter ), 'g'
+                       );
+
+                       $li = $( '<li>' )
+                               .addClass( 'mw-htmlform-cloner-li' )
+                               .html( html )
+                               .appendTo( $ul );
+
+                       enhance( $li );
+               } );
+
+               mw.hook( 'htmlform.enhance' ).fire( $root );
+
+       }
+
+       $( function () {
+               enhance( $( document ) );
        } );
 
        /**