gocodebox/lifterlms

View on GitHub
includes/forms/class-llms-forms-dynamic-fields.php

Summary

Maintainability
B
4 hrs
Test Coverage
A
99%
<?php
/**
 * LLMS_Forms_Dynamic_Fields file
 *
 * @package LifterLMS/Classes/Forms
 *
 * @since 5.0.0
 * @version 5.1.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Manage dynamically generated fields added to the form outside of the block editor
 *
 * @since 5.0.0
 */
class LLMS_Forms_Dynamic_Fields {

    /**
     * Constructor
     *
     * @since 5.0.0
     * @since 5.1.0 Added logic to make sure forms have all the required fields.
     *
     * @return void
     */
    public function __construct() {

        add_filter( 'llms_get_form_blocks', array( $this, 'add_password_strength_meter' ), 10, 2 );
        add_filter( 'llms_get_form_blocks', array( $this, 'maybe_add_required_block_fields' ), 10, 3 );
        add_filter( 'llms_get_form_blocks', array( $this, 'modify_account_form' ), 15, 2 );

    }

    /**
     * Creates a new HTML block with the given settings and inserts it into an existing blocks array at the specified location
     *
     * @since 5.0.0
     *
     * @param array[] $blocks         Array of WP_Block arrays.
     * @param array   $block_settings Block attributes used to generate a new custom HTML field block.
     * @param integer $index          Desired index of the new block.
     *
     * @return array[]
     */
    private function add_block( $blocks, $block_settings, $index ) {

        // Make the new block.
        $add_block = parse_blocks(
            LLMS_Forms::instance()->get_custom_field_block_markup( $block_settings )
        );

        // Add it into the form after the specified index.
        array_splice( $blocks, $index + 1, 0, $add_block );

        return $blocks;

    }

    /**
     * Adds a password strength meter to a block list
     *
     * This function will programmatically add an html block containing the necessary
     * markup for the password strength meter to function.
     *
     * This will locate the user password block and output the meter immediately after
     * the block. If the password block is within a group it'll output it after the
     * group block.
     *
     * @since 5.0.0
     * @since 5.0.1 Add `aria-live=polite` to ensure password strength is announced for screen readers.
     *
     * @param array[] $blocks WP_Block list.
     * @return array[]
     */
    public function add_password_strength_meter( $blocks, $location ) {

        $password = $this->find_block( 'password', $blocks );

        // No password field in the form.
        if ( ! $password ) {
            return $blocks;
        }

        list( $index, $block ) = $password;

        // Meter not enabled.
        if ( empty( $block['attrs']['meter'] ) || ! llms_parse_bool( $block['attrs']['meter'] ) ) {
            return $blocks;
        }

        $meter_settings = array(
            'type'            => 'html',
            'id'              => 'llms-password-strength-meter',
            'classes'         => 'llms-password-strength-meter',
            'description'     => ! empty( $block['attrs']['meter_description'] ) ? $block['attrs']['meter_description'] : '',
            'min_length'      => ! empty( $block['attrs']['html_attrs']['minlength'] ) ? $block['attrs']['html_attrs']['minlength'] : '',
            'min_strength'    => ! empty( $block['attrs']['min_strength'] ) ? $block['attrs']['min_strength'] : '',
            'llms_visibility' => ! empty( $block['attrs']['llms_visibility'] ) ? $block['attrs']['llms_visibility'] : '',
            'attributes'      => array(
                'aria-live' => 'polite',
            ),
        );

        if ( 'account' === $location ) {
            $meter_settings['wrapper_classes'] = 'llms-visually-hidden-field';
        }

        /**
         * Filters the settings used to create the dynamic password strength meter block
         *
         * @since 5.0.0
         *
         * @param array $meter_settings Array or block attributes/settings.
         */
        $meter_settings = apply_filters( 'llms_password_strength_meter_field_settings', $meter_settings );

        return $this->add_block( $blocks, $meter_settings, $index );

    }

    /**
     * Finds a block with the specified ID within a list of blocks
     *
     * There's a gotcha with this function... if a user password field is placed within a wp core columns block
     * the password strength meter will be added outside the column the password is contained within.
     *
     * @since 5.0.0
     *
     * @param string  $id           The ID of the field to find.
     * @param array[] $blocks       WP_Block list.
     * @param integer $parent_index Top level index of the parent block. Used to hold a reference to the current index within the toplevel
     *                              blocks of the form when looking into the innerBlocks of a block.
     * @return boolean|array Returns `false` when the block cannot be found in the given list, otherwise returns a numeric array
     *                       where item `0` is the index of the block within the list (the index of the items parent if it's in a
     *                       group) and item `1` is the block array.
     */
    private function find_block( $id, $blocks, $parent_index = null ) {

        foreach ( $blocks as $index => $block ) {

            if ( ! empty( $block['attrs']['id'] ) && $id === $block['attrs']['id'] ) {
                return array( is_null( $parent_index ) ? $index : $parent_index, $block );
            }

            if ( $block['innerBlocks'] ) {
                $inner = $this->find_block( $id, $block['innerBlocks'], is_null( $parent_index ) ? $index : $parent_index );
                if ( false !== $inner ) {
                    return $inner;
                }
            }
        }

        return false;

    }

