includes/forms/class-llms-forms.php
<?php
/**
* Register and manage LifterLMS user forms.
*
* @package LifterLMS/Classes
*
* @since 5.0.0
* @version 7.1.4
*/
defined( 'ABSPATH' ) || exit;
/**
* LLMS_Forms class
*
* @since 5.0.0
* @since 5.3.0 Replace singleton code with `LLMS_Trait_Singleton`.
*/
class LLMS_Forms {
use LLMS_Trait_Singleton;
/**
* Minimum Supported WP Version required to manage forms with the block editor UI.
*/
const MIN_WP_VERSION = '5.7.0';
/**
* Provide access to the post type manager class
*
* @var LLMS_Forms_Post_Type
*/
public $post_type_manager = null;
/**
* Private Constructor
*
* @since 5.0.0
*
* @return void
*/
private function __construct() {
$this->post_type_manager = new LLMS_Form_Post_Type( $this );
add_filter( 'render_block', array( $this, 'render_field_block' ), 10, 2 );
add_filter( 'llms_get_form_post', array( $this, 'maybe_load_preview' ) );
}
/**
* Determines if the WP core requirements are met
*
* This is used to determine if the block editor can be used to manage forms and fields,
* all frontend and server-side handling works on all core supported WP versions.
*
* @since 5.0.0
*
* @return boolean
*/
public function are_requirements_met() {
global $wp_version;
return version_compare( $wp_version, self::MIN_WP_VERSION, '>=' ) || is_plugin_active( 'gutenberg/gutenberg.php' );
}
/**
* Determine if usernames are enabled on the site.
*
* This method is used to determine if a username can be used to login / reset a user's password.
*
* A reference to every form with a username block is stored in an option. The option is an array
* of integers, the WP_Post IDs of all the form posts containing a username block.
*
* If the array is empty, there are no forms with username blocks and, therefore, usernames are disabled.
* If the array contains at least one item that means there is a form with a username block in it and,
* we therefore consider usernames to be enabled for the site.
*
* This isn't perfect. We're well aware. But usernames are kind of silly anyway, right? Just use the email
* address like your average website owner and stop pretending usernames matter.
*
* @since 5.0.0
*
* @return bool
*/
public function are_usernames_enabled() {
$locations = get_option( 'llms_forms_username_locations', array() );
/**
* Use this to explicitly enable of disable username fields.
*
* Note that usage of this filter will not actually disable the llms/form-field-username block.
* It's possible to create a confusing user experience by explicitly disabling usernames and
* leaving username field blocks on one or more forms. If you decide to explicitly disable via
* this filter you should also remove all the username blocks from all of your forms.
*
* @since 5.0.0
*
* @param boolean $enabled Whether or not usernames are enabled.
*/
return apply_filters( 'llms_are_usernames_enabled', ! empty( $locations ) );
}
/**
* Converts a block to settings understandable by `llms_form_field()`
*
* @since 5.0.0
* @since 5.1.0 Added logic to remove invisible fields.
* Added `$block_list` param.
*
* @param array $block A WP Block array.
* @param array[] $block_list Optional. The list of WP Block array `$block` comes from. Default is empty array.
* @return array
*/
private function block_to_field_settings( $block, $block_list = array() ) {
$is_visible = $this->is_block_visible_in_list( $block, $block_list );
/**
* Filters whether or not invisible fields should be included
*
* If the block is not visible (according to LLMS block-level visibility settings)
* it will return an empty array (signaling the field to be removed).
*
* @since 5.1.0
*
* @param boolean $filter Whether or not invisible fields should be included. Default is `false`.
* @param array $block A WP Block array.
* @param array[] $block_list The list of WP Block array `$block` comes from.
*/
if ( ! $is_visible && apply_filters( 'llms_forms_remove_invisible_field', false, $block, $block_list ) ) {
return array();
}
$attrs = $this->convert_settings_format( $block['attrs'], 'block' );
// If the field is required and hidden it's impossible for the user to fill it out so it gets marked as optional at runtime.
if ( ! empty( $attrs['required'] ) && ! $is_visible ) {
$attrs['required'] = false;
}
/**
* Filter an LLMS_Form_Field settings array after conversion from a field block
*
* @since 5.0.0
* @since 5.1.0 Added `$block_list` param.
*
* @param array $attrs An array of LLMS_Form_Field settings.
* @param array $block A WP Block array.
* @param array[] $block_list The list of WP Block array `$block` comes from.
*/
return apply_filters( 'llms_forms_block_to_field_settings', $attrs, $block, $block_list );
}
/**
* Cascade all llms_visibility attributes down into inner blocks.
*
* If a parent block has a visibility setting this will apply that visibility to a chlid block *if*
* the child block does not have a visibility setting of its own.
*
* Ultimately this ensures that a field block that's not visible can be marked as "optional" so that
* form validation can take place.
*
* For example, if a columns block is displayed only to logged out users and it's child fields are marked
* as required that means that it's required only to logged out users and the field becomes "optional"
* (for validation purposes) to logged in users.
*
* @since 5.0.0
*
* @param array[] $blocks Array of parsed block arrays.
* @param string|null $visibility The llms_visibility attribute of the parent block which is applied to all innerBlocks
* if the innerBlock does not already have it's own visibility attribute.
* @return array[]
*/
private function cascade_visibility_attrs( $blocks, $visibility = null ) {
foreach ( $blocks as &$block ) {
// If a visibility setting has been passed from the parent and the block does not have visibility setting of it's own.
if ( $visibility && ( empty( $block['attrs']['llms_visibility'] ) || 'off' === $block['attrs']['llms_visibility'] ) ) {
$block['attrs']['llms_visibility'] = $visibility;
}
// This block has a visibility attribute and it should be applied it to all the innerBlocks.
if ( ! empty( $block['attrs']['llms_visibility'] ) && ! empty( $block['innerBlocks'] ) ) {
$block['innerBlocks'] = $this->cascade_visibility_attrs( $block['innerBlocks'], $block['attrs']['llms_visibility'] );
}
}
return $blocks;
}
/**
* Converts field settings formats
*
* There are small differences between the LLMS_Form_Fields settings array
* and the WP_Block settings array.
*
* This method accepts an associative array
* in one format or the other and converts it from the original format to the opposite format.
*
* @since 5.0.0
*
* @param array $map Associative array of settings.
* @param string $orignal_format The original format of the submitted `$map`. Either "field" for
* an array of LLMS_Form_Field settings or `block` for an array
* of WP_Block attributes.
* @return [type] [description]
*/
private function convert_settings_format( $map, $orignal_format ) {
// Block attributes to LLMS_Form_Field settings.
$keys = array(
'field' => 'type',
'className' => 'classes',
'html_attrs' => 'attributes',
);
// LLMS_Form_Field settings to block attributes.
if ( 'field' === $orignal_format ) {
$keys = array_flip( $keys );
}
// Loop through the original map and rename the necessary keys.
foreach ( $keys as $orig_key => $new_key ) {
if ( isset( $map[ $orig_key ] ) ) {
$map[ $new_key ] = $map[ $orig_key ];
unset( $map[ $orig_key ] );
}
}
return $map;
}
/**
* Converts an array of LLMS_Form_Field settings to a block attributes array
*
* @since 5.0.0
*
* @param array $settings An array of LLMS_Form_Field settings.
* @return array An array of WP_Block attributes.
*/
public function convert_settings_to_block_attrs( $settings ) {
return $this->convert_settings_format( $settings, 'field' );
}
/**
* Create a form for a given location with the provided data.
*
* @since 5.0.0
*
* @param string $location_id Location id.
* @param bool $recreate If `true` and the form already exists, will recreate the existing form using the existing form's id.
* @return int|false Returns the created/update form post ID on success.
* If the location doesn't exist, returns `false`.
* If the form already exists and `$recreate` is `false` will return `false`.
*/
public function create( $location_id, $recreate = false ) {
if ( ! $this->is_location_valid( $location_id ) ) {
return false;
}
$locs = $this->get_locations();
$data = $locs[ $location_id ];
$existing = $this->get_form_post( $location_id );
// Form already exists and we haven't requested an update.
if ( false !== $existing && ! $recreate ) {
return false;
}
$args = array(
'ID' => $existing ? $existing->ID : 0,
'post_content' => LLMS_Form_Templates::get_template( $location_id ),
'post_status' => 'publish',
'post_title' => $data['title'],
'post_type' => $this->get_post_type(),
'meta_input' => $data['meta'],
'post_author' => $existing ? $existing->post_author : LLMS_Install::get_can_install_user_id(),
);
/**
* Filter arguments used to install a new form.
*
* @since 5.0.0
*
* @param array $args Array of arguments to be passed to wp_insert_post
* @param string $location_id Location ID/name.
* @param array $data Array of location information from LLMS_Forms::get_locations().
*/
$args = apply_filters( 'llms_forms_install_post_args', $args, $location_id, $data );
return wp_insert_post( $args );
}
/**
* Retrieve the form management user capability.
*
* @since 5.0.0
*
* @return string
*/
public function get_capability() {
return $this->post_type_manager->capability;
}
/**
* Pull LifterLMS Form Field blocks from an array of parsed WP Blocks.
*
* Searches innerBlocks arrays recursively.
*
* @since 5.0.0
* @since 5.1.0 First check block's innerBlock attribute exists when checking for inner blocks.
* Also made the access visibility public.
* @since 5.9.0 Pass an empty string to `strpos()` instead of `null`.
*
* @param array $blocks Array of WP Block arrays from `parse_blocks()`.
* @return array
*/
public function get_field_blocks( $blocks ) {
$fields = array();
foreach ( $blocks as $block ) {
if ( ! empty( $block['innerBlocks'] ) ) {
$fields = array_merge( $fields, $this->get_field_blocks( $block['innerBlocks'] ) );
} elseif ( false !== strpos( $block['blockName'] ?? '', 'llms/form-field-' ) ) {
$fields[] = $block;
} elseif ( 'core/html' === $block['blockName'] && ! empty( $block['attrs']['type'] ) ) {
$fields[] = $block;
}
}
return $fields;
}
/**
* Returns a list of field names used by LifterLMS forms
*
* Used to validate uniqueness of custom field data.
*
* @since 5.0.0
*
* @return string[]
*/
public function get_field_names() {
$names = array(
'user_login',
'user_login_confirm',
'email_address',
'email_address_confirm',
'password',
'password_confirm',
'first_name',
'last_name',
'display_name',
'llms_billing_address_1',
'llms_billing_address_2',
'llms_billing_city',
'llms_billing_country',
'llms_billing_state',
'llms_billing_zip',
'llms_phone',
);
/**
* Filters the list of field names used by LifterLMS forms
*
* @since 5.0.0
*
* @param string[] $names List of registered field names.
*/
return apply_filters( 'llms_forms_field_names', $names );
}
/**
* Retrieve an array of parsed blocks for the form at a given location.
*
* @since 5.0.0
*
* @param string $location Form location, one of: "checkout", "registration", or "account".
* @param array $args Additional arguments passed to the short-circuit filter.
* @return array|false
*/
public function get_form_blocks( $location, $args = array() ) {
$post = $this->get_form_post( $location, $args );
if ( ! $post ) {
return false;
}
$content = $post->post_content;
$content .= $this->get_additional_fields_html( $location, $args );
$blocks = $this->parse_blocks( $content );
/**
* Filters the parsed block list for a given LifterLMS form
*
* This hook can be used to programmatically modify, insert, or remove
* blocks (fields) from a form.
*
* @since 5.0.0
*
* @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 apply_filters( 'llms_get_form_blocks', $blocks, $location, $args );
}
/**
* Retrieve an array of LLMS_Form_Fields settings arrays for the form at a given location.
*
* This method is used by the LLMS_Form_Handler to perform validations on user-submitted data.
*
* @since 5.0.0
*
* @param string $location Form location, one of: "checkout", "registration", or "account".
* @param array $args Additional arguments passed to the short-circuit filter in `get_form_post()`.
* @return false|array
*/
public function get_form_fields( $location, $args = array() ) {
$blocks = $this->get_form_blocks( $location, $args );
if ( false === $blocks ) {
return false;
}
$fields = $this->get_fields_settings_from_blocks( $blocks );
/**
* Modify the parsed array of LifterLMS Form Fields
*
* @since 5.0.0
*
* @param array[] $fields Array of LifterLMS Form Field settings data.
* @param string $location Form location, one of: "checkout", "registration", or "account".
* @param array $args Additional arguments passed to the short-circuit filter in `get_form_post()`.
*/
return apply_filters( 'llms_get_form_fields', $fields, $location, $args );
}
/**
* Retrieve an array of LLMS_Form_Field settings from an array of blocks.
*
* @since 5.0.0
* @since 5.1.0 Pass the whole list of blocks to the `$this->block_to_field_settings()` method
* to better check whether a block is visible.
* @since 6.2.0 Exploded hidden checkbox fields.
*
* @param array $blocks Array of WP Block arrays from `parse_blocks()`.
* @return array
*/
public function get_fields_settings_from_blocks( $blocks ) {
$fields = array();
$blocks = $this->get_field_blocks( $blocks );
foreach ( $blocks as $block ) {
$settings = $this->block_to_field_settings( $block, $blocks );
if ( empty( $settings ) ) {
continue;
}
if (
'hidden' === ( $settings['type'] ?? null ) &&
isset( $block['attrs']['field'] ) && 'checkbox' === $block['attrs']['field']
) {
// Convert hidden checkbox settings into multiple "checked" hidden fields.
$settings['type'] = $block['attrs']['field'];
$field = new LLMS_Form_Field( $settings );
$form_fields = $field->explode_options_to_fields( true );
foreach ( $form_fields as $form_field ) {
$fields[] = $form_field->get_settings();
}
} else {
$field = new LLMS_Form_Field( $settings );
$fields[] = $field->get_settings();
}
}
return $fields;
}
/**
* Retrieve a field item from a list of fields by a key/value pair.
*
* @since 5.0.0
*
* @param array[] $fields List of LifterLMS Form Fields.
* @param string $key Setting key to search for.
* @param mixed $val Setting valued to search for.
* @param string $return Determine the return value. Use "field" to return the field settings
* array. Use "index" to return the index of the field in the $fields array.
* @return array|int|false `false` when the field isn't found in $fields, otherwise returns the field settings
* as an array when `$return` is "field". Otherwise returns the field's index as an int.
*/
public function get_field_by( $fields, $key, $val, $return = 'field' ) {
foreach ( $fields as $index => $field ) {
if ( isset( $field[ $key ] ) && $val === $field[ $key ] ) {
return 'field' === $return ? $field : $index;
}
}
return false;
}
/**
* Retrieve the rendered HTML for the form at a given location.
*
* @since 5.0.0
*
* @param string $location Form location, one of: "checkout", "registration", or "account".
* @param array $args Additional arguments passed to the short-circuit filter in `get_form_post()`.
* @return string
*/
public function get_form_html( $location, $args = array() ) {
$blocks = $this->get_form_blocks( $location, $args );
if ( ! $blocks ) {
return '';
}
$disable_visibility = ( 'checkout' !== $location );
// Force fields to display regardless of visibility settings when viewing account/registration forms.
if ( $disable_visibility ) {
add_filter( 'llms_blocks_visibility_should_filter_block', '__return_false', 999 );
}
$html = '';
foreach ( $blocks as $block ) {
$html .= render_block( $block );
}
if ( $disable_visibility ) {
remove_filter( 'llms_blocks_visibility_should_filter_block', '__return_false', 999 );
}
/**
* Modify the parsed array of LifterLMS Form Fields.
*
* @since 5.0.0
*
* @param string $html Form fields HTML.
* @param string $location Form location, one of: "checkout", "registration", or "account".
* @param array $args Additional arguments passed to the short-circuit filter in `get_form_post()`.
*/
return apply_filters( 'llms_get_form_html', $html, $location, $args );
}
/**
* Retrieve the WP Post for the form at a given location.
*
* @since 5.0.0
*
* @param string $location Form location, one of: "checkout", "registration", or "account".
* @param array $args Additional arguments passed to the short-circuit filter.
* @return WP_Post|false
*/
public function get_form_post( $location, $args = array() ) {
// @todo Add caching. This runs twice on some page loads.
/**
* Skip core lookup of the form for the request location and return a custom form post.
*
* @since 5.0.0
*
* @param null|WP_Post $post Return a WP_Post object to short-circuit default lookup query.
* @param string $location Form location. Either "checkout", "registration", or "account".
* @param array $args Additional custom arguments.
*/
$post = apply_filters( 'llms_get_form_post_pre_query', null, $location, $args );
if ( is_a( $post, 'WP_Post' ) ) {
return $post;
}
$query = new WP_Query(
array(
'post_type' => $this->get_post_type(),
'posts_per_page' => 1,
'no_found_rows' => true,
// Only show published forms to end users but allow admins to "preview" drafts.
'post_status' => current_user_can( $this->get_capability() ) ? array( 'publish', 'draft' ) : 'publish',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_llms_form_location',
'value' => $location,
),
array(
'key' => '_llms_form_is_core',
'value' => 'yes',
),
),
)
);
$post = $query->have_posts() ? $query->posts[0] : false;
/**
* Filters the returned `llms_form` post object
*
* @since 5.0.0
*
* @param WP_Post|boolean $post The post object of the form or `false` if no form could be located.
* @param string $location Form location. Either "checkout", "registration", or "account".
* @param array $args Additional custom arguments.
*/
return apply_filters( 'llms_get_form_post', $post, $location, $args );
}
/**
* Check whether a given form is a core form.
*
* When there are multiple forms for a location, the core form is identified as the one with the lowest ID.
*
* @since 6.4.0
*
* @param WP_Post|int $form Form's WP_Post instance, or its ID.
* @return boolean
*/
public function is_a_core_form( $form ) {
$form_id = $form instanceof WP_Post ? $form->ID : $form;
if ( ! $form_id ) {
return false;
}
return in_array( $form_id, $this->get_core_forms( 'ids' ), true );
}
/**
* Retrieves only core forms.
*
* When there are multiple forms for a location, the core form is identified as the one with the lowest ID.
*
* @since 6.4.0
*
* @param string $return What to return: 'posts', for an array of WP_Post; 'ids' for an array of WP_Post ids.
* @return WP_Post[]|int[]
*/
private function get_core_forms( $return = 'posts', $use_cache = true ) {
global $wpdb;
$forms_cache_key = 'posts' === $return ? 'llms_core_forms' : 'llms_core_form_ids';
$forms = $use_cache ? wp_cache_get( $forms_cache_key ) : false;
if ( false !== $forms ) {
return $forms;
}
$locations = array_keys( $this->get_locations() );
$locations_placeholders = implode( ',', array_fill( 0, count( $locations ), '%s' ) );
$prepare_values = array_merge( array( $this->get_post_type() ), $locations );
$query = "
SELECT MIN({$wpdb->posts}.ID) AS ID
FROM $wpdb->posts
INNER JOIN {$wpdb->postmeta} AS locations ON {$wpdb->posts}.ID = locations.post_id AND locations.meta_key='_llms_form_location'
INNER JOIN {$wpdb->postmeta} AS is_cores ON {$wpdb->posts}.ID = is_cores.post_id AND is_cores.meta_key='_llms_form_is_core'
WHERE {$wpdb->posts}.post_type = %s
AND locations.meta_value IN ({$locations_placeholders})
AND is_cores.meta_value = 'yes'
GROUP BY locations.meta_value";
$form_ids = $wpdb->get_col(
$wpdb->prepare(
$query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- It is prepared.
$prepare_values
)
);
$form_ids = array_map( 'absint', $form_ids );
$forms = 'post' === $return ? array_map( 'get_post', $form_ids ) : $form_ids;
wp_cache_set( $forms_cache_key, $forms );
return $forms;
}
/**
* Retrieve additional fields added to the form programmatically.
*
* @since 5.0.0
*
* @param string $location Form location, one of: "checkout", "registration", or "account".
* @param array $args Additional arguments passed to the short-circuit filter.
* @return array[]
*/
private function get_additional_fields( $location, $args = array() ) {
/**
* Filter to add custom fields to a form programmatically.
*
* @since 3.0.0
* @since 5.0.0 Moved from deprecated function `LLMS_Person_Handler::get_available_fields()`.
*
* @param array[] $fields Array of field array suitable to pass to `llms_form_field()`.
* @param string $location Form location, one of: "checkout", "registration", or "account".
* @param array $args Additional arguments passed to the short-circuit filter.
*/
return apply_filters( 'lifterlms_get_person_fields', array(), $location, $args );
}
/**
* Retrieve HTML for the form's additional programmatically-added fields.
*
* Gets the HTML for each field from `llms_form_field()` and wraps it as a `wp/html` block.
*
* @since 5.0.0
*
* @param string $location Form location, one of: "checkout", "registration", or "account".
* @param array $args Additional arguments passed to the short-circuit filter.
* @return string
*/
private function get_additional_fields_html( $location, $args = array() ) {
$html = '';
$fields = $this->get_additional_fields( $location, $args );
foreach ( $fields as $field ) {
$html .= "\r" . $this->get_custom_field_block_markup( $field );
}
return $html;
}
/**
* Retrieve the HTML markup for a custom form field block
*
* Retrieves an array of `LLMS_Form_Field` settings, generates the HTML
* for the field, and wraps it in a `wp:html` block.
*
* @since 5.0.0
*
* @param array $settings Form field settings (passed to `llms_form_field()`).
* @return string
*/
public function get_custom_field_block_markup( $settings ) {
return sprintf( '<!-- wp:html %1$s -->%2$s%3$s%2$s<!-- /wp:html -->', wp_json_encode( $settings ), "\r", llms_form_field( $settings, false ) );
}
/**
* Retrieve an array of form fields used for the "free enrollment" form
*
* This is the "one-click" enrollment form used when a logged-in user clicks the "checkout" button
* from an access plan.
*
* This function converts the checkout form to hidden fields, the result is that users with all required fields
* will be enrolled into the course with a single click (no need to head to the checkout page) and users
* who are missing required information will be directed to the checkout page.
*
* @since 5.0.0
* @since 5.1.0 Specifiy to pass the new 3rd param to the `llms_forms_block_to_field_settings` filter callback.
* @since 5.9.0 Fix php 8.1 deprecation warnings when `get_form_fields()` returns `false`.
* @since 7.0.0 Retrieve and use the free checkout redirect URL as not encoded.
*
* @param LLMS_Access_Plan $plan Access plan being used for enrollment.
* @return array[] List of LLMS_Form_Field settings arrays.
*/
public function get_free_enroll_form_fields( $plan ) {
// Convert all fields to hidden fields and remove any fields hidden by LLMS block-level visibility settings.
add_filter( 'llms_forms_block_to_field_settings', array( $this, 'prepare_field_for_free_enroll_form' ), 999, 3 );
$fields = $this->get_form_fields( 'checkout', compact( 'plan' ) );
remove_filter( 'llms_forms_block_to_field_settings', array( $this, 'prepare_field_for_free_enroll_form' ), 999, 3 );
// If no fields are found, ensure we add to an array instead of casting false to an array (causing a PHP 8.1 deprecation warning).
$fields = ! is_array( $fields ) ? array() : $fields;
// Add additional fields required for form processing.
$fields[] = array(
'name' => 'free_checkout_redirect',
'type' => 'hidden',
'value' => $plan->get_redirection_url( false ),
'data_store_key' => false,
);
$fields[] = array(
'id' => 'llms-plan-id',
'name' => 'llms_plan_id',
'type' => 'hidden',
'value' => $plan->get( 'id' ),
'data_store_key' => false,
);
/**
* Filter the list of LLMS_Form_Fields used to generate the "free enrollment" form
*
* @since 5.0.0
*
* @param array[] $fields List of LLMS_Form_Field settings arrays.
* @param LLMS_Access_Plan $plan Access plan being used for enrollment.
*/
return apply_filters( 'llms_forms_get_free_enroll_form_fields', $fields, $plan );
}
/**
* Retrieve the HTML of form fields used for the "free enrollment" form
*
* @since 5.0.0
*
* @see LLMS_Forms::get_free_enroll_form_fields()
*
* @param LLMS_Access_Plan $plan Access plan being used for enrollment.
* @return string
*/
public function get_free_enroll_form_html( $plan ) {
$html = '';
foreach ( $this->get_free_enroll_form_fields( $plan ) as $field ) {
$html .= llms_form_field( $field, false );
}
return $html;
}
/**
* Retrieve information on all the available form locations.
*
* @since 5.0.0
*
* @return array[] {
* An associative array. The array key is the location ID and each array is a location definition array.
*
* @type string $name The human-readable location name (as displayed on the admin panel).
* @type string $description A description of the form (as displayed on the admin panel).
* @type string $title The form's post title. This is displayed to the end user when the "Show Form Title" option is enabled.
* @type array $meta An associative array of postmeta information for the form. The array key is the meta key and the value is the meta value.
* @type string $template A string used to generate the post content of the form post, usually retrieve from `LLMS_Form_Templates`.
* @type array $meta Array of meta data used when generating the form. The array key is the meta key and array value is the meta value.
* @type array[] $required Array of arrays defining required fields for each form.
* }
*/
public function get_locations() {
$locations = require LLMS_PLUGIN_DIR . 'includes/schemas/llms-form-locations.php';
/**
* Filter the available form locations.
*
* NOTE: Removing core forms (as well as modifying the ids / keys) may cause areas of LifterLMS to stop working.
*
* @since 5.0.0
*
* @param array[] $locations Associative array of form location information.
*/
return apply_filters( 'llms_forms_get_locations', $locations );
}
/**
* Retrieve the forms post type name.
*
* @since 5.0.0
*
* @return string
*/
public function get_post_type() {
return $this->post_type_manager->post_type;
}
/**
* Determine if a block is visible based on LifterLMS Visibility Settings.
*
* @since 5.0.0
* @since 7.1.4 Fixed an issue running unit tests on PHP 7.4 and WordPress 6.2
* expecting `render_block()` returning a string while we were applying a filter
* that returned the boolean `true`.
*
* @param array $block Parsed block array.
* @return bool
*/
private function is_block_visible( $block ) {
// Make the block return a non empty string if it's visible, it will already automatically return an empty string if it's invisible.
add_filter( 'render_block', array( __CLASS__, '__return_string' ), 5 );
// Don't run this class render function on the block during this test.
remove_filter( 'render_block', array( $this, 'render_field_block' ), 10, 2 );
// Render the block.
$render = render_block( $block );
// Cleanup / reapply filters.
add_filter( 'render_block', array( $this, 'render_field_block' ), 10, 2 );
remove_filter( 'render_block', array( __CLASS__, '__return_string' ), 5 );
/**
* Filter whether or not the block is visible.
*
* @since 5.0.0
*
* @param bool $visible Whether or not the block is visible.
* @param array $block Parsed block array.
*/
return apply_filters( 'llms_forms_is_block_visible', llms_parse_bool( $render ), $block );
}
/**
* Determine if a block is visible in the list it's contained based on LifterLMS Visibility Settings
*
* Fall back on `$this->is_block_visible()` if empty `$block_list` is provided.
*
* @since 5.1.0
*
* @param array $block Parsed block array.
* @param array[] $block_list The list of WP Block array `$block` comes from.
* @return bool Returns `true` if `$block` (and all its parents) are visible. Returns `false` when `$block`
* or any of its parents are hidden or when `$block` is not found within `$block_list`.
*/
public function is_block_visible_in_list( $block, $block_list ) {
if ( empty( $block_list ) ) {
return $this->is_block_visible( $block );
}
$path = $this->get_block_path( $block, $block_list );
$is_visible = ! empty( $path ); // Assume the block is visible until proven hidden, except when path is empty.
foreach ( $path as $block ) {
if ( ! $this->is_block_visible( $block ) ) {
$is_visible = false;
break;
}
}
/**
* Filter whether or not the block is visible in the list of blocks it's contained.
*
* @since 5.1.0
*
* @param bool $is_visible Whether or not the block is visible.
* @param array $block Parsed block array.
* @param array[] $block_list The list of WP Block array `$block` comes from.
*/
return apply_filters( 'llms_forms_is_block_visible', $is_visible, $block, $block_list );
}
/**
* Returns a list of block parents plus the block itself in reverse order
*
* @since 5.1.0
*
* @param array $block Parsed block array.
* @param array[] $block_list The list of WP Block array `$block` comes from.
* @param int $iterations Stores the number of iterations.
* @return array[] List of WP_Block arrays or an empty array if `$block` cannot be found within `$block_list`.
*/
private function get_block_path( $block, $block_list, $iterations = 0 ) {
foreach ( $block_list as $_block ) {
// Found the block.
if ( $block === $_block ) {
return array( $block );
}
// No innerblocks, proceed to the next block.
if ( empty( $_block['innerBlocks'] ) ) {
continue;
}
// Look in innerblocks for the block.
foreach ( $_block['innerBlocks'] as $inner_block ) {
// The inner block needs to be merged to the path.
$to_merge = array( $inner_block );
if ( $block === $inner_block ) { // Inner block is the one we're looking for.
$path = array( $block );
$to_merge = array(); // Inner block equals the path, no need to merge it.
} else {
$path = $this->get_block_path( $block, array( $inner_block ), $iterations + 1 );
}
if ( $path ) {
// First iteration, append first block too.
if ( ! $iterations ) {
$to_merge[] = $_block;
}
// Merge.
return array_merge( $path, $to_merge );
}
}
}
// Block not found in the list.
return array();
}
/**
* Returns a filtered version of `$block_list` containing only the passed `$block` and its parents.
*
* @since 5.1.0
*
* @param array $block Parsed block array.
* @param array[] $block_list The list of WP Block array `$block` comes from.
* @return array[] Filtered version of `$block_list` containing only the passed `$block` and its parents.
* Or an empty array if `$block` cannot be found within `$block_list`.
*/
private function get_block_tree( $block, $block_list ) {
foreach ( $block_list as &$_block ) {
// Found the block.
if ( $block === $_block ) {
return array( $block );
}
if ( ! empty( $_block['innerBlocks'] ) ) {
$tree = $this->get_block_tree( $block, $_block['innerBlocks'] );
}
if ( ! empty( $tree ) ) { // Break as soon as the desired block is removed from one of the innerBlocks.
if ( $_block['innerBlocks'] !== $tree ) { // Update innerBlocks/innerContent structure if needed.
$_block['innerBlocks'] = $tree;
// Update innerContent to reflect the innerBlocks changes = only 1 innerBlock.
$inner_block_in_content_index = 0;
foreach ( $_block['innerContent'] as $index => $chunk ) {
if ( ! is_string( $chunk ) && $inner_block_in_content_index++ ) {
unset( $_block['innerContent'][ $index ] );
}
}
// Re-index.
$_block['innerContent'] = array_values( $_block['innerContent'] );
}
return array( $_block );
}
}
return array();
}
/**
* Installation function to install core forms.
*
* @since 5.0.0
*
* @param bool $recreate Whether or not to recreate an existing form. This is passed to `LLMS_Forms::create()`.
* @return WP_Post[] Array of created posts. Array key is the location id and array value is the WP_Post object.
*/
public function install( $recreate = false ) {
$installed = array();
foreach ( array_keys( $this->get_locations() ) as $location ) {
$installed[ $location ] = $this->create( $location, $recreate );
}
return $installed;
}
/**
* Determines if a location is a valid & registered form location
*
* @since 5.0.0
*
* @param string $location The location id.
* @return boolean
*/
public function is_location_valid( $location ) {
return in_array( $location, array_keys( $this->get_locations() ), true );
}
/**
* Loads reusable blocks into a block list.
*
* A reusable block contains a reference to the block post, e.g. `<!-- wp:block {"ref":2198} /-->`,
* which will be loaded during rendering.
*
* Dereferencing the reusable blocks allows the entire block list to be reviewed and to validate all form fields.
* This function will replace each reusable block with the parsed blocks from its reference post.
*
* @since 5.0.0
* @since 5.1.0 Access turned to public.
*
* @param array[] $blocks An array of blocks from `parse_blocks()`,
* where each block is usually an array cast from `WP_Block_Parser_Block`.
*
* @return array[]
*/
public function load_reusable_blocks( $blocks ) {
$loaded = array();
foreach ( $blocks as $block ) {
// Skip blocks that are not reusable blocks.
if ( 'core/block' === $block['blockName'] ) {
// Skip reusable blocks that do not exist or are not published.
$post = get_post( $block['attrs']['ref'] );
if ( ! $post || 'publish' !== get_post_status( $post ) ) {
continue;
}
$loaded = array_merge( $loaded, $this->parse_blocks( $post->post_content ) );
continue;
}
// Does this block's inner blocks have references to reusable blocks?
if ( $block['innerBlocks'] ) {
$block['innerBlocks'] = $this->load_reusable_blocks( $block['innerBlocks'] );
}
$loaded[] = $block;
}
return $loaded;
}
/**
* Load form autosaves when previewing a form
*
* @since 5.0.0
*
* @param WP_Post|boolean $post WP_Post object for the llms_form post or `false` if no form found.
* @return WP_Post|boolean
*/
public function maybe_load_preview( $post ) {
// No form post found.
if ( ! is_object( $post ) ) {
return $post;
}
// The `_set_preview()` method is marked as private but has existed since 2.7 and my guess is that we can use this safely.
if ( ! function_exists( '_set_preview' ) ) {
return $post;
}
$is_preview = ( is_preview() && current_user_can( $this->get_capability(), $post->ID ) );
return $is_preview ? _set_preview( $post ) : $post;
}
/**
* Parse the post_content of a form into a list of WP_Block arrays.
*
* This method parses the blocks, loads block data from any reusable blocks,
* and cascades visibility attributes onto a block's innerBlocks.
*
* @since 5.0.0
*
* @param string $content Post content HTML.
* @return array[] Array of parsed block arrays.
*/
public function parse_blocks( $content ) {
$blocks = parse_blocks( $content );
$blocks = $this->load_reusable_blocks( $blocks );
$blocks = $this->cascade_visibility_attrs( $blocks );
return $blocks;
}
/**
* Modifies a field for usage in the "free enrollment" checkout form
*
* If the block is not visible (according to LLMS block-level visibility settings)
* it will return an empty array (signaling the field to be removed).
*
* Otherwise the block will be converted to a hidden field.
*
* This method is a filter callback and is intended for internal use only.
*
* Backwards incompatible changes and/or method removal may occur without notice.
*
* @since 5.0.0
* @since 5.1.0 Added `$block_list` param.
* @access private
*
* @param array $attrs LLMS_Form_Field settings array for the field.
* @param array $block WP_Block settings array.
* @param array[] $block_list The list of WP Block array `$block` comes from.
* @return array
*/
public function prepare_field_for_free_enroll_form( $attrs, $block, $block_list ) {
if ( ! $this->is_block_visible_in_list( $block, $block_list ) ) {
return array();
}
$attrs['type'] = 'hidden';
return $attrs;
}
/**
* Render form field blocks.
*
* @since 5.0.0
* @since 5.9.0 Pass an empty string to `strpos()` instead of `null`.
*
* @param string $html Block HTML.
* @param array $block Array of block information.
* @return string
*/
public function render_field_block( $html, $block ) {
// Return HTML for any non llms/form-field blocks.
if ( false === strpos( $block['blockName'] ?? '', 'llms/form-field-' ) ) {
return $html;
}
if ( ! empty( $block['innerBlocks'] ) ) {
$inner_blocks = array_map( 'render_block', $block['innerBlocks'] );
return implode( "\n", $inner_blocks );
}
$attrs = $this->block_to_field_settings( $block );
return llms_form_field( $attrs, false );
}
/**
* Returns a non-empty string.
*
* Useful for returning a non empty string to filters easily.
*
* @since 7.1.4
*
* @access private
*
* @return string
*/
public static function __return_string(): string {// phpcs:ignore -- PHPCompatibility.FunctionNameRestrictions.ReservedFunctionNames.MethodDoubleUnderscore.
return '1';
}
}
return LLMS_Forms::instance();