spivurno
1/5/2016 - 5:52 AM

Gravity Wiz // Gravity Forms // Chained Selects for List Fields

Gravity Wiz // Gravity Forms // Chained Selects for List Fields

<?php
/**
 * Gravity Wiz // Gravity Forms // Chained Selects for List Fields
 *
 * Convert List Field inputs into selects and allow them to be chained.
 *
 * @version   1.13
 * @author    David Smith <david@gravitywiz.com>
 * @license   GPL-2.0+
 * @link      http://gravitywiz.com/...
 * @copyright 2015 Gravity Wiz
 */
class GW_List_Field_Chained_Selects {

	private $_choices         = null;
	private $_enable_product  = null;
	private $_product_id      = null;
	private $_input_counter   = 0;

    protected static $is_script_output = false;

    public function __construct( $args = array() ) {

        // set our default arguments, parse against the provided arguments, and store for use throughout the class
        $this->_args = wp_parse_args( $args, array(
            'form_id'  => false,
            'field_id' => false
        ) );

        // do version check in the init to make sure if GF is going to be loaded, it is already loaded
        add_action( 'init', array( $this, 'init' ) );

    }

    function init() {

        // make sure we're running the required minimum version of Gravity Forms
        if( ! property_exists( 'GFCommon', 'version' ) || ! version_compare( GFCommon::$version, '1.8', '>=' ) ) {
            return;
        }

        // rendering
	    add_filter( 'gform_form_post_get_meta',    array( $this, 'add_product_field' ) );
	    add_filter( 'gform_pre_render',            array( $this, 'load_form_script' ) );
	    add_filter( 'gform_register_init_scripts', array( $this, 'add_init_script' ) );
	    add_filter( 'gform_column_input',          array( $this, 'modify_list_field_input_type' ), 10, 6 );
	    add_filter( 'gform_list_field_parameter_delimiter', array( $this, 'set_custom_list_field_delimiter' ), 10, 2 );

	    // submission
	    add_filter( 'gform_product_info', array( $this, 'add_list_field_products' ), 20, 3 );

	    // magic
	    add_action( 'wp_ajax_gwlfcs_get_next_chained_select_choices',        array( $this, 'ajax_get_next_chained_select_choices' ) );
	    add_action( 'wp_ajax_nopriv_gwlfcs_get_next_chained_select_choices', array( $this, 'ajax_get_next_chained_select_choices' ) );

    }

    function load_form_script( $form ) {

        if( $this->is_applicable_form( $form ) && ! has_action( 'wp_footer', array( __class__, 'output_script' ) ) ) {
            add_action( 'wp_footer', array( __class__, 'output_script' ) );
            add_action( 'gform_footer', array( __class__, 'output_script' ) );
        }

        return $form;
    }