    /**
     * Retrieve the fields required for a given location based on user state
     *
     * @since 5.1.0
     *
     * @param string $location The request form location ID.
     * @param array  $args     Additional arguments passed to the short-circuit filter.
     * @return array[] Array of field_id => block_name required or an empty array if no fields required.
     */
    private function get_required_fields_for_location( $location, $args ) {

        $fields = array();

        if (
            ( ! is_user_logged_in() && in_array( $location, array( 'checkout', 'registration' ), true ) ) ||
                ( is_user_logged_in() && 'account' === $location ) ) {
            $fields = array(
                // Field ID => block name.
                'email_address' => 'email',
                'password'      => 'password',
            );
        }

        /**
         * Filters the required block fields to add to the form
         *
         * @since 5.1.0
         *
         * @param array[] $fields   Array of field_id => block_name required.
         * @param string  $location The request form location ID.
         * @param array   $args     Additional arguments passed to the short-circuit filter.
         */
        return apply_filters( 'llms_forms_required_block_fields', $fields, $location, $args );

    }

    /**
     * Retrieve the HTML for a field toggle button link
     *
     * @since 5.0.0
     *
     * @param string $fields      A comma-separated list of selectors for the controlled fields.
     * @param string $field_label Label for the original field.
     * @return string
     */
    private function get_toggle_button_html( $fields, $field_label ) {

        // Translator: %s = user-selected label for the given field being toggled.
        $change_text = sprintf( esc_attr_x( 'Change %s', 'Toggle button for changing email or password', 'lifterlms' ), $field_label );
        $cancel_text = esc_attr_x( 'Cancel', 'Cancel password or email address change button text', 'lifterlms' );

        return '<a class="llms-toggle-fields" data-fields="' . $fields . '" data-change-text="' . $change_text . '" data-cancel-text="' . $cancel_text . '" href="#">' . $change_text . '</a>';

    }

    /**
     * Modifies account form to improve the UX of editing the email address and password fields
     *
     * Adds a "Current Password" field used to verify the existing user password when changing passwords.
     *
     * Forces email & password fields to be required and makes them disabled and visually hidden on page load.
     *
     * Adds a toggle button for each set of fields, when the toggle is clicked the fields are revealed and enabled
     * so they can be used. Ensuring that the fields are only required when they're being explicitly changed.
     *
     * @since 5.0.0
     *
     * @param array[] $blocks   Array of parsed WP_Block arrays.
     * @param string  $location The form location ID.
     *
     * @return array[]
     */
    public function modify_account_form( $blocks, $location ) {

        // Only add toggles on the account edit form.
        if ( 'account' !== $location ) {
            return $blocks;
        }

        $blocks = $this->modify_toggle_blocks( $blocks );

        foreach ( array( 'email_address', 'password' ) as $id ) {
            $field  = $this->find_block( $id, $blocks );
            $blocks = $field ? $this->{"toggle_for_$id"}( $field, $blocks ) : $blocks;
        }

        return $blocks;

    }

    /**
     * Maybe add the required email and password block to a form.
     *
     * @since 5.1.0
     * @since 5.4.1 Make sure added reusable blocks contain the actual required field,
     *              otherwise fall back on the dynamically generated ones.
     *
     * @param array[] $blocks   Array of parsed WP_Block arrays.
     * @param string  $location The request form location ID.
     * @param array   $args     Additional arguments passed to the short-circuit filter.
     * @return array[]
     */
    public function maybe_add_required_block_fields( $blocks, $location, $args ) {

        $fields_to_require = $this->get_required_fields_for_location( $location, $args );
        if ( empty( $fields_to_require ) ) {
            return $blocks;
        }

        foreach ( $fields_to_require as $field_id => $field_block_name ) {

            $block = $this->find_block( $field_id, $blocks );

            if ( ! empty( $block ) ) {
                // Fields in non checkout forms are always visible - see LLMS_Forms::get_form_html().
                $blocks = 'checkout' === $location ? $this->make_block_visible( $block[1], $blocks, $block[0] ) : $blocks;
                unset( $fields_to_require[ $field_id ] );
                if ( empty( $fields_to_require ) ) { // All the required blocks are present.
                    return $blocks;
                }
            }
        }

        return $this->add_required_block_fields( $fields_to_require, $blocks, $location );

    }

