diff --git a/modules/contact-form/grunion-contact-form.php b/modules/contact-form/grunion-contact-form.php index 7abd02dcd50cc..4fae893b29cd0 100644 --- a/modules/contact-form/grunion-contact-form.php +++ b/modules/contact-form/grunion-contact-form.php @@ -2366,6 +2366,34 @@ static function remove_empty( $single_value ) { return ( $single_value !== '' ); } + /** + * Escape a shortcode value. + * + * Shortcode attribute values have a number of unfortunate restrictions, which fortunately we + * can get around by adding some extra HTML encoding. + * + * The output HTML will have a few extra escapes, but that makes no functional difference. + * + * @since 9.1.0 + * @param string $val Value to escape. + * @return string + */ + private static function esc_shortcode_val( $val ) { + return strtr( + esc_html( $val ), + array( + // Brackets in attribute values break the shortcode parser. + '[' => '[', + ']' => ']', + // Shortcode parser screws up backslashes too, thanks to calls to `stripcslashes`. + '\\' => '\', + // The existing code here represents arrays as comma-separated strings. + // Rather than trying to change representations now, just escape the commas in values. + ',' => ',', + ) + ); + } + /** * The contact-field shortcode processor * We use an object method here instead of a static Grunion_Contact_Form_Field class method to parse contact-field shortcodes so that we can tie them to the contact-form object. @@ -2384,18 +2412,18 @@ static function parse_contact_field( $attributes, $content ) { } foreach ( $attributes as $att => $val ) { if ( is_numeric( $att ) ) { // Is a valueless attribute - $att_strs[] = esc_html( $val ); + $att_strs[] = self::esc_shortcode_val( $val ); } elseif ( isset( $val ) ) { // A regular attr - value pair if ( ( $att === 'options' || $att === 'values' ) && is_string( $val ) ) { // remove any empty strings $val = explode( ',', $val ); } - if ( is_array( $val ) ) { + if ( is_array( $val ) ) { $val = array_filter( $val, array( __CLASS__, 'remove_empty' ) ); // removes any empty strings - $att_strs[] = esc_html( $att ) . '="' . implode( ',', array_map( 'esc_html', $val ) ) . '"'; + $att_strs[] = esc_html( $att ) . '="' . implode( ',', array_map( array( __CLASS__, 'esc_shortcode_val' ), $val ) ) . '"'; } elseif ( is_bool( $val ) ) { - $att_strs[] = esc_html( $att ) . '="' . esc_html( $val ? '1' : '' ) . '"'; + $att_strs[] = esc_html( $att ) . '="' . ( $val ? '1' : '' ) . '"'; } else { - $att_strs[] = esc_html( $att ) . '="' . esc_html( $val ) . '"'; + $att_strs[] = esc_html( $att ) . '="' . self::esc_shortcode_val( $val ) . '"'; } } } diff --git a/tests/php/modules/contact-form/test-class.grunion-contact-form.php b/tests/php/modules/contact-form/test-class.grunion-contact-form.php index 05fcb659a27cc..33a74d2f0058f 100644 --- a/tests/php/modules/contact-form/test-class.grunion-contact-form.php +++ b/tests/php/modules/contact-form/test-class.grunion-contact-form.php @@ -530,6 +530,33 @@ public function test_make_sure_that_we_remove_empty_options_from_form_field() { $this->assertEquals( "[contact-field type=\"select\" required=\"1\" options=\"fun,run\" label=\"fun times\" values=\"go,have some fun\"/]", $html ); } + /** + * Tests shortcode with commas and brackets. + * + * @covers Grunion_Contact_Form_Field + */ + public function test_array_values_with_commas_and_brackets() { + add_shortcode( 'contact-field', array( 'Grunion_Contact_Form', 'parse_contact_field' ) ); + $shortcode = "[contact-field type='radio' options='\"foo\",bar, baz,[b\rackets]' label='fun ][ times'/]"; + $html = do_shortcode( $shortcode ); + $this->assertEquals( '[contact-field type="radio" options=""foo",bar, baz,[b\rackets]" label="fun ][ times"/]', $html ); + } + + /** + * Tests Gutenblock input with commas and brackets. + * + * @covers Grunion_Contact_Form_Field + */ + public function test_array_values_with_commas_and_brackets_from_gutenblock() { + $attr = array( + 'type' => 'radio', + 'options' => array( '"foo"', 'bar, baz', '[b\\rackets]' ), + 'label' => 'fun ][ times', + ); + $html = Grunion_Contact_Form_Plugin::gutenblock_render_field_radio( $attr, '' ); + $this->assertEquals( '[contact-field type="radio" options=""foo",bar, baz,[b\rackets]" label="fun ][ times"/]', $html ); + } + /** * Test for text field_renders * @@ -690,13 +717,13 @@ public function test_make_sure_checkbox_multiple_field_renders_as_expected() { */ public function test_make_sure_radio_field_renders_as_expected() { $attributes = array( - 'label' => 'fun', - 'type' => 'radio', - 'class' => 'lalala', + 'label' => 'fun', + 'type' => 'radio', + 'class' => 'lalala', 'default' => 'option 1', - 'id' => 'funID', - 'options' => array( 'option 1', 'option 2' ), - 'values' => array( 'option 1', 'option 2' ), + 'id' => 'funID', + 'options' => array( 'option 1', 'option 2', 'option 3, or 4', 'back\\slash' ), + 'values' => array( 'option 1', 'option 2', 'option [34]', '\\' ), ); $expected_attributes = array_merge( $attributes, array( 'input_type' => 'radio' ) ); @@ -710,13 +737,13 @@ public function test_make_sure_radio_field_renders_as_expected() { */ public function test_make_sure_select_field_renders_as_expected() { $attributes = array( - 'label' => 'fun', - 'type' => 'select', - 'class' => 'lalala', + 'label' => 'fun', + 'type' => 'select', + 'class' => 'lalala', 'default' => 'option 1', - 'id' => 'funID', - 'options' => array( 'option 1', 'option 2' ), - 'values' => array( 'o1', 'o2' ), + 'id' => 'funID', + 'options' => array( 'option 1', 'option 2', 'option 3, or 4', 'back\\slash' ), + 'values' => array( 'option 1', 'option 2', 'option [34]', '\\' ), ); $expected_attributes = array_merge( $attributes, array( 'input_type' => 'select' ) ); @@ -842,17 +869,17 @@ public function assertValidCheckboxField( $html, $attributes ) { public function assertValidFieldMultiField( $html, $attributes ) { - $wrapperDiv = $this->getCommonDiv( $html ); - $this->assertCommonValidHtml( $wrapperDiv, $attributes ); + $wrapper_div = $this->getCommonDiv( $html ); + $this->assertCommonValidHtml( $wrapper_div, $attributes ); // Get label - $label = $this->getFirstElement( $wrapperDiv, 'label' ); + $label = $this->getFirstElement( $wrapper_div, 'label' ); //Inputs if ( $attributes['type'] === 'select' ) { $this->assertEquals( $label->getAttribute( 'class' ), 'grunion-field-label select', 'label class doesn\'t match' ); - $select = $this->getFirstElement( $wrapperDiv, 'select' ); + $select = $this->getFirstElement( $wrapper_div, 'select' ); $this->assertEquals( $label->getAttribute( 'for' ), $select->getAttribute( 'id' ), @@ -867,28 +894,49 @@ public function assertValidFieldMultiField( $html, $attributes ) { $this->assertEquals( $select->getAttribute( 'class' ), 'select '. $attributes['class'], ' select class does not match expected' ); - // First Option - $option = $this->getFirstElement( $select, 'option' ); - $this->assertEquals( $option->getAttribute( 'value' ), $attributes['values'][0], 'Input value doesn\'t match' ); - $this->assertEquals( $option->getAttribute( 'selected' ), 'selected', 'Input is not selected' ); - $this->assertEquals( $option->nodeValue, $attributes['options'][0], 'Input does not match the option' ); - + // Options. + $options = $select->getElementsByTagName( 'option' ); + $n = is_array( $options ) ? count( $options ) : $options->length; + $this->assertEquals( $n, count( $attributes['options'] ), 'Number of inputs doesn\'t match number of options' ); + $this->assertEquals( $n, count( $attributes['values'] ), 'Number of inputs doesn\'t match number of values' ); + for ( $i = 0; $i < $n; $i++ ) { + $option = is_array( $options ) ? $options[ $i ] : $options->item( $i ); + $this->assertEquals( $option->getAttribute( 'value' ), $attributes['values'][ $i ], 'Input value doesn\'t match' ); + if ( 0 === $i ) { + $this->assertEquals( $option->getAttribute( 'selected' ), 'selected', 'Input is not selected' ); + } else { + $this->assertNotEquals( $option->getAttribute( 'selected' ), 'selected', 'Input is selected' ); + } + //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $this->assertEquals( $option->nodeValue, $attributes['options'][ $i ], 'Input does not match the option' ); + } } else { $this->assertEquals( $label->getAttribute( 'class' ), 'grunion-field-label', 'label class doesn\'t match' ); - // Radio and Checkboxes - $second_label = $this->getFirstElement( $wrapperDiv, 'label', 1 ); - $this->assertEquals( $second_label->nodeValue, ' ' . $attributes['options'][0] ); // extra space added for a padding - - $input = $this->getFirstElement( $second_label, 'input' ); - $this->assertEquals( $input->getAttribute( 'type' ), $attributes['input_type'], 'Type doesn\'t match' ); - if ( $attributes['input_type'] === 'radio' ) { - $this->assertEquals( $input->getAttribute( 'name' ), $attributes['id'], 'Input name doesn\'t match' ); - } else { - $this->assertEquals( $input->getAttribute( 'name' ), $attributes['id'] . '[]', 'Input name doesn\'t match' ); + // Radio and Checkboxes. + $labels = $wrapper_div->getElementsByTagName( 'label' ); + $n = is_array( $labels ) ? count( $labels ) - 1 : $labels->length - 1; + $this->assertEquals( $n, count( $attributes['options'] ), 'Number of inputs doesn\'t match number of options' ); + $this->assertEquals( $n, count( $attributes['values'] ), 'Number of inputs doesn\'t match number of values' ); + for ( $i = 0; $i < $n; $i++ ) { + $item_label = is_array( $labels ) ? $labels[ $i + 1 ] : $labels->item( $i + 1 ); + //phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $this->assertEquals( $item_label->nodeValue, ' ' . $attributes['options'][ $i ] ); // extra space added for a padding. + + $input = $this->getFirstElement( $item_label, 'input' ); + $this->assertEquals( $input->getAttribute( 'type' ), $attributes['input_type'], 'Type doesn\'t match' ); + if ( 'radio' === $attributes['input_type'] ) { + $this->assertEquals( $input->getAttribute( 'name' ), $attributes['id'], 'Input name doesn\'t match' ); + } else { + $this->assertEquals( $input->getAttribute( 'name' ), $attributes['id'] . '[]', 'Input name doesn\'t match' ); + } + $this->assertEquals( $input->getAttribute( 'value' ), $attributes['values'][ $i ], 'Input value doesn\'t match' ); + $this->assertEquals( $input->getAttribute( 'class' ), $attributes['type'] . ' ' . $attributes['class'], 'Input class doesn\'t match' ); + if ( 0 === $i ) { + $this->assertEquals( $input->getAttribute( 'checked' ), 'checked', 'Input checked doesn\'t match' ); + } else { + $this->assertNotEquals( $input->getAttribute( 'checked' ), 'checked', 'Input checked doesn\'t match' ); + } } - $this->assertEquals( $input->getAttribute( 'value' ), $attributes['values'][0], 'Input value doesn\'t match' ); - $this->assertEquals( $input->getAttribute( 'class' ), $attributes['type'] . ' '. $attributes['class'], 'Input class doesn\'t match' ); - $this->assertEquals( $input->getAttribute( 'checked' ), 'checked', 'Input checked doesn\'t match' ); } }