    static function output_script() {
        ?>

        <script type="text/javascript">

            ( function( $ ) {

                window.GWListFieldChainedSelects = function( args ) {

	                var self = this;

	                // copy all args to current object: (list expected props)
	                for( prop in args ) {
		                if( args.hasOwnProperty( prop ) ) {
			                self[prop] = args[prop];
		                }
	                }

	                var $form    = $( '#gform_' + self.formId ),
		                $field   = $( '#field_' + self.formId + '_' + self.fieldId ),
		                $product = $( '#ginput_base_price_' + self.formId + '_' + self.productFieldId );

	                self.init = function() {

		                $form.on( 'submit', function() {
			                $field.find( 'select' ).prop( 'disabled', false );
		                } );

		                $field.on( 'change', self.getSelectSelectors().join( ',' ), function() {

			                var $select = $( this ),
				                inputId = self.getInputId( $select );

			                self.populateNextChoices( inputId, $select.val(), $select );
			                self.updatePricing();

			                //$( document ).trigger( 'gform_post_conditional_logic' );

		                } );

		                gform.addFilter( 'gform_list_item_pre_add', function( $clone ) {
			                var selectors = self.getSelectSelectors();
			                selectors.shift();
			                $clone.find( selectors.join( ',' ) ).prop( 'disabled', true );
			                return $clone;
		                } );

		                $field.find( 'img.delete_list_item' ).data( 'onclick', $field.find( 'img.delete_list_item' ).attr( 'onclick' ) ).attr( 'onclick', '' );
		                $field.on( 'click', 'img.delete_list_item', function() {
			                $( this ).parents( '.gfield_list_group' ).detach();
			                self.updatePricing();
			                eval( $field.find( 'img.delete_list_item' ).data( 'onclick' ) );
		                } );

		                self.initSelects();
		                self.updatePricing();

		                $( document ).bind( 'gform_post_conditional_logic', function() {
			                self.updatePricing();
		                } );

	                };

	                self.getInputId = function( $select ) {

		                var $parent = $select.parents( '.gfield_list_cell' ),
			                $row    = $parent.parents( '.gfield_list_group' ),
			                inputId = $row.find( '.gfield_list_cell' ).index( $parent ) + 1;

		                return inputId;
	                };

	                self.populateNextChoices = function( inputId, selectedValue, $select ) {

		                var nextInputId = self.getNextInputId( inputId ),
			                $nextSelect = self.$selects( $select ).filter( '.gfield_list_' + self.fieldId + '_cell' + nextInputId + ' select' );

		                // if there is no $nextSelect, we're at the end of our chain
		                if( $nextSelect.length <= 0 ) {
			                self.resetSelects( $select, true );
			                return;
		                } else {
			                self.resetSelects( $select );
		                }

		                if( ! selectedValue ) {
			                return;
		                }

		                var loadingText     = 'Loading',
			                $loadingOption  = $( '<option value="">' + loadingText + '...</option>' ),
			                dotCount        = 2,
			                loadingInterval = setInterval( function() {
				                $loadingOption.text( loadingText + ( new Array( dotCount ).join( '.' ) )  );
				                dotCount = dotCount > 3 ? 0 : dotCount + 1;
			                }, 250 );

		                $loadingOption.prependTo( $nextSelect ).prop( 'selected', true );
		                $nextSelect.css( { minWidth: $nextSelect.width() } );
		                $loadingOption.text( loadingText + '.' );

		                $.post( self.ajaxUrl, {
			                action:        'gwlfcs_get_next_chained_select_choices',
			                input_id:      inputId,
			                next_input_id: self.getNextInputId( inputId ),
			                form_id:       self.formId,
			                field_id:      self.fieldId,
			                value:         self.getChainedSelectsValue( $select )
		                }, function( response ) {

			                clearInterval( loadingInterval );
			                $loadingOption.remove();

			                if( ! response ) {
				                return;
			                }

			                var choices       = $.parseJSON( response ),
				                optionsMarkup = '';

			                $nextSelect.find( 'option:not(:first)' ).remove();

			                if( choices.length <= 0 ) {

				                self.resetSelects( $select, true );

			                } else {

				                $.each( choices, function( i, choice ) {
					                optionsMarkup += '<option value="' + choice.value + '">' + choice.text + '</option>';
				                } );

				                $nextSelect.show().append( optionsMarkup );

				                // the placeholder will be selected by default, rather than removing it and re-adding, just force the noOptions option to be selected
				                if( choices[0].noOptions ) {

					                var $noOption = $nextSelect.find( 'option:last-child' ).clone(),
						                $nextSelects = $nextSelect.parents( 'span' ).nextAll().find( 'select' );

					                $nextSelects.append( $noOption );

					                $nextSelects.add( $nextSelect )
						                .addClass( 'gf_no_options' )
						                .find( 'option:last-child' )
						                .prop( 'selected', true );

				                } else {
					                $nextSelect
						                .removeClass( 'gf_no_options' )
						                .prop( 'disabled', false );
				                }

			                }

		                } );

	                };

	                self.getChainedSelectsValue = function( $select ) {

		                var value = {};

		                self.$selects( $select ).each( function() {
			                var inputId = self.getInputId( $( this ) );
			                value[ inputId ] = $( this ).val();
		                } );

		                return value;
	                };

	                self.getNextInputId = function( currentInputId ) {

		                var nextInputIndex = self.getInputIndex( currentInputId ) + 1;

		                return self.columns[ nextInputIndex ];
	                };

	                self.getInputIndex = function( inputId ) {

		                var index = [];

		                $.each( self.columns, function( key, value ) {
			                index[ value ] = key;
		                } );

		                return index[ inputId ];
	                };

	                self.initSelects = function( $selects ) {

		                if( typeof $selects == 'undefined' ) {
			                $selects = self.$selects();
		                }

		                $selects.filter( function() {
			                return $( this ).hasClass( 'gf_no_options' ) || $( this ).find( 'option' ).length <= 1 || $( this ).find( 'option' ).length == $( this ).find( 'option[value=""]' ).length;
		                } ).prop( 'disabled', true );

	                };

	                self.resetSelects = function( $currentSelect ) {

		                var currentInputId    = self.getInputId( $currentSelect ),
			                currentInputIndex = self.getInputIndex( currentInputId ),
			                $nextSelects      = self.$selects( $currentSelect ).filter( ':gt(' + currentInputIndex + ')' );

		                $nextSelects
		                    .prop( 'disabled', true )
			                .find( 'option:not(:first)' )
			                .remove()
			                .val( '' )
			                .change();

	                };

	                self.getSelectSelectors = function() {

		                var selectors = [];

		                for( var i = 0; i < self.columns.length; i++ ) {
			                selectors.push( '.gfield_list_' + self.fieldId + '_cell' + self.columns[i] + ' select' );
		                }

		                return selectors;
	                };

	                self.$selects = function( $currentSelect ) {

		                var $parent = $field;

		                // if current select is provided, find selects of the current row only
		                if( typeof $currentSelect != 'undefined' ) {
			                $parent = $currentSelect.parents( '.gfield_list_group' );
		                }

		                return $parent.find( self.getSelectSelectors().join( ',' ) );
	                };

	                self.updatePricing = function() {

		                var total = 0;

		                if( $field.css( 'display' ) != 'none' ) {

			                var $inputs = $field.find( 'input[name="input_' + self.fieldId + '[]"], select[name="input_' + self.fieldId + '[]"]' );

			                $inputs.each( function( i, input ) {

				                var value = $( input ).val(),
					                bits  = value.split( '|' ),
					                price = bits[1] ? parseFloat( bits[1] ) : 0;

				                total += price;

			                } );

		                }

		                if( $product.val() != total ) {
			                $product.val( total ).change();
			                gformCalculateTotalPrice( self.formId );
		                }

	                };

	                self.init();

                };

            } )( jQuery );

        </script>

        <?php

        self::$is_script_output = true;

    }