    /**
     * Add required block fields.
     *
     * @since 5.4.1
     *
     * @param string[] $fields_to_require Array of field ids to require.
     * @param array[]  $blocks            Array of parsed WP_Block arrays to add required fields to.
     * @param string   $location          The request form location ID.
     * @return array[]
     */
    private function add_required_block_fields( $fields_to_require, $blocks, $location ) {

        $blocks_to_add = array();
        foreach ( $fields_to_require as $field_id => $block_to_add ) {

            // If a reusable block exists for the field, use it. Otherwise use a dynamically generated block from the template schema.
            $use_reusable = LLMS_Form_Templates::find_reusable_block( $block_to_add );
            $block        = LLMS_Form_Templates::get_block( $block_to_add, $location, $use_reusable );

            if ( $use_reusable ) {
                // Load reusable block.
                $_blocks = LLMS_Forms::instance()->load_reusable_blocks( array( $block ) );
                // The reusable block doesn't contain the needed block, use a dynamically generated block from the template schema.
                if ( empty( $_blocks ) || ! $this->find_block( $field_id, $_blocks ) ) {
                    $_blocks = array( LLMS_Form_Templates::get_block( $block_to_add, $location, false ) );
                }
                $block = $_blocks[0];
            }

            $blocks_to_add[] = $block;
        }

        // Make blocks to add visible.
        $blocks_to_add = 'checkout' === $location ? array_map( array( $this, 'make_all_visible' ), $blocks_to_add ) : $blocks_to_add;

        return array_merge(
            $blocks,
            $blocks_to_add
        );

    }

    /**
     * Make a block visible within its list of blocks
     *
     * @since 5.1.0
     *
     * @param array   $block       Parsed WP_Block array.
     * @param array[] $blocks      Array of parsed WP_Block arrays.
     * @param int     $block_index Index of the block within the `$blocks` list.
     *                             If the block is in a group, this is the the index of the item's parent.
     * @return array[]
     */
    private function make_block_visible( $block, $blocks, $block_index ) {

        if ( LLMS_Forms::instance()->is_block_visible_in_list( $block, array( $blocks[ $block_index ] ) ) ) {
            return $blocks;
        }

        // If the block has a confirm group, use that.
        $confirm = $this->get_confirm_group( $block['attrs']['id'], array( $blocks[ $block_index ] ) );

        $block_to_add = empty( $confirm ) ? $block : $confirm;

        $replace = true;
        // Insert the visible block before the invisible one if the block is in a group,
        // so to avoid the replacement of the whole group which might contain other required fields.
        // But replace the invisible with the visible if otherwise.
        if ( $block_to_add !== $blocks[ $block_index ] ) {
            $replace = false;
            $this->remove_block( $block_to_add, $blocks );
        }

        // Make the block to add and its children visible.
        $block_to_add = $this->make_all_visible( $block_to_add );

        array_splice( $blocks, $block_index, (int) ( ! empty( $replace ) ), array( $block_to_add ) );

        return $blocks;

    }

    /**
     * Remove block from the list which contains it.
     *
     * @since 5.1.0
     *
     * @param array   $block  Parsed WP_Block array.
     * @param array[] $blocks Array of parsed WP_Block arrays (passed by reference).
     * @param array   $parent Optional. Parsed WP_Block array representing the parent block of the `$blocks`, in case this is a list of inner blocks. Default null.
     *                        Passed by reference.
     * @return bool
     */
    private function remove_block( $block, &$blocks, &$parent = null ) {

        foreach ( $blocks as $index => &$_block ) {

            if ( $_block === $block ) {
                array_splice( $blocks, $index, 1 ); // Remove and re-index.
                // If we're removing an innerBlock we need to update the innerContent too, to avoid wp calling the render method on nulls.
                if ( ! is_null( $parent ) ) {
                    $this->remove_inner_block_from_inner_content( $index, $parent );
                }
                return true;
            }

            if ( ! empty( $_block['innerBlocks'] ) ) {
                $removed = $this->remove_block( $block, $_block['innerBlocks'], $_block );
            }
            if ( ! empty( $removed ) ) { // Break as soon as the desired block is removed from one of the innerBlocks.
                return true;
            }
        }

        return false;

    }

    /**
     * Remove inner block reference from inner content
     *
     * See WP_Block::inner_content documentation.
     *
     * The inner_content block's property is an array of string fragments and null markers where inner blocks were found.
     * So here we cycle over the block's parent innerContent field looking for references to innerBlocks (null).
     * When we found a positional correspondance between the removed innerBlock and its refernce in innerContent we remove the latter too.
     *
     * @since 5.1.0
     *
     * @param int   $inner_block_index The index of the inner block in the block's innerBlocks list.
     * @param array $parent            Parsed WP_Block array representing the inner blocks parent. Passed by reference.
     */
    private function remove_inner_block_from_inner_content( $inner_block_index, &$parent ) {

        $inner_block_in_content_index = 0;
        foreach ( $parent['innerContent'] as $chunk_index => $chunk ) {
            if ( ! is_string( $chunk ) && $inner_block_index === $inner_block_in_content_index++ ) {
                array_splice( $parent['innerContent'], $chunk_index, 1 ); // Remove and re-index.
                break;
            }
        }

    }

