Walidacja Formularzy

Przyszedł najwyższy czas na sprawdzenie, czy nie chcą nas oszukać :)
Sprawdzanie danych, które wprowadza użytkownik to jedna z ważniejszych rzeczy o jaką musimy zadbać przy tworzeniu strony. Sprawdzać je możemy na wiele, wiele sposobów. Poniżej przedstawię ci jeden z nich - czy najłatwiejszy? Napewno nie. Większość takich sprawdzeń moglibyśmy robić łapotologicznie. Poniżej zajmiemy się nieco bardziej zaawansowanym podejściem.

Weryfikacja danych powinna przebiegać w 3 etapach:

  1. Pierwszy etap to prosta podpowiedź w czasie wprowadzania danych przez użytkownika. Wykorzystamy tutaj zdarzenia change, select, focus, blur lub input...
  2. Drugi etap to sprawdzenie danych tuż przed wysłaniem. Jeżeli są poprawne to je wysyłamy na serwer, jeżeli nie, wyświetlamy stosowną informację (lub zaznaczamy pola) i przesyłania nie wykonujemy.
  3. Ostatni - trzeci etap to sprawdzenie przesłanych danych po stronie serwera. Jeżeli dane są błędne, wówczas wracamy do formularza wyświetlając informację i czekamy na wykonanie punktu drugiego.

Tak naprawdę ciężko mówić o pełnoprawnej walidacji danych po stronie przeglądarki. Jasne - możemy dane spradzać i pokazywać użytkownikowi podpowiedzi, że coś źle robi lub wpisuje. Ale sprawdzanie danych tylko po stronie przeglądarki jest naiwne. Użytkownikó mógł by spreparować swój formularz i wysłać na dany adres dane za pomocą jego kodu.
Prawdziwa walidacja danych powinna się odbywać po stronie serwera. Skrypty w przeglądarce traktuj tylko jako rzeczy służące poprawie użyteczności formularza i pomocy dla użytkownika. Nic więcej.

Html

Wykorzystajmy do naszych celów formularz, w którym umieścimy wszystkie zazwyczaj stosowane pola:


<form class="form" action="adres-do-wysylki" method="post">
    <div class="form-row">
        <label for="formName">Tekst</label>
        <input type="text" id="formName" placeholder="Może być puste" />
    </div>

    <div class="form-row">
        <label for="formDate">Data</label>
        <input type="text" id="formDate" pattern="^\d{2}\.\d{2}\.\d{4}$" required placeholder="dd.mm.rrrr" />
    </div>

    <div class="form-row">
        <label for="formUrl">URL</label>
        <input type="url" id="formUrl" required placeholder="http://" />
    </div>

    <div class="form-row">
        <label for="formEmail" >Email</label>
        <input type="email"  id="formEmail" required placeholder="Email" />
    </div>

    <div class="form-row">
        <label for="formMessage">Wpisz tylko abc</label>
        <textarea id="formMessage" required pattern="^abc$"></textarea>
    </div>

    <div class="form-row">
        <label for="formThing">Wybierz</label>
        <select id="formThing" required>
            <option value="">Wybierz coś</option>
            <option value="1">Super opcja 1</option>
            <option value="2">Super opcja 2</option>
        </select>
    </div>

    <fieldset class="form-row">
        <label>Zaznacz</label>
        <input type="radio" required name="test1" /> 1
        <input type="radio" required name="test1" /> 2
        <input type="radio" required name="test1" /> 3
        <input type="radio" required name="test1" /> 4
        <input type="radio" required name="test1" /> 5
    </fieldset>

    <fieldset class="form-row">
        <label>Zaznacz</label>
        <input type="checkbox" required name="test2" /> 1
        <input type="checkbox" required name="test2" /> 2
        <input type="checkbox" required name="test2" /> 3
        <input type="checkbox" required name="test2" /> 4
        <input type="checkbox" required name="test2" /> 5
    </fieldset>

    <div class="form-row">
        <input type="submit" value="Wyślij" />
    </div>
</form>

