juergenweb / FrontendForms

A module for ProcessWire CMS to create and validate forms on the frontend easily using the Valitron library.
MIT License
15 stars 1 forks source link

Request: Allow AJAX on multiple forms on the same page #11

Closed donatasben closed 2 months ago

donatasben commented 2 months ago

Hi, I have a need to load the same form several times on the same page. I want to use AJAX to submit each form separately.

Examples:

It would be great if FrontendForms AJAX functionality could account for this complex scenario.

For now I wrote my own custom hook and my own JS ajax function to handle this, but if it could happen out-of-the-box - it would be very useful.

More context for future ideas: I am even tracking which iteration of the form - top, middle, bottom - is being submitted. I track clicks with custom Facebook Pixel or Google Analytics events. So the use case is quite complex sometimes :)

juergenweb commented 2 months ago

Hello @donatasben

You are very busy at the moment ;-)

Please keep in mind: If you are loading the same form multiple times on a page, it could be a problem, because every form should have its own id. FrontenForms checks which form is submitted via the id, so if you have several forms with the same id... you will run into problems (beside it is not valid HTML). If one form will be removed before another will be opened, everthing should be fine.

You can run multiple forms on one page, but everyone must have a unique id.

Is your site live, so I can take a look at the source code?

donatasben commented 2 months ago

Yes, I figured out about form IDs. I have implemented a custom functionality so I can insert forms in text fields with some {{custom_placeholder}} which is replaced with forms on page render. It tracks iteration count of the inserted form and provides a unique ID for each form (just appends a number to existing). It works quite nicely.

However the built-in frontendforms.js AJAX solution is not aware of multiple forms on my testing and fails if there are several. Thus this request :)

juergenweb commented 2 months ago

Ok, I will try it today with several forms on one page and all off them should be subitted via AJAX. To be honest, I have tested it with several forms without AJAX and this had worked without problems, but I have not tested such a usecase. I am excited what will happen :-)

juergenweb commented 2 months ago

I have tested it with 4 forms one one page and every form has Ajax submission enabled. All of them work in my case as expected. So if there are no errors, the form will be submitted as ususal, if there are errors the appropriate error message will be thrown under the given input field in the given form.

I guess it must be something to do with the custom functionality you have implemented.

For now I wrote my own custom hook and my own JS ajax function to handle this, but if it could happen out-of-the-box - it would be very useful.

Could you post what you have written to achive your goal. If it works without side effects, maybe it could be integrated inside FF.

donatasben commented 2 months ago

Well, it's a bit involved and probably not the most elegant solution but here it is:

Hope it will provide some ideas for possible improvements (my code is not the best though... 😇)

<?php namespace ProcessWire;

/**
 * Simplified example of embedding same form several times on a page.
 * Custom AJAX function to post inline.
 * Uses placeholders {{formContact}} and {{formInterests}} in the body field to replace with iterated forms.
*/

function formContact(int $iteration = 1){
    $formName = 'formContact__'.$iteration;
    $form = new \FrontendForms\Form($formName);
    $form->setMinTime(1);
    $form->setMaxTime(0);
    $form->setDescPosition("afterLabel");
    $form->setAttribute('class', 'form');
    $form->setAttribute('class', 'form-wrapper');
    $form->setAttribute('onsubmit', 'return doFormSubmit(event, this)'); // Enables custom AJAX submit
    $form->useAjax(false);
    $form->showProgressbar(false);

    $email = new \FrontendForms\InputEmail('email');
    $email->setLabel('Email address');
    if(user()->isLoggedIn())
        $email->setAttribute('value', user()->email);
    $email->setSanitizer('email');
    $email->setRule('required')->setCustomFieldName('The Email address');
    $form->add($email);

    $accept = new \FrontendForms\InputCheckbox('accept');
    $accept->setLabel('I accept the data privacy');
    $accept->setRule('required')->setCustomMessage('You have to accept the data privacy');
    $form->add($accept);

    $button = new \FrontendForms\Button('submit');
    $button->setAttribute('value', 'Send');
    $button->setAttribute('class', 'button-primary');
    $form->add($button);

    if ($form->isValid()) {
        wire()->log->save('frontenform-test', 'Test contact form filled successfully');
    }

    $content  = '<div class="form-ajax-wrapper">';
    $content .= $form->render();
    $content .= '</div>';
    return $content;
}

