AdrianVillamayor / Wizard-JS

A lightweight wizard UI component that supports accessibility and HTML5 in JavaScript Vanilla.
https://adrianvillamayor.github.io/Wizard-JS/
MIT License
44 stars 13 forks source link

Add support for intermediate AJAX calls between steps #10

Open aleaforny opened 1 year ago

aleaforny commented 1 year ago

Is your feature request related to a problem? Please describe.

It would be fantastic if the library could support asynchronous calls before continuing to the next step. For instance, it could be useful to make an intermediate AJAX call before going further in the process of a registration (where this library is useful).

Describe the solution you'd like

Describe alternatives you've considered

@AdrianVillamayor in fact, I was able to make this behavior with small changes to your code, and a bit with my logic in my code. I'll comment all the changes I've made here, because I'm not sure it's good enough for a PR, I'm pretty sure you can come with something great 👍

  1. First and foremost, pretty much everything takes place in the onClick method, but I had to split this method into two distincts method, with this specific piece of code apart:
     const $this = e
        const wz = document.querySelector(this.wz_class);

        const parent = $_.getParent($this, this.wz_class);

        const nav = parent.querySelector(this.wz_nav);
        const content = parent.querySelector(this.wz_content);

        if ($_.str2bool(step)) {
            this.setCurrentStep(step)
        }

        if ($_.str2bool(this.buttons) !== false) {
            const buttons = parent.querySelector(this.wz_buttons);
            const next = buttons.querySelector(this.wz_button + this.wz_next);;
            const prev = buttons.querySelector(this.wz_button + this.wz_prev);
            const finish = buttons.querySelector(this.wz_button + this.wz_finish);

            this.checkButtons(next, prev, finish)
        }

        if ($_.str2bool(this.nav) !== false) {
            const $wz_nav = nav.querySelectorAll(this.wz_step);
            $_.removeClassList($wz_nav, "active");
            nav.querySelector(`${this.wz_step}[data-step="${this.getCurrentStep()}"]`).classList.add("active");
        }

        const $wz_content = content.querySelectorAll(this.wz_step);
        $_.removeClassList($wz_content, "active");
        content.querySelector(`${this.wz_step}[data-step="${this.getCurrentStep()}"]`).classList.add("active");

I've wrapped this piece of code in another method called proceedStep(e, step) (will use it later)


  1. Back on the onClick method, I've added the two following const at the beginning (with other consts):
        const stepEl = content.querySelector(`${this.wz_step}[data-step="${this.getCurrentStep()}"]`);
        const isAsync = stepEl.getAttribute("data-async-step");

This allows us to set a data attribute like data-async-step="true" on the .wizard-step element, in order to control which step will requires additional call before continuing


  1. Next, in the code that follows (it comes just after the const declarations), it was important to remove the event dispatcher. Indeed, the event should not be triggered from there (it's way too early, even way before the form is validated).
        if (is_btn) {
            if ($_.hasClass($this, this.wz_prev)) {
                step = step - 1;
                wz.dispatchEvent(new Event("wz.btn.prev"));    <========== REMOVE HERE
            } else if ($_.hasClass($this, this.wz_next)) {
                step = step + 1;
                wz.dispatchEvent(new Event("wz.btn.next")));    <========== REMOVE HERE
            }
        }

  1. Just after the form validation, I store inside another const a reference to the proceedStep method created previously
        const proceedFn = () => this.proceedStep(e, step);

  1. I've moved the event dispatchers (of both is_btn and is_nav checkers) just after this, because at this point, the form has been validated so we could consider that the user can safely moves forward.

         if (is_btn) {
            if ($_.hasClass($this, this.wz_prev)) {
                wz.dispatchEvent(new Event("wz.btn.prev"));
            } else if ($_.hasClass($this, this.wz_next)) {
                wz.dispatchEvent(new CustomEvent("wz.btn.next", {"detail": {"proceedFn": proceedFn, "step": step, "isAsync": isAsync}}));
            }
        }
    
         if (is_nav) {
            if (step_action) {
                wz.dispatchEvent(new CustomEvent("wz.nav.forward", {"detail": {"proceedFn": proceedFn, "step": step, "isAsync": isAsync}}));
            } else if (step < this.getCurrentStep()) {
                wz.dispatchEvent(new Event("wz.nav.backward"));
            }
        }
    
         if (!isAsync || this.getCurrentStep() > step) proceedFn();
        // END OF THE onClick METHOD

There, this is all where the magic happens: if the developer did NOT specify the async option on the data attribute (!isAsync), or if the user is not going to a previous step (this.getCurrentStep() > step), only in these conditions we can proceed to display the next step.

Otherwise, it will NOT proceed, and the developer would have to manually make the step progress when he caught the event on his code. This is doable because the proceed method's reference is passed as a parameter to the CustomEvent, as well as the step number and the isAsync information.


Now, what about a business case with an example of mine in my own code?

This is how I've made it work like a charm and got exactly what I've wanted (to have an intermediate call before proceeding):

  1. On the step I need to do an intermediate AJAX call, let's say step number 4, I've added the data attribute data-async-step="true" to the .wizard-step element.

  2. Then, I simply had to create the event listener as below. Piece of cake 🥇

    ...
    let wz_class = ".wizard";
    let $wz_doc = document.querySelector(wz_class);
    $wz_doc.addEventListener("wz.btn.next", async function(e) {
    const proceedFn = e.detail.proceedFn;
    const currentStep = e.detail.step;
    const isAsync = e.detail.isAsync;
    
    if (isAsync) { // <======== First, only do something if it's an async call
        displayLoading();   // <======== Here I've got a function to pop up a display, preventing the user to click anywhere
    
        switch(currentStep) {   // <====== This is how I can control what call to make depending on the step (if we have several async calls for different steps, for instance)
            case 4:  // <======== For me, I only need an async call for step number 4
                // Do my AJAX calls here... lot of awaits lol
    
                if (ajaxResponse.success) { 
                        // Yeee, I can continue and take the user to the next step
                        proceedFn();
                } else {
                       // Don't call the proceedFn, so that the user stays at this step (and is displayed with another function of mine)
                       displayError(ajaxResponse.error.message);  
                }
    
                stopLoading();  // <============ Don't forget to hide the loading to the user so he can clicks back (another fn)
                break;
        }
    }
    });
    ...

Obviously, feel free to implement this feature with a complete different logic, I just wanted to give a bit of help here ;)