    /**
     * Make the block and its children visible
     *
     * @since 5.1.0
     *
     * @param array $block A parsed WP_Block.
     * @return array
     */
    private function make_all_visible( $block ) {

        if ( ! empty( $block['innerBlocks'] ) ) {
            foreach ( $block['innerBlocks'] as $index => $inner_block ) {
                $block['innerBlocks'][ $index ] = $this->make_all_visible( $inner_block );
            }
        }
        $block['attrs']['llms_visibility'] = '';

        return $block;

    }

    /**
     * Get confirm group in a list of blocks for a given block id
     *
     * @since 5.1.0
     *
     * @param string  $id     The ID of the field to find the confirm group for.
     * @param array[] $blocks WP_Block list.
     * @return array
     */
    private function get_confirm_group( $id, $blocks ) {

        foreach ( $blocks as $index => $block ) {

            if ( $block['innerBlocks'] ) {
                if ( ( 'llms/form-field-confirm-group' === $block['blockName'] ) &&
                        $this->find_block( $id, $block['innerBlocks'] ) ) {
                    return $block;
                }
                $inner = $this->get_confirm_group( $id, $block['innerBlocks'] );
                if ( false !== $inner ) {
                    return $inner;
                }
            }
        }

        return false;
    }

    /**
     * Modifies block settings for toggle-controlled fields
     *
     * @since 5.0.0
     *
     * @param array[] $blocks Array of WP_Block arrays.
     * @return array[]
     */
    private function modify_toggle_blocks( $blocks ) {

        // List of toggle fields to modify.
        $fields = array(
            'email_address',
            'email_address_confirm',
            'password',
            'password_confirm',
        );

        foreach ( $blocks as &$block ) {

            if ( ! empty( $block['innerBlocks'] ) ) {
                $block['innerBlocks'] = $this->modify_toggle_blocks( $block['innerBlocks'] );
            } elseif ( ! empty( $block['attrs']['id'] ) && in_array( $block['attrs']['id'], $fields, true ) ) {
                $block['attrs']['wrapper_classes'] = 'llms-visually-hidden-field';
                $block['attrs']['disabled']        = true;
                $block['attrs']['required']        = true;
            }
        }

        return $blocks;
    }

    /**
     * Adds a toggle link button allowing the user to change their email address
     *
     * @since 5.0.0
     *
     * @param array   $email  Email field data as located by LLMS_Forms_Dynamic_Fields::find_block().
     * @param array[] $blocks Array of WP_Block arrays.
     * @return array[]
     */
    private function toggle_for_email_address( $email, $blocks ) {

        return $this->add_block(
            $blocks,
            array(
                'type'  => 'html',
                'id'    => 'llms-field-toggle--email',
                'value' => $this->get_toggle_button_html( '#email_address,#email_address_confirm', $email[1]['attrs']['label'] ),
            ),
            $email[0]
        );

    }


    /**
     * Adds a current password field and a toggle link button allowing the user to change their password
     *
     * @since 5.0.0
     *
     * @param array   $password Password field data as located by LLMS_Forms_Dynamic_Fields::find_block().
     * @param array[] $blocks   Array of WP_Block arrays.
     * @return array[]
     */
    private function toggle_for_password( $password, $blocks ) {

        // Add the toggle button.
        $blocks = $this->add_block(
            $blocks,
            array(
                'type'  => 'html',
                'id'    => 'llms-field-toggle--password',
                'value' => $this->get_toggle_button_html( '#password,#password_confirm,#llms-password-strength-meter,#password_current', $password[1]['attrs']['label'] ),
            ),
            $password[1]['attrs']['meter'] ? $password[0] + 1 : $password[0]
        );

        /**
         * Filters the settings used to create the dynamic password strength meter block
         *
         * @since 5.0.0
         *
         * @param array $settings Array or block attributes/settings.
         */
        $current_password = apply_filters(
            'llms_current_password_field_settings',
            array(
                'type'            => 'password',
                'id'              => 'password_current',
                'name'            => 'password_current',
                'label'           => sprintf( __( 'Current %s', 'lifterlms' ), $password[1]['attrs']['label'] ),
                'required'        => true,
                'disabled'        => true,
                'data_store_key'  => false,
                'wrapper_classes' => 'llms-visually-hidden-field',
            )
        );
        return $this->add_block( $blocks, $current_password, $password[0] - 1 );

    }

}

return new LLMS_Forms_Dynamic_Fields();