function formInterests(int $iteration = 1){
    $formName = 'formInterests__'.$iteration;
    $form = new \FrontendForms\Form($formName);
    $form->setMinTime(1);
    $form->setMaxTime(0);
    $form->setDescPosition("afterLabel");
    $form->setAttribute('class', 'form');
    $form->setAttribute('class', 'form-wrapper');
    $form->setAttribute('onsubmit', 'return doFormSubmit(event, this)'); // Enables custom AJAX submit
    $form->useAjax(false);
    $form->showProgressbar(false);

    $php = new \FrontendForms\Select('php');
    $php->setLabel('My preferred PHP version is');
    $php->setDefaultValue('PHP 8');
    $php->addOption('PHP 6', 'PHP 6');
    $php->addOption('PHP 7', 'PHP 7');
    $php->addOption('PHP 8', 'PHP 8');
    $php->setRule('required');
    $form->add($php);

    $button = new \FrontendForms\Button('submit');
    $button->setAttribute('value', 'Send');
    $button->setAttribute('class', 'button-primary');
    $form->add($button);

    if ($form->isValid()) {
        wire()->log->save('frontenform-test', 'Test interests form filled successfully');
    }

    $content  = '<div class="form-ajax-wrapper">';
    $content .= $form->render();
    $content .= '</div>';
    return $content;
}

/** =======================================
 * 
 * Handle AJAX form post
 * This should be inside init.php or hooked with 
 * `addHookAfter('Page::render'...`
 * 
 * ======================================= */
// If we detect a specific post value that we inject with JS into AJAX post values
if (input()->post->submit_ajax && input()->post->submit_ajax!='') {
    list($formName, $formCount) = explode('__', input()->post->submit_ajax);
    $formCount = $formCount ?? 1;

    // Render the appropriate form with correct iteration number
    $form = 'No such form found';
    switch ($formName) {
        case 'formContact':
            $form = formContact($formCount);
            break;

        case 'formInterests':
            $form = formInterests($formCount);
            break;
    }

    // Return the form and exit
    echo $form;
    exit();
}

/** =======================================
 * 
 * Search & Replace placeholders with forms 
 * within page body. This better search within 
 * full HTML of body, maybe hooked with 
 * `addHookAfter('Page::render'...`
 * 
 * ======================================= */
$body = page()->body;

// Save found placeholders temporarily to iterate their number
$foundForms = page()->data('found-forms', []);

// Find forms with or without surrounding <p> inside body
$findFormsRegex = '/(<p>\s*)?{{([a-zA-Z0-9_-]+)}}(\s*<\/p>)?/';

// Do the search and Replace
$body = preg_replace_callback(
    $findFormsRegex,
    function($matches){
        // This is where the magic happens
        $formHtml = $matches[0];
        $formFullName = $matches[2];

        $output = '';

        // Figure out form name and iteration, if exists
        $formNameParts = explode('__', $formFullName);
        $formName = $formNameParts[0];
        $formCount = $formNameParts[1] ?? 1;

        // Replace with nothing if placeholders are unknown
        if ($formName!='formContact' && $formName!='formInterests') return '';

        // Get form number on page
        $formCount = $formCount ?? 1;

        // Get array of existing form names from temp page variable
        $foundForms = page()->data('found-forms');

        // Increment form number if form name was already found before and exists in the temp found forms array
        while (in_array($formName . '__' . $formCount, $foundForms)) $formCount++;

        // Generate form name with new incremented number
        $formNameCount = $formName . '__' . $formCount;

        // Include new form name in the list of existing forms
        $foundForms[] = $formNameCount;

        // Push to the global page variable
        page()->data('found-forms', $foundForms);

        // Generate a form HTML with numbered name
        switch ($formName) {
            case 'formContact':
                $output = formContact($formCount);
                break;

            case 'formInterests':
                $output = formInterests($formCount);
                break;
        }

        return $output;
    },
    $body
);
?>