Nasz formularz dzięki odpowiednim atrybutom ma podstawową htmlową walidację. Taka walidacja to całkiem fajna sprawa. Dzięki niej nawet jak użytkownikowi padnie Javacript na naszej stronie (np. przez adBlocka, który może się pomylić i zablokować nasze skrypty), i tak będzie miał jakieś domyślne podpowiedzi/walidację. Jest ona ciut toporna, nie działa wybitnie, ale jest. Jak chcesz ją sprawdzić, wystarczy, że wkleisz sobie na stronę powyższy formularz i spróbujesz go wysłać.

Walidacja taka opiera o pewne mechanizmy. Pola wymagane do wypełnienia powinny mieć atrybut required. Jeżeli w dane pole powinna być wpisana specyficzna wartość, ustawimy to dodatkowym atrybutem pattern, do którego podajemy w postaci wyrażeń regularnych wzór tekstu który musi być podany. Niektóre pola takie jak url, email itp. maja już domyślne patterny i do nich atrybutu pattern nie musimy podawać (chyba że chcemy zmienić domyślne zachowanie tych pól).

Do walidacji możemy skorzystać z metody checkValidity. Podejście takie przedstawiłem w tym i tym artykule. W poniższym tekście użyjemy klasycznych mechanizmów - czyli walidacja typowo po stronie JS. Czy wykorzystasz bardziej klasyczne metody, czy nowe, poniżej nauczymy się kilku ciekawych technik.

Etap 1 - podpowiedzi dla użytkownika

Przechodzimy do pracy.
Sporo tutaj będzie się powtarzało z poprzednich działów. Zaczynamy od konstruktora, w którym tworzymy opcję dla naszego typu. Podobnie robiliśmy w przypadku slidera:


const ValidateForm = function(form, options) {
    //domyślne opcje naszej walidacji
    const defaultOptions = {
        classError : 'error'
    }

    //formularz właściwy podstawiamy pod this.form
    this.form = form;

    //tworzymy opcje dla naszego obiektu
    //scalając przekazane opcje z obiektem defaultOptions
    this.options = Object.assing({}, defaultOptions, options);

    //wyłączamy htmlową walidację
    this.form.setAttribute('novalidate', 'novalidate');
};

Przygotowanie pól do weryfikacji

Zacznijmy od pierwszego punktu naszego planu. Podczas wypełniania formularza, dynamicznie będziemy użytkownikowi pokazywać informacje, czy popełnia błędy. Aby to zrobić do każdego pola podepniemy odpowiednie zdarzenie:


FormValidate.prototype.prepareElements = function() {
    const elements = this.form.querySelectorAll('[required]');

    //pętla po elementach
    [].forEach.call(elements, function(element) {
        //sprawdzamy typ pola
        if (element.nodeName.toUpperCase() === 'INPUT') {
            const type = element.type.toUpperCase();

            //dla każdego pola dodajemy obsługę funkcji sprawdzającej
            if (type == 'TEXT') {
                element.addEventListener('input', function(e) {
                    this.testInputText(e.target);
                }.bind(this));
            }

            if (type == 'EMAIL') {
                element.addEventListener('input', function(e) {
                    this.testInputEmail(e.target);
                }.bind(this));
            }

            if (type == 'URL') {
                element.addEventListener('input', function(e) {
                    this.testInputURL(e.target);
                }.bind(this));
            }

            if (type == 'CHECKBOX') {
                element.addEventListener('click', function() {
                    this.testInputCheckbox(e.target);
                }.bind(this));
            }

            if (type == 'RADIO') {
                element.addEventListener('click', function() {
                    this.testInputCheckbox(e.target);
                }.bind(this));
            }
        }

        if (element.nodeName.toUpperCase() == 'TEXTAREA') {
            element.addEventListener('input', function(e) {
                this.testInputText(e.target);
            }.bind(this));
        }

        if (element.nodeName.toUpperCase() == 'SELECT') {
            element.addEventListener('change', function(e) {
                this.testInputSelect(e.target);
            }.bind(this));
        }
    }, this);
};

I odpalamy ją w konstruktorze:


const ValidateForm = function(form, options) {
    const defaultOptions = {
        classError : 'error'
    }

    this.form = form;

    //tworzymy opcje dla naszego obiektu
    //merdżując przekazane opcje z obiektem defaultOptions
    this.options = Object.assing({}, defaultOptions, options);

    //wyłączamy htmlową walidację
    this.form.setAttribute('novalidate', 'novalidate');

    this.prepareElements();
};

