DEV Community

Recca Tsai
Recca Tsai

Posted on • Originally published at recca0120.github.io

Alpine.js Plugin for Laravel AJAX Validation Errors

Originally published at recca0120.github.io

When Laravel validation fails on a regular form submission, you can use the @error directive in Blade to display errors. But for AJAX requests, Laravel returns errors in JSON format, and you need to handle the frontend display yourself.

Blade's @error Only Works for Synchronous Forms

The typical approach:

<label for="email">Email</label>

<input id="title"
    type="email"
    name="email"
    class="@error('email') is-invalid @enderror">

@error('email')
    <div class="alert alert-danger">{{ $message }}</div>
@enderror
Enter fullscreen mode Exit fullscreen mode

But when the request is AJAX, Laravel returns JSON:

{
  "errors": {
      "email": ["The email field must be a valid email address."],
      "name": ["The name field is required."]
  },
  "message": "The name field is required. (and 1 more error)"
}
Enter fullscreen mode Exit fullscreen mode

To avoid having to learn a separate frontend error handling approach, I wrote an Alpine.js plugin that provides an API similar to Laravel's MessageBag.

Alpine.js errors plugin

// errors.js
class MessageBag {
    constructor(errors = {}) {
        this.errors = errors;
    }

    set(key, value) {
        return this.put(key, value);
    }

    put(key, value) {
        const values = typeof key === 'object' ? key : {[key]: value};

        for (const x in values) {
            let val = values[x];

            this.errors[x] = typeof val === 'string' ? [val] : val;
        }

        return this;
    }

    get(key) {
        if (!this.errors.hasOwnProperty(key) || !this.errors[key]) {
            this.put(key, null);
        }

        return this.errors[key];
    }

    has(key) {
        return this.get(key) !== null;
    }

    first(key) {
        if (!this.has(key)) {
            return null;
        }

        const value = this.get(key);

        return value instanceof Array ? value[0] : value;
    }

    remove(...keys) {
        keys.forEach(key => this.put(key, null));
    }

    clear() {
        this.remove(...Object.keys(this.errors));
    }

    all() {
        return Object.keys(this.errors).reduce((acc, key) => {
            const value = this.get(key);

            return value === null ? acc : {...acc, [key]: value};
        }, {});
    }

    registerAxiosInterceptor(axios) {
        const beforeRequest = (config) => {
            this.clear();

            return config;
        };
        const onError = (err) => {
            const {status, data} = err.response;

            if (status === 422) {
                this.set(data.errors);
            }

            return Promise.reject(err);
        };

        axios.interceptors.request.use(beforeRequest, err => Promise.reject(err));
        axios.interceptors.response.use(response => response, onError);
    }
}

export default function (Alpine) {
    const errors = Alpine.reactive(new MessageBag({}));
    Alpine.magic('errors', () => errors);
    Object.defineProperty(Alpine, 'errors', {get: () => errors});
}
Enter fullscreen mode Exit fullscreen mode

By using registerAxiosInterceptor to hook into Axios interceptors, errors are automatically populated into the MessageBag on a 422 response and automatically cleared when a new request is sent.

Initialization

// bootstrap.js
import Alpine from 'alpinejs';
import Axios from 'axios';
import errors from './errors';

errors(Alpine);
Alpine.start();

window.axios = Axios.create();
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
Alpine.errors.registerAxiosInterceptor(window.axios);
Enter fullscreen mode Exit fullscreen mode

Usage in Blade

Inside an Alpine component, you can use the $errors magic property directly, similar to Laravel's Blade @error:

<div x-data>
    <input type="text" name="email"
           :class="{'text-red-900': $errors.has('email')}"
           @keyup="$errors.remove('email')">

    <template x-if="$errors.has('email')">
        <p x-text="$errors.first('email')" class="text-red-600"></p>
    </template>
</div>
Enter fullscreen mode Exit fullscreen mode

Testing

// errors.test.js
import { fireEvent, screen } from '@testing-library/dom';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import Alpine from 'alpinejs';
import plugin from './errors';

describe('Alpine $errors', () => {
    const givenComponent = (name) => {
        const component = document.createElement('div');
        component.innerHTML = `
            <div x-data>
                <div>
                    <label for="${name}">${name}</label>
                    <div>
                        <input type="text" name="${name}" id="${name}"
                               :class="{'text-red-900': $errors.has('${name}')}"
                               @keyup="$errors.remove('${name}')"
                               role="input">
                    </div>

                    <template x-if="$errors.has('${name}')">
                        <p x-text="$errors.first('${name}')" role="error-message"></p>
                    </template>
                </div>
            </div>
        `;
        document.body.append(component);

        return component;
    };

    beforeAll(() => {
        plugin(Alpine);

        const mock = new MockAdapter(axios);
        mock.onPost('/users').reply(422, {
            'message': 'The email field must be a valid email address.',
            'errors': {'email': ['The email field must be a valid email address.']},
        });
        Alpine.$errors.registerAxiosInterceptor(axios);

        Alpine.start();
    });

    afterAll(() => Alpine.stopObservingMutations());

    beforeEach(() => {
        document.body.innerHTML = '';
        givenComponent('email');
        givenComponent('password');
    });

    afterEach(() => document.body.innerHTML = '');

    const getInvalidInputs = () => screen.queryAllByRole('input').filter(el => el.classList.contains('text-red-900'));
    const getErrorMessages = () => screen.queryAllByRole('error-message');

    async function expectShowError() {
        try {
            await axios.post('/users');
        } catch (e) {
        }

        expect(getInvalidInputs()).toHaveLength(1);
        expect(getErrorMessages()).toHaveLength(1);
        expect(getErrorMessages()[0].innerHTML).toContain('The email field must be a valid email address.');
    }

    it('show errors', async () => {
        await expectShowError();
    });

    it('clear errors', async () => {
        await expectShowError();

        await Alpine.nextTick(() => Alpine.$errors.clear());

        expect(getInvalidInputs()).toHaveLength(0);
        expect(getErrorMessages()).toHaveLength(0);
    });

    it('key up clear input error', async () => {
        await expectShowError();

        const invalidInput = getInvalidInputs()[0];
        await Alpine.nextTick(() => fireEvent.keyUp(invalidInput));

        expect(invalidInput.classList).not.toContain('text-red-900');
    });
});
Enter fullscreen mode Exit fullscreen mode

Top comments (0)