<section>
    <script>
        // Function to hadle form submit via AJAX
        function doFormSubmit(event, doform){
            event.preventDefault();

            const button = doform.querySelector('button[type="submit"]');
            if (button.classList.contains('loading')) { return false; } // Prevent double submission
            if (button.disabled == true) { return false; } // Prevent double submission

            const data = new FormData(doform);
            data.append('submit_ajax', doform.id);

            // Disabling form
            button.classList.add('loading');
            button.disabled = true;
            button.textContent = button.dataset.loading; // Switch button label to loading string

            // Send the form data and display the result
            fetch(doform.action, {
                method: "POST",
                body: data
            })
                .then(res => {
                    if (res.status != 200) { throw new Error("Bad Server Response"); }
                    return res.text();
                })
                .then(res => doform.closest('.form-ajax-wrapper').innerHTML = res)
                .catch(err => console.error(err)) 

            return false
        };
    </script>
    <div class="section-content">
        <h1><?=page()->title?></h1>
        <?=$body?>
    </div>
</section>

Page content for this looks like: SCR-20240711-bcle

Output: SCR-20240711-bdbj

juergenweb commented 2 months ago

Wow! I am impressed! Thanks for sharing this and the whole description.

I hope I am able to understand all what is going on inside your code ;-) and I will try to reproduce it on my local installation. You have written, that everything works with your workaround, so I guess you can continue with your work without problems?

I am currently working on inputfield dependencies for FrontendForms (you probably know this from the Processwire backend -> show field 2 only if field 1 has a certain value,...) It is all about showing/hiding/enabling/disabling certain form fields due to 1 or more conditions that have been set. Therefore I have not so much time left to dig deep into your problem immediately, but I will keep this in mind and try to check it out as soon as possible ;-).

donatasben commented 2 months ago

Sure, no problem. My solution is working fine for me for now, but I wish I wouldn't have had to develop it on my own 😆 Other users would benefit from such functionality. If you need any help looking into my example - hit me up.

Inputfield dependencies sound like a very nice addition! Looking forward to it! Already know I might need it soon enough in a project (was thinking doing it myself too)! 😆

Thank you for your work on this module!

juergenweb commented 2 months ago

Only if you are interested in: I have implemented this script (mf-conditional-fields) inside FrontendForms and it seems to work very well, but I am testing at the moment, so I will try to add it to the next update, if everything works fine.

The script will not cover all scenarios, but that is the same with the ProcessWire built-in inputfield dependencies. No script can do it all ;-), but it will help to solve standard issues.

I am afraid, that you will have to write your own dependency script if you want to do some very special logic ;-)

juergenweb commented 2 months ago

Hello @donatasben

I have added your code now on a page on my local installation. For the test scenario I have added 3 forms of the type "contactForm" to the body field on the page. All 3 forms will be rendered as expected on the frontend. To test if Ajax works in this case I have removed your custom JS code and what should I say: Everything works as expected with the default Ajax function from FrontendForms.

ajax-submission

As you can see the getValues() method returns the email address that I have entered inside the email field (test@gmx.at), the console.log which runs inside my Ajax function returns the form data and the entries for the successful submission will be written into the log files.

From my point of view, I cannot confirm, that the default Ajax function does not work. Maybe there is another Javascript running on your site that blocks the Ajax request???

For now I will try to find a way to integrate the functionality of adding forms to the body :-)

juergenweb commented 2 months ago

Hello @donatasben

I have added the functionality to replace placeholders with forms now to the module.

To achive this, I have re-written your code with a Hook function that will be triggered after Page::render and it works as expected in my case (with or without Ajax).

I have added this function today (14.7. at 15:45 and updated it at 21:00) to the the FrontendForms.module file on Github. So if you are downloading this file later on, you have the new Hook function included - if you have updated the module earlier, than you only have to replace your FrontendForms.module file with the one on Github.

You can also take a look at the code: The Hook function is called "replaceFormPlaceholders". If it is in your code, you are ready to go ;-)

Short explanation: The Hook function checks for placeholders inside all CKEditor fields on the given template on the page. This means that you can add your form not only to the "body", but also to all other CKEditor fields, that are present. I have added the forms on my test page to the fields "body" and "sidebar" (take a look at the picture afterwards).

Screenshot 2024-07-14 at 13-07-55 Home

You can see 3 forms on the left side which have been added to the body field and 1 form on the right side which has been added to the sidebar field (the form in the sidebar field is not really good visible, because the Tracy console hides it a little bit, but you can see the send button).

Inside the editors I have added the placeholders as usual:

Screenshot 2024-07-14 at 15-52-39 Edit Page Home • webseite1 at

This is the code that I have added to the template file. You can see that only the form functions are there, all the other code is no longer necessary (no Javascript, no replace function) :-). So it is very clean.

<?php namespace ProcessWire;