Funkcja prepareElement robi pętlę po wszystkich polach z atrybutem required. Dla każdego takiego pola dodaje odpowiednie zdarzenie z obsługą funkcji sprawdzającej. Za chwilę napiszemy kod każdej takiej funkcji.

Sprawdzanie pól tekstowych

W powyższym kodzie do sprawdzania pól tekstowych wykorzystujemy funkcję testInputText(element). Napiszmy jej kod:


FormValidate.prototype.testInputText = function(input) {
    let inputIsValid = true;
    const pattern = input.getAttribute('pattern');

    if (pattern !== null) {
        //tutaj moglibyśmy skorzystać z checkValidity()
        const reg = new RegExp(pattern, 'gi');

        if (!reg.test(input.value)) {
            inputIsValid = false;
        }
    } else {
        if (input.value === '') {
            inputIsValid = false;
        }
    }

    if (inputIsValid) {
        this.showFieldValidation(input, true);
        return true;
    } else {
        this.showFieldValidation(input, false);
        return false;
    }
};

Pola tekstowe - input:text oraz textarea mogą zawierać atrybut pattern, który definiuje wzór teksty który musi się w tym polu pojawić. Dla przykładu dla pola z datą będzie to pattern ^\d{2}\.\d{2}\.\d{4}$ (spójrz na kod naszego formularza). Jeżeli takiego atrybutu pole nie posiada, a jest wymagane (a jest, bo przecież robimy pętlę tylko po polach required), to znaczy, że musi się w nim znaleźć jakakolwiek wartość.
Jeżeli dane pole będzie błędnie wyświetlone, wtedy pokażemy błąd za pomocą metody showFieldValidation(input, czyPoleJestPoprawne). Ma ona postać:


FormValidate.prototype.showFieldValidation = function(input, inputIsValid) {
    if (!inputIsValid) {
        input.parentElement.classList.add(this.options.classError);
    } else {
        input.parentElement.classList.remove(this.options.classError);
    }
};

Sprawdzanie pól typu email

Sprawdzanie pól email robiliśmy już w poprzednim rozdziale i w rozdziale o wyrażeniach regularnych, nie będzie to więc nic nowego. Tutaj tak samo jak w poprzednim teście wyświetlamy błędy za pomocą funkcji showFieldValidation:


FormValidate.prototype.testInputEmail = function(input) {
    const mailReg = new RegExp('^[0-9a-zA-Z_.-]+@[0-9a-zA-Z.-]+\.[a-zA-Z]{2,3}$', 'gi');

    if (!mailReg.test(input.value)) {
        this.showFieldValidation(input, false);
        return false;
    } else {
        this.showFieldValidation(input, true);
        return true;
    }
};

Sprawdzanie pól typu url

Pola typu URL powinny zawierać http:// (lub lepiej https), sam test jest więc bardzo prosty:


FormValidate.prototype.testInputURL = function(input) {
    const urlReg = new RegExp('^https?:\/\/.+', 'i');
    if (!urlReg.test(input.value)) {
        this.showFieldValidation(input, false);
        return false;
    } else {
        this.showFieldValidation(input, true);
        return true;
    }
};

Sprawdzanie selektów

Dla selektów sprawdzamy aktualnie wybraną pozycję. To także już robiliśmy :):


FormValidate.prototype.testInputSelect = function(select) {
    if (select.options[select.selectedIndex].value === '' || select.options[select.selectedIndex].value === '-1') {
        this.showFieldValidation(select, false);
        return false;
    } else {
        this.showFieldValidation(select, true);
        return true;
    }
};

Sprawdzanie radio i checkboxów

Do pól radio i checkbox musimy podejść nieco inaczej. Jak wiemy występują one w grupach, więc musimy testować całe grupy tych pól:


FormValidate.prototype.testInputCheckbox = function(input) {
    const name = input.getAttribute('name');
    const group = input.form.querySelectorAll('input[name="'+name+'"]:checked');

    if (group.length) {
        this.showFieldValidation(input, true);
        return true;
    } else {
        this.showFieldValidation(input, false);
        return false;
    }
};

