Basti0208's avatar

Best way to hook up Livewire with JS Form Repeater

Hi, I am using a HTML template which includes a JS based form repeater with nice animations and that kind of stuff. However, I want to use that form repeater with Livewire. What would be the best way to link these two together?

The creation of the new fields should still happen on the JS side (because it's just a nicer UX with the animation), but the data handling itself (the field content) should be handled by Livewire.

If anyone has a great idea, I would appreciate a reply.

Best regards

0 likes
16 replies
Basti0208's avatar

I found a way to do what I described. Unfortunately, the code does not work 100%. The first "row" works as expected, but later added rows do not "connect" to livewire -> I am setting the wire:model attribute on the inputs but those are not working as expected, maybe someone knows how to fix that.

In my livewire component:

<x-form::form.elements.repeater id="contact-repeater" :required="true" group-name="contactPersons">
    <div class="form-group row mb-3">
        <div class="col-md-4">
            <x-form::inputs.input
                name="name"
                wire:model.defer="name"
                :required="true"
                placeholder="Name"
            />
        </div>
        <div class="col-md-4">
            <x-form::inputs.input
                name="name"
                wire:model="email"
                :required="true"
                type="email"
                placeholder="E-Mail"
            />
        </div>
        <div class="col-md-4 d-flex align-items-center">
            <a href="javascript:;" data-repeater-delete
                class="btn btn-sm btn-light-danger">
                <i class="ki-duotone ki-trash fs-5"><span class="path1"></span><span
                        class="path2"></span><span class="path3"></span><span class="path4"></span><span
                        class="path5"></span></i>
                {{ __('basic::general.delete') }}
            </a>
        </div>
    </div>
</x-form::form.elements.repeater>

The components view:

<div id="{{ $id }}" class="fv-row mb-10">
    <x-form::general.label :id="$id" :help-text="$helpText" :label="$label" :required="$required"/>
    <!--begin::Form group-->
    <div class="form-group" wire:ignore>
        <div data-repeater-list="{{ $groupName }}">
            <div data-repeater-item>
                {{ $slot }}
            </div>
        </div>
    </div>
    <!--end::Form group-->

    <!--begin::Form group-->
    <div class="form-group mt-5">
        <a href="javascript:;" data-repeater-create class="btn btn-light-primary">
            <i class="ki-duotone ki-plus fs-3"></i>
            {{ __('basic::general.add') }}
        </a>
    </div>
    <!--end::Form group-->
</div>

<script>
    function initRepeater() {
        let repeater = $('#{{ $id }}');

        if (repeater.data('repeater-initialized')) {
            return;
        }

        let setLivewireModel = function (element, name) {
            // Find the index of the current item
            let index = element.closest('[data-repeater-item]').index();

            // Set the name
            name = '{{ $groupName }}.' + index + '.' + name;

            // Find the wire:model attribute name
            // We can't just use wire:model because it might be wire:model.lazy or wire:model.defer
            let attributeName;
            let attributes = element[0].attributes;
            for (let i = 0; i < attributes.length; i++) {
                let currentAttrName = attributes[i].name;
                if (currentAttrName.startsWith('wire:model')) {
                    attributeName = currentAttrName;
                }
            }

            // Set the wire:model attribute name
            element.attr(attributeName, name);
        };

        $('#{{ $id }}').repeater({
            initEmpty: false,
            isFirstItemUndeletable: {{ $required ? 'true' : 'false' }},

            defaultValues: @json($defaultValues, JSON_FORCE_OBJECT),

            show: function () {
                $(this).slideDown();

                {{ $showScript ?? '' }}

                $(this).find('[livewire-input]').each(function () {
                    setLivewireModel($(this), $(this).attr('livewire-input'));
                });
            },

            hide: function (deleteElement) {
                $(this).slideUp(deleteElement);

                {{ $hideScript ?? '' }}
            }
        });

        repeater.find('[livewire-input]').each(function () {
            setLivewireModel($(this), $(this).attr('livewire-input'));
        });

        repeater.data('repeater-initialized', true);
    }

    document.addEventListener("DOMContentLoaded", () => {
        initRepeater();

        Livewire.hook('message.processed', () => {
            initRepeater();
        });
    });
</script>

The component class does not contain any specific code.

Maybe someone can help me. Just to sum up the problem:

  1. I can enter values in the first row (which exists on page load) -> data is added on livewire side
  2. I add a new row with the "add" button (uses jquery.repeater)
  3. Newly added inputs don't send data to livewire backend -> wire:model is correctly set.

Best regards

newbie360's avatar

@Basti0208 first, i'm not fully understanding the OP code <script> part and the JS Form Repeater

but now the problem is here ?

        <div class="col-md-4">
            <x-form::inputs.input
                name="name"
                wire:model.defer="name"
                :required="true"
                placeholder="Name"
            />
        </div>

livewire @entangle just like a bridge used between front-end and back-end, can it solve your problem ?

    <div x-data="{ name: @entangle('name').defer }">
        ...

        <div class="col-md-4">
            <x-form::inputs.input
                {{-- name="name" --}}
                {{-- wire:model.defer="name" --}}

                x-model="name"
                :required="true"
                placeholder="Name"
            />
        </div>

        ...
    </div>
Basti0208's avatar

@newbie360 What part do you not understand? Maybe I can explain it.

The problem is that livewire does not recognize the change of the input if it's added by the form repeater. The input gets transformed into something like this:

<input type="text" name="contactPersons[0][name]" wire:model="contactPersons.0.name" />

The added inputs get the same name but the index is increased.

I will try it with AlpineJS and entangle. Thank you for the idea, I will give some feedback once I've tested it.

newbie360's avatar

@Basti0208 be careful read here

<x-form::inputs.input

	{{-- don't use both here, use the x-model only, let the Alpine JS handle the DOM --}}
    wire:model.defer="name"
    x-model="name"


    :required="true"
    placeholder="Name"
/>
Basti0208's avatar

@newbie360 What would be the best way to bind the AlpineJS property to the desired array index? My JS snippet converts the wire:model="name" into wire:model="contactPersons.0.name". How would I do that in your example?

newbie360's avatar

@Basti0208 hmmm may be given a clean example, after you playing with this, you will understand how to work

Livewire component class

    public $tests = [
        'a',
        'b',
    ];

Livewire component blade code

<div>

    @json($tests)

    <div x-data="{ tests: @entangle('tests') }" class="flex flex-col space-y-4">

        <template x-for="(test, index) in tests">
            <input x-model.debounce.1000ms="tests[index]" type="text">
        </template>

        <span x-text="tests"></span>
    </div>

</div>

just copy above code for test

in your case, you should remove .debounce.1000ms

and keep to use @entangle('tests').defer

because i try to let you real time can see the effect on the browser.

keep an eyes on the browser network XHR tag

Basti0208's avatar

@newbie360 Hi, so I've set up the environment with AlpineJS. I am facing the issue that an error occurs once the new row is being added: Alpine Expression Error: fields[1] is undefined.

That is the code in the component:

<div id="{{ $id }}"
     class="fv-row mb-10"
     x-data="{
        fields: @if($useLivewire) @entangle($groupName).defer @else [] @endif,
        initRepeater() {
            let repeater = $(this.$refs.repeater);
            let fields = this.fields;

            if (repeater.data('repeater-initialized')) {
                return;
            }

            console.log('initRepeater', this.fields);

            const setLivewireModel = function (element, name) {
                // Find the index of the current item
                let index = element.closest('[data-repeater-item]').index();

                // Check if the index exists in the fields array
                if (typeof fields[index] === 'undefined') {
                    fields[index] = {};
                }

                // Check if the name exists in the fields array
                if (typeof fields[index][name] === 'undefined') {
                    fields[index][name] = null;
                }

                // Set the name
                name = 'fields[' + index + '].' + name;

                // Set the wire:model attribute name
                element.attr('x-model', name);
            };

            repeater.repeater({
                initEmpty: false,
                isFirstItemUndeletable: {{ $required ? 'true' : 'false' }},

                defaultValues: @json($defaultValues, JSON_FORCE_OBJECT),

                show: function () {
                    $(this).slideDown();

                    {{ $showScript ?? '' }}

                    $(this).find('[livewire-input]').each(function () {
                        setLivewireModel($(this), $(this).attr('livewire-input'));
                    });
                },

                hide: function (deleteElement) {
                    $(this).slideUp(deleteElement);

                    {{ $hideScript ?? '' }}

                    // Remove the item from the fields array
                    let index = $(this).index();
                    let removed = fields.splice(index, 1);
                    console.log('hide', index, fields, removed);
                }
            });

            repeater.find('[livewire-input]').each(function () {
                setLivewireModel($(this), $(this).attr('livewire-input'));
            });

            repeater.data('repeater-initialized', true);
        }
    }"
     x-init="
        $nextTick(() => {
            initRepeater();
        });

        $watch('fields', (value) => {
            console.log('fields', value);
        });
    "
     x-ref="repeater"
>
    <x-form::general.label :id="$id" :help-text="$helpText" :label="$label" :required="$required"/>
    <!--begin::Form group-->
    <div class="form-group" wire:ignore>
        <div data-repeater-list="{{ $groupName }}">
            <div data-repeater-item>
                {{ $slot }}
            </div>
        </div>
    </div>
    <!--end::Form group-->

    <!--begin::Form group-->
    <div class="form-group mt-5">
        <a href="javascript:;" data-repeater-create class="btn btn-light-primary">
            <i class="ki-duotone ki-plus fs-3"></i>
            {{ __('basic::general.add') }}
        </a>
    </div>
    <!--end::Form group-->
</div>

This is the code in my livewire view (my form):

<x-form::form.elements.repeater id="contact-repeater" :required="true"
                                group-name="contactPersons" :use-livewire="true">
    <div class="form-group row mb-3">
        <div class="col-md-4">
            <x-form::inputs.input
                name="name"
                {{--                                wire:model.defer="name"--}}
                {{--                                x-model="contactPersons[0].name"--}}
                livewire-input="name"
                :required="true"
                placeholder="Name, Vorname"
            />
        </div>
        <div class="col-md-4">
            <x-form::inputs.input
                name="name"
                {{--                                wire:model="email"--}}
                livewire-input="email"
                :required="true"
                type="email"
                placeholder="E-Mail Adresse"
            />
        </div>
        <div class="col-md-4 d-flex align-items-center">
            <a href="javascript:;" data-repeater-delete
                class="btn btn-sm btn-light-danger">
                <i class="ki-duotone ki-trash fs-5"><span class="path1"></span><span
                        class="path2"></span><span class="path3"></span><span class="path4"></span><span
                        class="path5"></span></i>
                {{ __('basic::general.delete') }}
            </a>
        </div>
    </div>
</x-form::form.elements.repeater>

I add the index to the fields array before the attribute is set. Unfortunately, I am getting the error above. Currently I have no idea on how to fix this or where this comes from, maybe you have an idea.

Best regards

newbie360's avatar

@Basti0208 your code is out of my knowledge, i don't know about JS Form Repeater , look like you are using jQuery with Alpine JS

i think you can make more simple code than the current code, may be try to google search any Doc about Livewire with JS Form Repeater

or debug your code from rendered code (NOT the source code)

PS. i guess here is name="email"

            <x-form::inputs.input
                name="name" <<<<<<<<<<<<<<<<<<<<<<<<<<<
                {{--                                wire:model="email"--}}
                livewire-input="email"
Basti0208's avatar

@newbie360 I have debugged the code and generated output. I still don't see the error.

The fields array returns the new index at creation of the input field. The input has the correct x-model attribute.

Is there maybe an issue with syncing? Like the array isn't pushed and therefore the AlpineJS cannot access the index?

newbie360's avatar

@Basti0208 i never tired use jQuery with Alpine JS handle the dom at the same time

are you wraped all code into x-data,

<div x-data="{........}">
	// all code inside here
</div>
Basti0208's avatar

@newbie360 Yes, I did. I logged a lot of things in the console.

1 email <empty string> 
Proxy { <target>: (2) […], <handler>: {…} }

You can see, that the index 1 is added and the property is correctly set.

I don't get it. Does Alpine need some sort of "timeout" till the values are set? Without entangle it works -> using a simple array.

So the error comes from the entangle and sync with livewire.

newbie360's avatar

@Basti0208

<div>

    @json($tests)

    <div x-data="{ doms: @entangle('tests') }" class="flex flex-col space-y-4">

        <template x-for="(dom, index) in doms">
            <input x-model.debounce.1000ms="doms[index]" type="text">
        </template>

        <span x-text="doms"></span>
    </div>

</div>

if x-model was changed, doms will sync via @entangle('tests')

but we can delay Alpine pass data to @entangle

@entangle('tests')

<input x-model.debounce.999999999999999999ms

and also we can prevent @entangle sync data to backend

@entangle('tests').defer

// here if x-model changed, that means doms also changed
// but .defer prevented send the request, the next request will send the whole doms data to backend
<input x-model

you can think @entangle is bridge

Alpine <<<<  any delay ? >>>> @entangle <<<< sync ? >>>> Livewire 

PS. we can't use like this, this is invalid

x-data="{ doms: @entangle('tests').debounce.1000ms }"
Basti0208's avatar
Basti0208
OP
Best Answer
Level 1

@newbie360 I found my error. It is stupid obvious. I assigned the fields array to a variable which obviously does not use reference and just copied the array. The array was never updated (just locally). Now it's working correctly, at least the adding.

Please or to participate in this conversation.