/**
 * Simplified example of embedding same form several times on a page.
 * Uses placeholders {{formContact}} and {{formInterests}} in a CKEditor field to replace with iterated forms.
 * Works on all CKEditor fields inside the template
*/

function formContact(string $id = 'formContact'){

            $form = new \FrontendForms\Form($id);
            $form->setMinTime(1);
            $form->setMaxTime(0);
            $form->setDescPosition("afterLabel");
            $form->setAttribute('class', 'form');
            $form->setAttribute('class', 'form-wrapper');
            $form->setAttribute('onsubmit', 'return doFormSubmit(event, this)'); // Enables custom AJAX submit
            $form->useAjax(true);
            $form->showProgressbar(false);

            $email = new \FrontendForms\InputEmail('email');
            $email->setLabel('Email address');
            if(user()->isLoggedIn())
                $email->setAttribute('value', user()->email);
            $email->setSanitizer('email');
            $email->setRule('required')->setCustomFieldName('The Email address');
            $form->add($email);

            $accept = new \FrontendForms\InputCheckbox('accept');
            $accept->setLabel('I accept the data privacy');
            $accept->setRule('required')->setCustomMessage('You have to accept the data privacy');
            $form->add($accept);

            $button = new \FrontendForms\Button('submit');
            $button->setAttribute('value', 'Send');
            $button->setAttribute('class', 'button-primary');
            $form->add($button);

            if ($form->isValid()) {
                wire()->log->save('frontenform-test', 'Test contact form filled successfully');
            }

            $content  = '<div class="form-ajax-wrapper">';
            $content .= $form->render();
            $content .= '</div>';
            return $content;
        }

        function formInterests(string $id = 'formInterests'){

            $form = new \FrontendForms\Form($formName);
            $form->setMinTime(1);
            $form->setMaxTime(0);
            $form->setDescPosition("afterLabel");
            $form->setAttribute('class', 'form');
            $form->setAttribute('class', 'form-wrapper');
            $form->setAttribute('onsubmit', 'return doFormSubmit(event, this)'); // Enables custom AJAX submit
            $form->useAjax(true);
            $form->showProgressbar(false);

            $php = new \FrontendForms\Select('php');
            $php->setLabel('My preferred PHP version is');
            $php->setDefaultValue('PHP 8');
            $php->addOption('PHP 6', 'PHP 6');
            $php->addOption('PHP 7', 'PHP 7');
            $php->addOption('PHP 8', 'PHP 8');
            $php->setRule('required');
            $form->add($php);

            $button = new \FrontendForms\Button('submit');
            $button->setAttribute('value', 'Send');
            $button->setAttribute('class', 'button-primary');
            $form->add($button);

            if ($form->isValid()) {
                wire()->log->save('frontenform-test', 'Test interests form filled successfully');
            }

            $content  = '<div class="form-ajax-wrapper">';
            $content .= $form->render();
            $content .= '</div>';
            return $content;
        }

This is the whole code that you have to write inside your template! Just note: Instead of adding the iteration as the parameter to the function, I have replaced it with the id. In this case you have to write 1 line less

Old code version:

function formInterests(int $iteration = 1){
$formName = 'formInterests__'.$iteration;
$form = new \FrontendForms\Form($formName);

New code version:

function formInterests(string $id =  'formInterests'){
$form = new \FrontendForms\Form($id);

The new form id/name with the iteration number will be replaced inside the Hook function automatically. So it is very close to the original writing of code for a form. The only difference is that it has to be wrapped by the function and no echo, but a return at the end.

function formInterests(string $id =  'formInterests'){
......
......
}

I think this is very userfriendly and not complex :-)

Submission of the 2 type of forms will lead to the appropriate entries inside the log files:

Screenshot 2024-07-14 at 15-58-50 Logs • ProcessWire • webseite1 at

As you can see the contact form and the form for the interests are both present inside the log files.

I have tested both forms with and without Ajax and on every submission $form->getValues(); contains the POST values.

So from my side everything works fine, but it needs more testing. If everything is OK then I will bump up the version to the next higher number. So please test it and I hope it works on your site as well as on my site :-)

Best regards Jürgen

juergenweb commented 2 months ago

Integration into FrontendForms has been done in version 2.2.11. Version for the new feature is alpha, but it should work as expected. For this reason the issue will be closed now. If there are problems, please open a new issue.