LLMS_Forms_Dynamic_Fields

Manage dynamically generated fields added to the form outside of the block editor


Source Source

File: includes/forms/class-llms-forms-dynamic-fields.php

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 );

	}

}

Top ↑

Methods Methods


Top ↑

Changelog Changelog

Changelog
Version Description
5.0.0 Introduced.

Top ↑

User Contributed Notes User Contributed Notes

You must log in before being able to contribute a note or feedback.