    function add_init_script( $form ) {

        if( ! $this->is_applicable_form( $form ) ) {
            return;
        }

        $args = array(
            'formId'  => $this->_args['form_id'],
            'fieldId' => $this->_args['field_id'],
	        'columns' => $this->_args['columns'],
	        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
	        'productFieldId' => $this->_product_id,
        );

        $script = 'new GWListFieldChainedSelects( ' . json_encode( $args ) . ' );';
        $slug   = implode( '_', array( 'gw_list_field_chained_selects', $this->_args['form_id'], $this->_args['field_id'] ) );

        GFFormDisplay::add_init_script( $this->_args['form_id'], $slug, GFFormDisplay::ON_PAGE_RENDER, $script );

    }

	function modify_list_field_input_type( $input, $field, $column, $value, $form_id, $input_id /* aka $column_index */ ) {

		if( ! $this->is_applicable_field( $field ) ) {
			return $input;
		}

		$this->_input_counter++;
		$row = ceil( $this->_input_counter / count( $field->choices ) );
		$full_chain_value = $this->get_chain_value_by_row( $field, $row );

		$input_id = $this->get_column_index( $column, $field );

		if( $this->is_applicable_input( $input_id, $field ) ) {

			$choices    = $this->get_input_choices( $full_chain_value, $input_id );
			$no_options = empty( $choices );

			if( $no_options ) {
				array_unshift( $choices, array(
					'text'       => __( 'No options' ),
					'value'      => '',
					'isSelected' => true,
					'noOptions'  => true,
				) );
			}

			array_unshift( $choices, array(
				'text'       => sprintf( __( 'Select a %s' ), $column ),
				'value'      => '',
				'isSelected' => ! $no_options,
			) );

			$input = array(
				'type'    => 'select',
				'choices' => $choices
			);

		}

	    return $input;
    }

	function get_chain_value_by_row( $field, $row ) {

		$full_list_value = GFFormsModel::get_field_value( $field );

		if( empty( $full_list_value ) ) {
			$return = array_map(
				function( $value ) {
					return '';
				},
				array_flip( $this->_args['columns'] )
			);
		} else {
			$return = array_values( $full_list_value[ $row - 1 ] );
		}

		return $return;
	}

    function is_applicable_form( $form ) {

        $form_id = isset( $form['id'] ) ? $form['id'] : $form;

        return $form_id == $this->_args['form_id'];
    }

	function is_applicable_field( $field ) {
		return $field->id == $this->_args['field_id'] && $this->is_applicable_form( $field->formId );
	}

	function is_applicable_input( $index, $field ) {
		return $this->is_applicable_field( $field ) && in_array( $index, $this->_args['columns'] );
	}

