Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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
) );
@ikantarellis

This comment has been minimized.

Copy link

@ikantarellis ikantarellis commented Jun 6, 2016

Is it possible to give an example of how to use this?
Thanks in advance
Iosif

@tareqhi

This comment has been minimized.

Copy link

@tareqhi tareqhi commented Apr 19, 2017

@ikantarellis, here is the example you needed:
http://www.screencast.com/t/yxYtQ4tl

@mrcarlsen

This comment has been minimized.

Copy link

@mrcarlsen mrcarlsen commented Jul 4, 2017

Thanks for the code snippet. Your snippets have helped me a lot! This snippet works exactly as expected on the front-end. However there's a problem with storing the values when submitted.

I have a multi-page form and when the user press "next" the first value in the chain are stored as expected but the second value (and possibly subsequent values) in the chain is forgotten going back to it's unselected state.

Thanks in advance.

@michaeledi

This comment has been minimized.

Copy link

@michaeledi michaeledi commented Apr 25, 2018

@tareqhi
Thanks for the video tutorial! It's really helpful!

@yellowstonebum

This comment has been minimized.

Copy link

@yellowstonebum yellowstonebum commented Sep 15, 2018

Video isn't working... Having trouble implementing it and would really like to see this in action!

@sidouglas

This comment has been minimized.

Copy link

@sidouglas sidouglas commented Oct 1, 2018

Can't PR a gist, but please update L:62
add_action( 'gform_preview_footer', array( __class__, 'output_script' ) );
there is no longer a hook named: gform_footer

@src1988

This comment has been minimized.

Copy link

@src1988 src1988 commented Dec 3, 2018

Where should this code be put or how is this called within the existing php files for Gravity Forms?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment