• Resolved Bartek20

    (@bartek20)


    Since I’m using the Lite version of WPForms, I had to implement the field myself, which is normally a paid field.
    After implementing custom field, during testing, I discovered that the button for adding a field to the form (in the builder) was duplicated. After reviewing the code, I found a bug in the official implementation of button generation.
    I can’t confirm whether the error also occurs in the paid version of the plugin (I assume it does), but in the Lite version, it is visible when creating a field of the same type as one of the default fields.

    Bug Location:
    File: src/Lite/Admin/Education/Builder/Fields.php
    Function: add_fields
    Description: In the code, the search for a field of type is incorrectly performed on the field group, rather than on the field list.

    // What is in code
    // Skip if in the current group already exist field of this type.
    if ( ! empty( wp_list_filter( $group_data, [ 'type' => $edu_field['type'] ] ) ) ) {
    continue;
    }

    // What should be
    // Skip if in the current group already exist field of this type.
    if ( ! empty( wp_list_filter( $group_data[ 'fields' ], [ 'type' => $edu_field['type'] ] ) ) ) {
    continue;
    }
Viewing 4 replies - 1 through 4 (of 4 total)
  • Plugin Support Ralden Souza

    (@rsouzaam)

    Hi @bartek20,

    Thanks for reaching out and for the detailed bug report! We really appreciate you taking the time to dig into the code and share your findings!

    Before I pass this along to our development team for review, it would help to have some additional context. Could you share a copy of the custom field code you implemented? That will give the team what they need to investigate the issue more thoroughly.

    When you have a chance, please share the code and let me know if you have any additional questions.

    Thanks!

    Thread Starter Bartek20

    (@bartek20)

    Hi, @rsouzaam ,

    Here is my custom field – it is basic text field modified to fit my needs (that’s why for example LOCALE is hard coded).

    <?php

    namespace Customizations;

    class DateTimeField extends \WPForms_Field {

    /**
    * Primary class constructor.
    *
    * @since 1.0.0
    */
    public function init() {

    // Define field type information.
    $this->name = esc_html__( 'Date / Time', 'wpforms-lite' );
    $this->type = 'date-time';
    $this->icon = 'fa-calendar-o';
    $this->order = 60;
    $this->group = 'fancy';

    // Define additional field properties.
    add_filter( 'wpforms_field_properties_date-time', [ $this, 'field_properties' ], 5, 3 );
    add_action('init', [$this, 'register_frontend_js']);
    add_action( 'wpforms_frontend_js', [ $this, 'frontend_js' ] );
    }

    /**
    * Convert mask formatted for jquery.inputmask into the format used by amp-inputmask.
    *
    * Note that amp-inputmask does not yet support all of the options that jquery.inputmask provides.
    * In particular, amp-inputmask doesn't provides:
    * - Upper-alphabetical mask.
    * - Upper-alphanumeric mask.
    * - Advanced Input Masks with arbitrary repeating groups.
    *
    * @link https://amp.dev/documentation/components/amp-inputmask
    * @link https://wpforms.com/docs/how-to-use-custom-input-masks/
    *
    * @param string $mask Mask formatted for jquery.inputmask.
    * @return array {
    * Mask and placeholder.
    *
    * @type string $mask Mask for amp-inputmask.
    * @type string $placeholder Placeholder derived from mask if one is not supplied.
    * }
    */
    protected function convert_mask_to_amp_inputmask( $mask ) {
    $placeholder = '';

    // Convert jquery.inputmask format into amp-inputmask format.
    $amp_mask = '';
    $req_mask_mapping = [
    '9' => '0', // Numeric.
    'a' => 'L', // Alphabetical (a-z or A-Z).
    'A' => 'L', // Upper-alphabetical (A-Z). Note: AMP does not have an uppercase-alphabetical mask type, so same as previous.
    '*' => 'A', // Alphanumeric (0-9, a-z, A-Z).
    '&' => 'A', // Upper-alphanumeric (A-Z, 0-9). Note: AMP does not have an uppercase-alphanumeric mask type, so same as previous.
    ' ' => '_', // Automatically insert spaces.
    ];
    $opt_mask_mapping = [
    '9' => '9', // The user may optionally add a numeric character.
    'a' => 'l', // The user may optionally add an alphabetical character.
    'A' => 'l', // The user may optionally add an alphabetical character.
    '*' => 'a', // The user may optionally add an alphanumeric character.
    '&' => 'a', // The user may optionally add an alphanumeric character.
    ];
    $placeholder_mapping = [
    '9' => '0',
    'a' => 'a',
    'A' => 'a',
    '*' => '_',
    '&' => '_',
    ];
    $is_inside_optional = false;
    $last_mask_token = null;
    for ( $i = 0, $len = strlen( $mask ); $i < $len; $i++ ) {
    if ( '[' === $mask[ $i ] ) {
    $is_inside_optional = true;
    $placeholder .= $mask[ $i ];
    continue;
    } elseif ( ']' === $mask[ $i ] ) {
    $is_inside_optional = false;
    $placeholder .= $mask[ $i ];
    continue;
    } elseif ( isset( $last_mask_token ) && preg_match( '/^\{(?P<n>\d+)(?:,(?P<m>\d+))?\}/', substr( $mask, $i ), $matches ) ) {
    $amp_mask .= str_repeat( $req_mask_mapping[ $last_mask_token ], $matches['n'] );
    $placeholder .= str_repeat( $placeholder_mapping[ $last_mask_token ], $matches['n'] );
    if ( isset( $matches['m'] ) ) {
    $amp_mask .= str_repeat( $opt_mask_mapping[ $last_mask_token ], $matches['m'] );
    $placeholder .= str_repeat( $placeholder_mapping[ $last_mask_token ], $matches['m'] );
    }
    $i += strlen( $matches[0] ) - 1;

    $last_mask_token = null; // Reset.
    continue;
    }

    if ( '\\' === $mask[ $i ] ) {
    $amp_mask .= '\\';
    $i++;
    if ( ! isset( $mask[ $i ] ) ) {
    continue;
    }
    $amp_mask .= $mask[ $i ];
    } else {
    // Remember this token in case it is a mask.
    if ( isset( $opt_mask_mapping[ $mask[ $i ] ] ) ) {
    $last_mask_token = $mask[ $i ];
    }

    if ( $is_inside_optional && isset( $opt_mask_mapping[ $mask[ $i ] ] ) ) {
    $amp_mask .= $opt_mask_mapping[ $mask[ $i ] ];
    } elseif ( isset( $req_mask_mapping[ $mask[ $i ] ] ) ) {
    $amp_mask .= $req_mask_mapping[ $mask[ $i ] ];
    } else {
    $amp_mask .= '\\' . $mask[ $i ];
    }
    }

    if ( isset( $placeholder_mapping[ $mask[ $i ] ] ) ) {
    $placeholder .= $placeholder_mapping[ $mask[ $i ] ];
    } else {
    $placeholder .= $mask[ $i ];
    }
    }

    return [ $amp_mask, $placeholder ];
    }

    /**
    * Define additional field properties.
    *
    * @since 1.4.5
    *
    * @param array $properties Field properties.
    * @param array $field Field settings.
    * @param array $form_data Form data and settings.
    *
    * @return array
    */
    public function field_properties( $properties, $field, $form_data ) {
    // Add class that will trigger custom mask.
    $properties['inputs']['primary']['class'][] = 'wpforms-masked-input';
    $properties['inputs']['primary']['class'][] = 'wpforms-datepicker';

    if ( wpforms_is_amp() ) {
    return $this->get_amp_input_mask_properties( $properties, $field );
    }

    $properties['inputs']['primary']['data']['rule-inputmask-incomplete'] = true;

    $mask = $field['format'];
    $properties['inputs']['primary']['data']['inputmask-alias'] = 'datetime';
    $properties['inputs']['primary']['data']['inputmask-inputformat'] = $mask;

    /**
    * Some datetime formats include letters, so we need to switch inputmode to text.
    * For instance:
    * – tt is am/pm
    * – TT is AM/PM
    */
    $properties['inputs']['primary']['data']['inputmask-inputmode'] = preg_match( '/[tT]/', $mask ) ? 'text' : 'numeric';

    return $properties;
    }

    /**
    * Define additional field properties for the inputmask on AMP pages.
    *
    * @since 1.7.6
    *
    * @param array $properties Field properties.
    * @param array $field Field settings.
    *
    * @return array
    */
    private function get_amp_input_mask_properties( $properties, $field ) {

    list( $amp_mask, $placeholder ) = $this->convert_mask_to_amp_inputmask( 'date:' . $field['format'] );

    $properties['inputs']['primary']['attr']['mask'] = $amp_mask;

    if ( empty( $properties['inputs']['primary']['attr']['placeholder'] ) ) {
    $properties['inputs']['primary']['attr']['placeholder'] = $placeholder;
    }

    return $properties;
    }

    /**
    * Field options panel inside the builder.
    *
    * @since 1.0.0
    *
    * @param array $field Field settings.
    */
    public function field_options( $field ) {
    /*
    * Basic field options.
    */

    // Options open markup.
    $this->field_option(
    'basic-options',
    $field,
    [
    'markup' => 'open',
    ]
    );

    // Label.
    $this->field_option( 'label', $field );

    // Description.
    $this->field_option( 'description', $field );

    // Required toggle.
    $this->field_option( 'required', $field );

    // Options close markup.
    $this->field_option(
    'basic-options',
    $field,
    [
    'markup' => 'close',
    ]
    );

    /*
    * Advanced field options.
    */

    // Options open markup.
    $this->field_option(
    'advanced-options',
    $field,
    [
    'markup' => 'open',
    ]
    );

    // Size.
    $this->field_option( 'size', $field );

    // Date format
    $lbl = $this->field_element('label', $field, [
    'slug' => 'format',
    'value' => esc_html__( 'Date format', 'wpforms-lite' ),
    ], false);
    $fld = $this->field_element('select', $field, [
    'slug' => 'format',
    'value' => empty($field['format']) ? 'dd/mm/yyyy' : $field['format'],
    'options' => [
    'dd/mm/yyyy' => 'dd/mm/yyyy'
    ],
    ], false);
    $args = [
    'slug' => 'format',
    'content' => $lbl . $fld,
    ];
    $this->field_element('row', $field, $args, true);

    // Placeholder.
    $this->field_option( 'placeholder', $field );

    // Default value.
    $this->field_option( 'default_value', $field );

    // Custom CSS classes.
    $this->field_option( 'css', $field );

    // Hide label.
    $this->field_option( 'label_hide', $field );

    // Options close markup.
    $this->field_option(
    'advanced-options',
    $field,
    [
    'markup' => 'close',
    ]
    );
    }

    /**
    * Field preview inside the builder.
    *
    * @since 1.0.0
    *
    * @param array $field Field settings.
    */
    public function field_preview( $field ) {

    // Define data.
    $placeholder = ! empty( $field['placeholder'] ) ? $field['placeholder'] : '';
    $default_value = ! empty( $field['default_value'] ) ? $field['default_value'] : '';

    // Label.
    $this->field_preview_option( 'label', $field );

    // Primary input.
    echo '<input type="text" placeholder="' . esc_attr( $placeholder ) . '" value="' . esc_attr( $default_value ) . '" class="primary-input" readonly>';

    // Description.
    $this->field_preview_option( 'description', $field );
    }

    /**
    * Field display on the form front-end.
    *
    * @since 1.0.0
    *
    * @param array $field Field settings.
    * @param array $deprecated Deprecated.
    * @param array $form_data Form data and settings.
    */
    public function field_display( $field, $deprecated, $form_data ) {

    // Define data.
    $primary = $field['properties']['inputs']['primary'];

    // Primary field.
    printf(
    '<input type="text" %s %s>',
    wpforms_html_attributes( $primary['id'], $primary['class'], $primary['data'], $primary['attr'] ),
    $primary['required'] // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
    );
    }

    public function register_frontend_js() {
    wp_register_script('picker-popper', 'https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js', null, false, true);
    wp_register_script('picker-airdatepicker', 'https://cdn.jsdelivr.net/npm/[email protected]/air-datepicker.js', ['picker-popper'], false, true);
    wp_register_style('picker-airdatepicker-css', 'https://cdn.jsdelivr.net/npm/[email protected]/air-datepicker.min.css', null, false);
    }

    /**
    * Enqueue frontend datepicker js.
    *
    * @since 1.5.6
    *
    * @param array $forms Forms on the current page.
    */
    public function frontend_js( $forms ) {
    // Get fields.
    $fields = array_map(
    function( $form ) {
    return empty( $form['fields'] ) ? [] : $form['fields'];
    },
    (array) $forms
    );

    // Make fields flat.
    $fields = array_reduce(
    $fields,
    function( $accumulator, $current ) {
    return array_merge( $accumulator, $current );
    },
    []
    );

    // Leave only fields with limit.
    $fields = array_filter(
    $fields,
    function( $field ) {
    return $field['type'] === $this->type;
    }
    );

    if ( count( $fields ) ) {
    wp_enqueue_script('picker-popper');
    wp_enqueue_script('picker-airdatepicker');
    wp_enqueue_style('picker-airdatepicker-css');
    wp_add_inline_script('picker-airdatepicker', <<<JS
    const PICKER_LOCALE = {
    days: ['Niedziela', 'Poniedziałek', 'Wtorek', 'Środa', 'Czwartek', 'Piątek', 'Sobota'],
    daysShort: ['Nie', 'Pon', 'Wto', 'Śro', 'Czw', 'Pią', 'Sob'],
    daysMin: ['Nd', 'Pn', 'Wt', 'Śr', 'Czw', 'Pt', 'So'],
    months: ['Styczeń', 'Luty', 'Marzec', 'Kwiecień', 'Maj', 'Czerwiec', 'Lipiec', 'Sierpień', 'Wrzesień', 'Październik', 'Listopad', 'Grudzień'],
    monthsShort: ['Sty', 'Lut', 'Mar', 'Kwi', 'Maj', 'Cze', 'Lip', 'Sie', 'Wrz', 'Paź', 'Lis', 'Gru'],
    today: 'Dzisiaj',
    clear: 'Wyczyść',
    dateFormat: 'dd/MM/yyyy',
    timeFormat: '',
    firstDay: 1
    }

    document.querySelectorAll('.wpforms-datepicker').forEach(input => {
    new AirDatepicker(input, {
    container: input.parentElement,
    selectedDates: [],
    onSelect: () => {
    input.dispatchEvent(new Event('dateSelected'));
    },
    locale: PICKER_LOCALE,
    autoClose: true,
    toggleSelected: false,
    position({ \$datepicker, \$target, \$pointer, done }) {
    let popper = Popper.createPopper(\$target, \$datepicker, {
    placement: 'bottom',
    modifiers: [
    {
    name: 'flip',
    options: {
    padding: {
    top: 64
    }
    }
    },
    {
    name: 'offset',
    options: {
    offset: [0, 20]
    }
    },
    {
    name: 'arrow',
    options: {
    element: \$pointer
    }
    }
    ]
    });

    return function completeHide() {
    popper.destroy();
    done();
    };
    }
    });
    })
    JS);
    }
    }

    /**
    * Format and sanitize field.
    *
    * @since 1.5.6
    *
    * @param int $field_id Field ID.
    * @param mixed $field_submit Field value that was submitted.
    * @param array $form_data Form data and settings.
    */
    public function format( $field_id, $field_submit, $form_data ) {

    $field = $form_data['fields'][ $field_id ];
    $name = ! empty( $field['label'] ) ? sanitize_text_field( $field['label'] ) : '';

    // Sanitize.
    $value = sanitize_text_field( $field_submit );

    wpforms()->obj( 'process' )->fields[ $field_id ] = [
    'name' => $name,
    'value' => $value,
    'id' => wpforms_validate_field_id( $field_id ),
    'type' => $this->type,
    ];
    }

    }

    The bug is caused by the use of the “date-time” type in the init() function (date-time as an example). This is a pro field from WPForms, but according to the comment in the previously mentioned function, using an existing type should exclude the original field, as a field with that name already exists. However, this doesn’t happen, and duplication occurs.

    Plugin Support Ralden Souza

    (@rsouzaam)

    Hi @bartek20,

    Thanks for sharing your custom field code, and that was really helpful for our team to review!

    I’ve passed this along and the issue has been flagged. I apologize that I don’t have an ETA on when a fix will be released just yet.

    In the meantime, here’s a workaround: use a type slug that doesn’t match any existing WPForms Pro field in your init() function. Since the deduplication check compares against existing field types, using a unique slug will prevent it from triggering and avoid the duplicate button until the fix is in place.

    For example, instead of 'type' => 'date-time' (which matches an existing Pro field), use something like 'type' => 'custom-date-time' or any other slug that isn’t already used by WPForms.

    When you have a chance, please try that and let me know if you have any additional questions.

    Thanks!

    Plugin Support Ralden Souza

    (@rsouzaam)

    Hi @bartek20,

    Happy to confirm that the issue with the duplicate field button in the form builder has now been resolved.

    You can verify the fix by updating WPForms Lite to version 1.10.1.

    Thank you for your patience and for bringing this to our attention, especially for the thorough report with the exact file, function, and code comparison. We really appreciate your help in making WPForms better!

Viewing 4 replies - 1 through 4 (of 4 total)

You must be logged in to reply to this topic.