Koniec etapu pierwszego

W tym momencie nasz kod wygląda następująco:


const FormValidate = function(form, options) {
    const defaultOptions = {
        classError : 'error'
    }

    this.form = form;
    this.options = Object.assign({}, defaultOptions, options);

    //wyłączamy htmlową walidację
    this.form.setAttribute('novalidate', 'novalidate');

    this.prepareElements();
};

FormValidate.prototype.prepareElements = function() {
    const elements = this.form.querySelectorAll('[required]');

    [].forEach.call(elements, function(element) {
        //sprawdzamy typ pola
        if (element.nodeName.toUpperCase() === 'INPUT') {
            const type = element.type.toUpperCase();

            //dla każdego pola dodajemy obsługę funkcji sprawdzającej
            if (type === 'TEXT') {
                element.addEventListener('input', function(e) {
                    this.testInputText(e.target);
                }.bind(this));
            }
            if (type === 'EMAIL') {
                element.addEventListener('input', function(e) {
                    this.testInputEmail(e.target);
                }.bind(this));
            }
            if (type === 'URL') {
                element.addEventListener('input', function(e) {
                    this.testInputURL(e.target);
                }.bind(this));
            }
            if (type === 'CHECKBOX') {
                element.addEventListener('click', function() {
                    this.testInputCheckbox(e.target);
                }.bind(this));
            }
            if (type === 'RADIO') {
                element.addEventListener('click', function() {
                    this.testInputCheckbox(e.target);
                }.bind(this));
            }
        }
        if (element.nodeName.toUpperCase() === 'TEXTAREA') {
            element.addEventListener('input', function(e) {
                this.testInputText(e.target);
            }.bind(this));
        }
        if (element.nodeName.toUpperCase() === 'SELECT') {
            element.addEventListener('change', function(e) {
                this.testInputSelect(e.target);
            }.bind(this));
        }
    }, this);
};

FormValidate.prototype.showFieldValidation = function(input, inputIsValid) {
    if (!inputIsValid) {
        input.parentElement.classList.add(this.options.classError);
    } else {
        input.parentElement.classList.remove(this.options.classError);
    }
};

FormValidate.prototype.testInputText = function(input) {
    let inputIsValid = true;
    const pattern = input.getAttribute('pattern');

    if (pattern !== null) {
        const reg = new RegExp(pattern, 'gi');
        if (!reg.test(input.value)) {
            inputIsValid = false;
        }
    } else {
        if (input.value === '') {
            inputIsValid = false;
        }
    }

    if (inputIsValid) {
        this.showFieldValidation(input, true);
        return true;
    } else {
        this.showFieldValidation(input, false);
        return false;
    }
};

FormValidate.prototype.testInputEmail = function(input) {
    const mailReg = new RegExp('^[0-9a-zA-Z_.-]+@[0-9a-zA-Z.-]+\.[a-zA-Z]{2,3}$', 'gi');

    if (!mailReg.test(input.value)) {
        this.showFieldValidation(input, false);
        return false;
    } else {
        this.showFieldValidation(input, true);
        return true;
    }
};

FormValidate.prototype.testInputURL = function(input) {
    const urlReg = new RegExp('^https?:\/\/.+', 'i');

    if (!urlReg.test(input.value)) {
        this.showFieldValidation(input, false);
        return false;
    } else {
        this.showFieldValidation(input, true);
        return true;
    }
};

FormValidate.prototype.testInputSelect = function(select) {
    if (select.options[select.selectedIndex].value === '' || select.options[select.selectedIndex].value === '-1') {
        this.showFieldValidation(select, false);
        return false;
    } else {
        this.showFieldValidation(select, true);
        return true;
    }
};

FormValidate.prototype.testInputCheckbox = function(input) {
    const name = input.getAttribute('name');
    const group = input.form.querySelectorAll('input[name="'+name+'"]:checked');

    if (group.length) {
        this.showFieldValidation(input, true);
        return true;
    } else {
        this.showFieldValidation(input, false);
        return false;
    }
};