	function get_input_choices( $chain_value, $input_id = false, $depth = false, $choices = null, $full_chain_value = null ) {

		$full_chain_value = $full_chain_value !== null ? $full_chain_value : $chain_value;
		$value            = array_shift( $chain_value );
		$index            = $input_id ? $this->get_input_index( $input_id ) : 0;
		$depth            = $depth ? $depth : 0;
		$choices          = $choices !== null ? $choices: $this->get_choices();
		$input_choices    = array();

		if ( $depth == $index ) {
			$input_choices = $choices;
		} else {
			foreach ( $choices as $choice ) {
				if ( $choice['value'] == $value ) {
					$input_choices = $this->get_input_choices( $chain_value, $input_id, $depth + 1, isset( $choice['choices'] ) ? $choice['choices'] : array(), $full_chain_value );
					break;
				}
			}
		}

		if ( empty( $input_choices ) ) {
			if( $this->get_previous_input_value( $input_id, $full_chain_value ) ) {
				$input_choices = array(
					array(
						'text'       => __( 'No options' ),
						'value'      => '',
						'isSelected' => true,
						'noOptions'  => true,
					)
				);
			}
		}

		return $input_choices;
	}

	function get_choices() {

		if( $this->_choices != null ) {
			return $this->_choices;
		}

		$choices = $this->_args['choices'];

		if( ! is_array( $choices ) ) {

			$form  = GFAPI::get_form( $this->_args['form_id'] );
			$field = GFFormsModel::get_field( $form, $choices );

			if( is_callable( array( $field, 'get_input_type' ) ) && $field->get_input_type() == 'html' ) {
				$choices = $this->convert_string_to_choices( $field->content );
			} else {
				$choices = $field['choices']; // $field->choices; @todo
			}

		}

		$this->_choices = $choices;

		return $this->_choices;
	}

	function convert_string_to_choices( &$string, $depth = 0 ) {

		if( is_array( $string ) ) {
			$lines = &$string;
		} else {
			$lines = explode( "\n", $string );
		}

		$choices = array();

		while( count( $lines ) > 0 ) {

			$line       = reset( $lines );
			$dash_count = $this->get_dash_count( $line );

			if( $dash_count > $depth ) {

				$choices[ count( $choices ) - 1 ]['choices'] = $this->convert_string_to_choices( $lines, $dash_count );

			} else if( $dash_count < $depth ) {

				break;

			} else {

				// remove current line
				array_shift( $lines );

				$cleaned = trim( $line, ' -' );

				list( $text, $value, $price ) = array_pad( explode( '|', $cleaned ), 3, false );

				if( ! $value ) {
					$value = $text;
				}

				if( $price ) {
					$value .= '|' . $price;
					$this->_enable_product = true; // used to flag the addition of a hidden product field for displaying list field total on the frontend
				}

				$choices[] = array(
					'text'  => $text,
					'value' => $value,
					'price' => $price,
				);

			}

		}

		return $choices;
	}

	function get_input_index( $input_id ) {
		$index = array_flip( $this->_args['columns'] );
		return $index[ $input_id ];
	}

	function get_previous_input_value( $current_input_id, $full_chain_value ) {

		$current_input_index = $this->get_input_index( $current_input_id );
		$prev_input_index    = $current_input_index - 1;
		$prev_input_id       = $this->_args['columns'][ $prev_input_index ];

		return $full_chain_value[ $prev_input_id ];
	}

	function modify_submitted_data( $form ) {

		if( ! $this->is_applicable_form( $form ) ) {
			return;
		}

	}

	function add_product_field( $form ) {

		if( GFCommon::is_form_editor() || ! $this->is_applicable_form( $form ) ) {
			return $form;
		}

		// avoid infinite recursion issue
		remove_filter( 'gform_form_post_get_meta', array( $this, 'add_product_field' ) );

		$is_product_mode_enabled = $this->is_product_mode_enabled();

		add_filter( 'gform_form_post_get_meta', array( $this, 'add_product_field' ) );

		if( ! $is_product_mode_enabled || ! $this->is_applicable_form( $form ) ) {
			return $form;
		}

		$ids               = wp_list_pluck( $form['fields'], 'id' );
		$this->_product_id = max( $ids ) + 1;
		$label             = __( 'Hidden Product Field for List Field Products' );

		$product_field = new GF_Field_HiddenProduct( array(
			'id'               => $this->_product_id,
			'type'             => 'product',
			'inputType'        => 'hiddenproduct',
			'label'            => $label,
			'basePrice'        => 0,
			'conditionalLogic' => 0, // @todo copy from list field
			'inputs'           => array(
				array(
					'id' => $this->_product_id . '.1',
					'label' => $label,
					'name' => ''
				),
				array(
					'id' => $this->_product_id . '.2',
					'label' => sprintf( '%s ( %s )', $label, __( 'Price' ) ),
					'name' => ''
				),
				array(
					'id' => $this->_product_id . '.3',
					'label' => sprintf( '%s ( %s )', $label, __( 'Quantity' ) ),
					'name' => ''
				)
			),
		) );

		$form['fields'][] = $product_field;

		return $form;
	}

