HTMLForm: Add hide-if
authorBrad Jorsch <bjorsch@wikimedia.org>
Thu, 27 Feb 2014 17:14:38 +0000 (12:14 -0500)
committerOri.livneh <ori@wikimedia.org>
Fri, 2 May 2014 14:57:34 +0000 (14:57 +0000)
SecurePoll will need a way to display a field only if another field has
a particular value.

We already have this for a limited case in HTMLSelectOrOtherField; this
makes it possible to specify that any particular field should be hidden
based on any other field.

Change-Id: I5d2e6fb1efba0ad97647ac140e2b9a9ac0aee06e

includes/htmlform/HTMLFormField.php
resources/src/mediawiki/mediawiki.htmlform.js

index fdb3924..204020d 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,6 +63,184 @@ 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 to add specific validation checks on the
         * field input.  Don't forget to call parent::validate() to ensure
@@ -73,6 +252,10 @@ abstract class HTMLFormField {
         * @return bool|string true on success, or String error to display.
         */
        function validate( $value, $alldata ) {
+               if ( $this->isHidden( $alldata ) ) {
+                       return true;
+               }
+
                if ( isset( $this->mParams['required'] )
                        && $this->mParams['required'] !== false
                        && $value === ''
@@ -213,6 +396,10 @@ abstract class HTMLFormField {
                if ( isset( $params['hidelabel'] ) ) {
                        $this->mShowEmptyLabels = false;
                }
+
+               if ( isset( $params['hide-if'] ) ) {
+                       $this->mHideIf = $params['hide-if'];
+               }
        }
 
        /**
@@ -229,6 +416,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 +434,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 +490,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 +540,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 +565,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;
        }
index 5ba1a54..def29fc 100644 (file)
@@ -5,6 +5,175 @@
  */
 ( function ( mw, $ ) {
 
+       /**
+        * 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.
         *
                        }
                } );
 
+               // Set up hide-if elements
+               $( '.mw-htmlform-hide-if' ).each( function () {
+                       var $el = $( this ),
+                               spec = $el.data( 'hideIf' ),
+                               v, $fields, test, func;
+
+                       if ( !spec ) {
+                               return;
+                       }
+
+                       v = hideIfParse( $el, spec );
+                       $fields = v[0];
+                       test = v[1];
+                       func = function () {
+                               if ( test() ) {
+                                       $el.hide();
+                               } else {
+                                       $el.show();
+                               }
+                       };
+                       $fields.change( func );
+                       func();
+               } );
+
        } );
 
        function addMulti( $oldContainer, $container ) {