Etap pierwszy mamy zakończony. W tym momencie wystarczy odpalić nasz moduł, by uzyskać dynamiczne sprawdzanie pól w formularzu:


document.addEventListener("DOMContentLoaded", function() {
    const cfg = {};
    const form = new FormValidate(document.querySelector('.form'), cfg);
});

Etap 2 - sprawdzanie danych przy wysyłaniu formularza

Pozostało nam do wykonania sprawdzenie danych przy wysyłce formularza. Jeżeli dane są błędne, wtedy powinniśmy pokazać odpowiednie błędy w formularzu, oraz przerwać jego wysyłanie. Jest to proste zadanie, ponieważ wszystkie niezbędne metody mamy już napisane. Wystarczy dodać obsługę zdarzenia submit formularza:


FormValidate.prototype.bindSubmit = function() {
    this.form.addEventListener('submit', function(e) {
        e.preventDefault();

        let formIsValidated = true;
        const elements = this.form.querySelectorAll('[required]');

        [].forEach.call(elements, function(element) {
            if (element.nodeName.toUpperCase() === 'INPUT') {
                const type = element.type.toUpperCase();

                if (type === 'EMAIL') {
                    if (!this.testInputEmail(element)) {
                        formIsValidated = false;
                    }
                }
                if (type === 'URL') {
                    if (!this.testInputURL(element)) {
                        formIsValidated = false;
                    }
                }
                if (type === 'TEXT') {
                    if (!this.testInputText(element)) {
                        formIsValidated = false;
                    }
                }
                if (type === 'CHECKBOX') {
                    if (!this.testInputCheckbox(element)) {
                        formIsValidated = false;
                    }
                }
                if (type === 'RADIO') {
                    if (!this.testInputCheckbox(element)) {
                        formIsValidated = false;
                    }
                }
            }

            if (element.nodeName.toUpperCase() === 'TEXTAREA') {
                if (!this.testInputText(element)) {
                    formIsValidated = false;
                }
            }
            if (element.nodeName.toUpperCase() === 'SELECT') {
                if (!this.testInputSelect(element)) {
                    formIsValidated = false;
                }
            }
        }, this);

        if (formIsValidated) {
            e.target.submit();
        } else {
            return false;
        }
    }.bind(this));
};

I tak jak poprzednio odpalenie tej metody w konstruktorze:


const FormValidate = function(form, options) {
    const defaultOptions = {
        classError : 'error'
    }

    this.form = form;
    this.options = Object.assign({}, defaultOptions, options);

    //wyłączamy htmlową walidację
    this.form.setAttribute('novalidate', 'novalidate');

    this.prepareElements();
    this.bindSubmit();
};

Działanie jest tutaj identyczne co w funkcji prepareElements. Robimy pętlę po elementach z klasą .required (które zamienialiśmy w tamtej funkcji) i wykorzystując wcześniej przygotowane funkcje dokonujemy sprawdzenia, czy formularz możemy być wysłany.

Poniżej przykładowy formularz z poniższym bardzo prostym stylowaniem (+ jakieś małe, które używam w tym kursie):


.form .error {
    color:#DB083E;
}
.form .error input[type=text],
.form .error input[type="url"],
.form .error input[type="email"],
.form .error textarea,
.form .error select {
    color: #DB083E;
    border-color: #DB083E;
    background: #FFEDED url(field-error.png) no-repeat;
    background-position: calc(100% - 10px) center;
    padding-right:40px;
}
.form .error textarea {
    background-position: calc(100% - 10px) 10px;
}
.form .error input[type="radio"],
.form .error input[type="checkbox"] {
    border-color: #DB083E;
    background: #FFEDED;
}
.form .error select {
    background-image: none;
    padding-right:4px;
}

Formularz testowy

1 2 3 4 5
1 2 3 4 5

Trening czyni mistrza

Poniżej zamieszczam kilka zadań, które w ramach ćwiczenia możesz wykonać:

  1. Posiłkując się artykułem wspomnianym na początku, dodaj do powyższego modułu walidację początkową, która będzie widoczna od razu po wejściu na stronę. Walidację tą pokazuj w zależności od dodatkowego parametru, który będziesz przekazywał w _options (ok, ok - zadanie to jest wykonane w tamtym artykule)