	function add_list_field_products( $products, $form, $entry ) {

		if( ! $this->is_applicable_form( $form ) || ! $this->is_product_mode_enabled() ) {
			return $products;
		}

		/**
		 * Add each List field row as a product
		 */
		foreach( $form['fields'] as $field ) {


			if( ! $this->is_applicable_field( $field ) ) {
				continue;
			}

			$value = $this->get_stashed_list_field_value( $entry['id'], $field->id, rgar( $entry, $field->id ) );
			if( ! $value ) {
				continue;
			}

			$groups = maybe_unserialize( $value );

			foreach( $groups as $group_index => $group ) {

				$group_total   = 0;
				$group_product = array();

				foreach( $group as $value ) {
					list( $text, $price ) = array_pad( explode( '|', $value ), 2, false );
					if( $price ) {
						$group_total += $price;
					}
				}

				if( $group_total > 0 ) {
					$group_product = array(
						'name'     => implode( ' / ', $this->remove_prices( array_filter( $group ) ) ),
						'price'    => $group_total,
						'quantity' => 1
					);
				}

				$group_id = sprintf( '%d.%d', $field->id, $group_index );
				$products['products'] = array( $group_id => $group_product ) + $products['products'];

			}

		}

		/**
		 * Remove Placeholder Product
		 */
		unset( $products['products'][ $this->_product_id ] );

		return $products;
	}

	function get_stashed_list_field_value( $entry_id, $field_id, $default_value = array() ) {
        global $_gform_lead_meta;

		if( $entry_id == null ) {
			return $default_value;
		}

		$key   = 'gwlfcs_stashed_list_field_value_' . $field_id;
		$value = gform_get_meta( $entry_id, $key );
		if( $value === false ) {
			gform_add_meta( $entry_id, $key, $default_value );
			if( isset( $_gform_lead_meta[ $entry_id . '_' . $key ] ) ) {
			    unset( $_gform_lead_meta[ $entry_id . '_' . $key ] );
            }
			GFAPI::update_entry_field( $entry_id, $field_id, null ); // delete list field value from the entry
			$value = $default_value;
		}

		return $value;
	}

	function get_column_index( $column, $field ) {

		$column_index = 1;

		if ( is_array( $field->choices ) ) {
			foreach ( $field->choices as $choice ) {
				if ( $choice['text'] == $column ) {
					break;
				}
				$column_index ++;
			}
		}

		return $column_index;
	}

	public function remove_prices( $group ) {
		foreach( $group as &$value ) {
			list( $text, $price ) = array_pad( explode( '|', $value ), 2, false );
			$value = $text;
		}
		return $group;
	}

	public function is_product_mode_enabled() {
		if( $this->_enable_product == null ) {
			// get_choices() will set the _enable_product flag
			$this->get_choices();
		}
		return $this->_enable_product;
	}

	public function get_dash_count( $string ) {
		$chars = str_split( $string );
		$count = 0;
		foreach( $chars as $char ) {
			if( $char == '-' ) {
				$count++;
			} else {
				break;
			}
		}
		return $count;
	}

	public function set_custom_list_field_delimiter( $delimiter, $field ) {
		if( $this->is_applicable_field( $field ) ) {
			$delimiter = '||';
		}
		return $delimiter;
	}

	public function ajax_get_next_chained_select_choices() {

		$form_id  = rgpost( 'form_id' );
		$field_id = rgpost( 'field_id' );
		$form     = GFAPI::get_form( $form_id );
		$field    = GFFormsModel::get_field( $form, $field_id );

		if( ! $this->is_applicable_field( $field ) ) {
			return;
		}

		$next_input_id = rgpost( 'next_input_id' );
		$value         = rgpost( 'value' );
		$choices       = $next_input_id ? $this->get_input_choices( $value, $next_input_id ) : array();

		die( json_encode( $choices ) );
	}

}

# Configuration

new GW_List_Field_Chained_Selects( array(
	'form_id'          => 1148,
	'field_id'         => 2,
	'columns'          => array( 2, 3, 4 ),
	'choices'          => 1, // takes a field ID or array of choices
) );