diff --git a/admin/section/class-convertkit-admin-settings-broadcasts.php b/admin/section/class-convertkit-admin-settings-broadcasts.php index 82fe2eb8..53453e05 100644 --- a/admin/section/class-convertkit-admin-settings-broadcasts.php +++ b/admin/section/class-convertkit-admin-settings-broadcasts.php @@ -357,7 +357,8 @@ public function enable_callback( $args ) { 'on', $this->settings->enabled(), // phpcs:ignore WordPress.Security.EscapeOutput $args['label'], // phpcs:ignore WordPress.Security.EscapeOutput - $args['description'] // phpcs:ignore WordPress.Security.EscapeOutput + $args['description'], // phpcs:ignore WordPress.Security.EscapeOutput + array( 'convertkit-conditional-display' ) ); } diff --git a/admin/section/class-convertkit-settings-base.php b/admin/section/class-convertkit-settings-base.php index e0273373..bb1923d5 100644 --- a/admin/section/class-convertkit-settings-base.php +++ b/admin/section/class-convertkit-settings-base.php @@ -436,6 +436,38 @@ public function get_text_field( $name, $value = '', $description = false, $css_c } + /** + * Returns a number field. + * + * @since 2.6.1 + * + * @param string $name Name. + * @param string $value Value. + * @param int $min `min` attribute value. + * @param int $max `max` attribute value. + * @param int $step `step` attribute value. + * @param bool|string|array $description Description (false|string|array). + * @param bool|array $css_classes CSS Classes (false|array). + * @return string HTML Field + */ + public function get_number_field( $name, $value = '', $min = 0, $max = 9999, $step = 1, $description = false, $css_classes = false ) { + + $html = sprintf( + '', + ( is_array( $css_classes ) ? implode( ' ', $css_classes ) : 'small-text' ), + $name, + $this->settings_key, + $name, + $value, + $min, + $max, + $step + ); + + return $html . $this->get_description( $description ); + + } + /** * Returns a textarea field. * diff --git a/admin/section/class-convertkit-settings-general.php b/admin/section/class-convertkit-settings-general.php index b6ccbd19..16fe8259 100644 --- a/admin/section/class-convertkit-settings-general.php +++ b/admin/section/class-convertkit-settings-general.php @@ -233,8 +233,9 @@ public function enqueue_scripts( $section ) { // Enqueue Select2 JS. convertkit_select2_enqueue_scripts(); - // Enqueue Preview Output JS. + // Enqueue JS. wp_enqueue_script( 'convertkit-admin-preview-output', CONVERTKIT_PLUGIN_URL . 'resources/backend/js/preview-output.js', array( 'jquery' ), CONVERTKIT_PLUGIN_VERSION, true ); + wp_enqueue_script( 'convertkit-admin-settings-conditional-display', CONVERTKIT_PLUGIN_URL . 'resources/backend/js/settings-conditional-display.js', array( 'jquery' ), CONVERTKIT_PLUGIN_VERSION, true ); } @@ -307,7 +308,19 @@ public function register_fields() { $this->settings_key, $this->name, array( - 'label_for' => '_wp_convertkit_settings_' . $supported_post_type . '_form', + 'label_for' => '_wp_convertkit_settings_' . $supported_post_type . '_form_position', + 'post_type' => $supported_post_type, + 'post_type_object' => $post_type, + ) + ); + add_settings_field( + $supported_post_type . '_form_position_element', + '', + array( $this, 'default_form_position_element_callback' ), + $this->settings_key, + $this->name, + array( + 'label_for' => '_wp_convertkit_settings_' . $supported_post_type . '_form_position_element', 'post_type' => $supported_post_type, 'post_type_object' => $post_type, ) @@ -461,7 +474,6 @@ public function maybe_initialize_and_refresh_resources() { $tags = new ConvertKit_Resource_Tags( 'settings' ); $tags->refresh(); - } /** @@ -544,19 +556,68 @@ public function default_form_position_callback( $args ) { $args['post_type'] . '_form_position', esc_attr( $this->settings->get_default_form_position( $args['post_type'] ) ), array( - 'before_content' => esc_html__( 'Before content', 'convertkit' ), - 'after_content' => esc_html__( 'After content', 'convertkit' ), - 'before_after_content' => esc_html__( 'Before and after content', 'convertkit' ), + 'before_content' => sprintf( + /* translators: Post type singular name */ + esc_attr__( 'Before %s content', 'convertkit' ), + esc_attr( $args['post_type_object']->labels->singular_name ) + ), + 'after_content' => sprintf( + /* translators: Post type singular name */ + esc_attr__( 'After %s content', 'convertkit' ), + esc_attr( $args['post_type_object']->labels->singular_name ) + ), + 'before_after_content' => sprintf( + /* translators: Post type singular name */ + esc_attr__( 'Before and after %s content', 'convertkit' ), + esc_attr( $args['post_type_object']->labels->singular_name ) + ), + 'after_element' => esc_html__( 'After element', 'convertkit' ), ), sprintf( /* translators: Post Type name, plural */ esc_html__( 'Where forms should display relative to the %s content', 'convertkit' ), - esc_html( $args['post_type_object']->label ) + esc_html( $args['post_type_object']->labels->singular_name ) + ), + array( 'convertkit-conditional-display' ), + array( + 'data-conditional-value' => 'after_element', + 'data-conditional-element' => esc_attr( $args['post_type'] ) . '_form_position_element_index', ) ); } + /** + * Renders the input for the Default Form Position Index setting for the given Post Type. + * + * @since 2.6.1 + * + * @param array $args Field arguments. + */ + public function default_form_position_element_callback( $args ) { + + echo $this->get_number_field( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $args['post_type'] . '_form_position_element_index', + esc_attr( (string) $this->settings->get_default_form_position_element_index( $args['post_type'] ) ), + 1, + 999, + 1, + false, + array( 'after_element' ) + ); + + echo $this->get_select_field( // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $args['post_type'] . '_form_position_element', + esc_attr( $this->settings->get_default_form_position_element( $args['post_type'] ) ), + array( + 'p' => esc_html__( 'Paragraphs', 'convertkit' ), + ), + esc_html__( 'The number of elements before outputting the form.', 'convertkit' ), + array( 'after_element' ) + ); + + } + /** * Renders the input for the Non-inline Form setting. * diff --git a/includes/class-convertkit-output.php b/includes/class-convertkit-output.php index cd3a8641..d937ac45 100644 --- a/includes/class-convertkit-output.php +++ b/includes/class-convertkit-output.php @@ -338,6 +338,22 @@ public function append_form_to_content( $content ) { $content = $form . $content; break; + case 'after_element': + $element = $this->settings->get_default_form_position_element( get_post_type( $post_id ) ); + $index = $this->settings->get_default_form_position_element_index( get_post_type( $post_id ) ); + + // Check if DOMDocument is installed. + // It should be installed as mosts hosts include php-dom and php-xml modules. + // If not, fallback to using preg_match_all(), which is less reliable. + if ( ! class_exists( 'DOMDocument' ) ) { + $content = $this->inject_form_after_element_fallback( $content, $element, $index, $form ); + break; + } + + // Use DOMDocument. + $content = $this->inject_form_after_element( $content, $element, $index, $form ); + break; + case 'after_content': default: // Default behaviour < 2.5.8 was to append the Form after the content. @@ -362,6 +378,112 @@ public function append_form_to_content( $content ) { } + /** + * Injects the form after the given element and index, using DOMDocument. + * + * @since 2.6.2 + * + * @param string $content Page / Post Content. + * @param string $tag HTML tag to insert form after. + * @param int $index Number of $tag elements to find before inserting form. + * @param string $form Form HTML to inject. + * @return string + */ + private function inject_form_after_element( $content, $tag, $index, $form ) { + + // Load Page / Post content into DOMDocument. + libxml_use_internal_errors( true ); + $html = new DOMDocument(); + $html->loadHTML( $content, LIBXML_HTML_NODEFDTD ); + + // Find the element to append the form to. + // item() is a zero based index. + $element_node = $html->getElementsByTagName( $tag )->item( $index - 1 ); + + // If the element could not be found, either the number of elements by tag name is less + // than the requested position the form be inserted in, or no element exists. + // Append the form to the content and return. + if ( is_null( $element_node ) ) { + return $content . $form; + } + + // Create new element for the Form. + $form_node = new DOMDocument(); + $form_node->loadHTML( $form, LIBXML_HTML_NODEFDTD ); + + // Append the form to the specific element. + $element_node->parentNode->insertBefore( $html->importNode( $form_node->documentElement, true ), $element_node->nextSibling ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + + // Fetch HTML string. + $content = $html->saveHTML(); + + // Remove some HTML tags that DOMDocument adds, returning the output. + // We do this instead of using LIBXML_HTML_NOIMPLIED in loadHTML(), because Legacy Forms are not always contained in + // a single root / outer element, which is required for LIBXML_HTML_NOIMPLIED to correctly work. + $content = str_replace( '', '', $content ); + $content = str_replace( '', '', $content ); + $content = str_replace( '
', '', $content ); + $content = str_replace( '', '', $content ); + $content = str_replace( '', '', $content ); + $content = str_replace( '', '', $content ); + + return $content; + + } + + /** + * Injects the form after the given element and index, using preg_match_all(). + * This is less reliable than DOMDocument, and is called if DOMDocument is + * not installed on the server. + * + * @since 2.6.2 + * + * @param string $content Page / Post Content. + * @param string $tag HTML tag to insert form after. + * @param int $index Number of $tag elements to find before inserting form. + * @param string $form Form HTML to inject. + * @return string + */ + private function inject_form_after_element_fallback( $content, $tag, $index, $form ) { + + // Calculate tag length. + $tag_length = ( strlen( $tag ) + 3 ); + + // Find all closing elements. + preg_match_all( '/<\/' . $tag . '>/', $content, $matches ); + + // If no elements exist, just append the form. + if ( count( $matches[0] ) === 0 ) { + $content = $content . $form; + return $content; + } + + // If the number of elements is less than the index, we don't have enough elements to add the form to. + // Just add the form after the content. + if ( count( $matches[0] ) <= $index ) { + $content = $content . $form; + return $content; + } + + // Iterate through the content to find the element at the configured index e.g. find the 4th closing paragraph. + $offset = 0; + foreach ( $matches[0] as $element_index => $element ) { + $position = strpos( $content, $element, $offset ); + if ( ( $element_index + 1 ) === $index ) { + return substr( $content, 0, $position + 4 ) . $form . substr( $content, $position + 4 ); + } + + // Increment offset. + $offset = $position + 1; + } + + // If here, something went wrong. + // Just add the form after the content. + $content = $content . $form; + return $content; + + } + /** * Registers the ConvertKit Form block to before or after the Query Loop block, when viewing a Category archive. * diff --git a/includes/class-convertkit-settings.php b/includes/class-convertkit-settings.php index 92ccfa59..542b2ba5 100644 --- a/includes/class-convertkit-settings.php +++ b/includes/class-convertkit-settings.php @@ -307,6 +307,44 @@ public function get_default_form_position( $post_type ) { } + /** + * Returns the Default Form Position Element Plugin setting. + * + * @since 2.6.1 + * + * @param string $post_type Post Type. + * @return string Element to insert form after + */ + public function get_default_form_position_element( $post_type ) { + + // Return after_content if this Post Type's position doesn't exist as a setting. + if ( ! array_key_exists( $post_type . '_form_position_element', $this->settings ) ) { + return 'p'; + } + + return $this->settings[ $post_type . '_form_position_element' ]; + + } + + /** + * Returns the Default Form Position Index Plugin setting. + * + * @since 2.6.1 + * + * @param string $post_type Post Type. + * @return int Number of elements before inserting form + */ + public function get_default_form_position_element_index( $post_type ) { + + // Return 1 if this Post Type's position index doesn't exist as a setting. + if ( ! array_key_exists( $post_type . '_form_position_element_index', $this->settings ) ) { + return 1; + } + + return (int) $this->settings[ $post_type . '_form_position_element_index' ]; + + } + /** * Returns the Global non-inline Form Plugin setting. * @@ -406,8 +444,10 @@ public function get_defaults() { // Add Post Type Default Forms. foreach ( convertkit_get_supported_post_types() as $post_type ) { - $defaults[ $post_type . '_form' ] = 0; // -1, 0 or Form ID. - $defaults[ $post_type . '_form_position' ] = 'after_content'; // before_content,after_content. + $defaults[ $post_type . '_form' ] = 0; // -1, 0 or Form ID. + $defaults[ $post_type . '_form_position' ] = 'after_content'; // before_content,after_content,before_after_content,element. + $defaults[ $post_type . '_form_position_element' ] = 'p'; + $defaults[ $post_type . '_form_position_element_index' ] = 1; } /** diff --git a/resources/backend/js/settings-conditional-display.js b/resources/backend/js/settings-conditional-display.js index cea60200..57e5ebd9 100644 --- a/resources/backend/js/settings-conditional-display.js +++ b/resources/backend/js/settings-conditional-display.js @@ -17,16 +17,20 @@ document.addEventListener( function () { // Update settings and refresh UI when a setting is changed. - const enabledInput = document.querySelector( 'input#enabled' ); - enabledInput.addEventListener( - 'change', - function () { - convertKitConditionallyDisplaySettings( this.id, this.checked ); + const sourceInputs = document.querySelectorAll( '.convertkit-conditional-display' ); + sourceInputs.forEach( + function ( input ) { + input.addEventListener( + 'change', + function () { + convertKitConditionallyDisplaySettings( this ); + } + ); + + convertKitConditionallyDisplaySettings( input ); } ); - convertKitConditionallyDisplaySettings( 'enabled', enabledInput.checked ); - } ); @@ -35,31 +39,58 @@ document.addEventListener( * table rows related to a setting, if that setting is disabled. * * @since 2.2.4 + * + * @param object input Element interacted with */ -function convertKitConditionallyDisplaySettings( name, display ) { +function convertKitConditionallyDisplaySettings( input ) { - // Show all rows. const rows = document.querySelectorAll( 'table.form-table tr' ); - rows.forEach( row => row.style.display = '' ); - // Don't do anything else if display is true. - if ( display ) { - return; - } + switch ( input.type ) { + case 'checkbox': + // Show all rows. + rows.forEach( row => row.style.display = '' ); - // Iterate through the table rows, hiding any settings. - rows.forEach( - function ( row ) { - // Skip if this table row is for the setting we've just checked/unchecked. - if ( row.querySelector( `[id = "${name}"]` ) ) { - return; + // Don't do anything else if the checkbox is checked. + if ( input.checked ) { + return; } - // Hide this row if the input, select, link or span element within the row has the CSS class of the setting name. - if ( row.querySelector( `input.${name}, select.${name}, a.${name}, span.${name}` ) ) { - row.style.display = 'none'; - } - } - ); + // Iterate through the table rows, hiding any settings. + rows.forEach( + function ( row ) { + // Skip if this table row is for the setting we've just checked/unchecked. + if ( row.querySelector( `[id = "${input.id}"]` ) ) { + return; + } + + // Hide this row if the input, select, link or span element within the row has the CSS class of the setting ID. + if ( row.querySelector( `input.${input.id}, select.${input.id}, a.${input.id}, span.${input.id}` ) ) { + row.style.display = 'none'; + } + } + ); + break; + + default: + // Iterate through the table rows, hiding any settings. + rows.forEach( + function ( row ) { + // Skip if this table row is for the setting we've just changed. + if ( row.querySelector( `[id = "${input.id}"]` ) ) { + return; + } + + if ( row.querySelector( `input#${input.dataset.conditionalElement}` ) ) { + if ( input.value !== input.dataset.conditionalValue ) { + row.style.display = 'none'; + } else { + row.style.display = ''; + } + } + } + ); + break; + } } diff --git a/tests/_support/Helper/Acceptance/ConvertKitForms.php b/tests/_support/Helper/Acceptance/ConvertKitForms.php index 64bdd2e1..e85849c7 100644 --- a/tests/_support/Helper/Acceptance/ConvertKitForms.php +++ b/tests/_support/Helper/Acceptance/ConvertKitForms.php @@ -18,8 +18,10 @@ class ConvertKitForms extends \Codeception\Module * @param AcceptanceTester $I Tester. * @param int $formID Form ID. * @param bool|string $position Position of the form in the DOM relative to the content. + * @param bool|string $element Element the form should display after. + * @param bool|string $element_index Number of elements before the form should display. */ - public function seeFormOutput($I, $formID, $position = false) + public function seeFormOutput($I, $formID, $position = false, $element = false, $element_index = 0) { // Calculate how many times the Form should be in the DOM. $count = ( ( $position === 'before_after_content' ) ? 2 : 1 ); @@ -46,6 +48,10 @@ public function seeFormOutput($I, $formID, $position = false) case 'after_content': $I->assertEquals($formID, $I->grabAttributeFrom('div.entry-content > *:last-child', 'data-sv-form')); break; + + case 'after_element': + $I->seeInSource('<' . $element . '>Item #' . $element_index . '' . $element . '>