Why does my form uploads the image again?

  inertiajs, javascript, laravel, php, vue.js

I am working on a Laravel 8 app with Jetstream and Inertia and I created a form that has two text fields and one image upload field. The problem is when I edit the resource and upload a new image everything works but when I try to save the resource again immediately after the first time it looks like it is uploading the image again even though the data after the first upload for the field is a URL to the image. The goal is to prevent the second image upload on the second edit as the image is the same – there is no change. If you reload the page after the first edit and save the data again it works fine there is no image upload.

I’ve uploaded a video here.

I assume the loading indicator below my bookmarks is somehow done by InertiaJS when making ajax requests. Or maybe it is doing a full page reload?

I’ve spend hours on this, googled various things, looked and tinkered with the code, read InertiaJS documentation and found nothing!

I am using S3 to upload the images to. Also, I am using Spatie‘s media-library package to handle the images.

here is my Edit page component:

<template>
    <app-layout title="Edit author information">
        <div>
            <div class="max-w-7xl mx-auto py-5 sm:px-6 lg:px-8">
                <div>
                    <create-or-update-author-form :author="author" method="PUT" :blank_avatar_image="$page.props.assets.blank_avatar_image"/>
                </div>
            </div>
        </div>
    </app-layout>
</template>

<script>
    import AppLayout from '@/Layouts/AppLayout.vue'
    import CreateOrUpdateAuthorForm from '@/Pages/Authors/Partials/CreateOrUpdateAuthorForm.vue'

    export default {
        props: ['sessions', 'author'],

        components: {
            AppLayout,
            CreateOrUpdateAuthorForm,
        },
    }
</script>

here is my CreateOrUpdateAuthorForm component:

<template>
    <jet-form-section @submitted="save">
        <template #title>
            <span v-if="method === 'POST'">Create new author</span>
            <span v-else-if="method === 'NONE'">Viewing author</span>
            <span v-else>Update author information</span>
        </template>

        <template #form>
            <!-- Photo -->
            <div class="col-span-6 sm:col-span-4">
                <!-- Photo File Input -->
                <input type="file" class="hidden"
                            ref="photo"
                            @change="updatePhotoPreview">

                <jet-label for="photo" value="Photo" />

                <!-- Current Photo -->
                <div class="mt-2" v-show="! photo_preview">
                    <img v-if="photo_or_blank_image" :src="photo_or_blank_image" :alt="author.name" class="rounded-full h-20 w-20 object-cover">
                </div>

                <!-- New Photo Preview -->
                <div class="mt-2" v-show="photo_preview">
                    <span class="block rounded-full w-20 h-20"
                          :style="'background-size: cover; background-repeat: no-repeat; background-position: center center; background-image: url('' + photo_preview + '');'">
                    </span>
                </div>

                <jet-secondary-button v-if="method !== 'NONE'" class="mt-2 mr-2" type="button" @click.prevent="selectNewPhoto">
                    Select A New Photo
                </jet-secondary-button>

                <jet-secondary-button v-if="author.photo && method !== 'NONE' && !author_photo_is_blank" type="button" class="mt-2" @click.prevent="deletePhoto">
                    Remove Photo
                </jet-secondary-button>

                <jet-input-error :message="form.errors.photo" class="mt-2" />
            </div>

            <!-- Name -->
            <div class="col-span-6 sm:col-span-4">
                <jet-label for="name" value="Name" />
                <jet-input id="name" type="text" class="mt-1 block w-full disabled:bg-gray-100" v-model="form.name" autocomplete="name" :disabled="disabled" />
                <jet-input-error :message="form.errors.name" class="mt-2" />
            </div>

            <!-- Email -->
            <div class="col-span-6 sm:col-span-4">
                <jet-label for="bio" value="Bio" />
                <jet-input id="bio" type="text" class="mt-1 block w-full disabled:bg-gray-100" v-model="form.bio" :disabled="disabled" />
                <jet-input-error :message="form.errors.bio" class="mt-2" />
            </div>
        </template>

        <template v-if="!disabled" #actions>
            <jet-button :class="{ 'opacity-25': form.processing }" :disabled="form.processing">
                <span v-if="method === 'POST'">Create</span>
                <span v-else>Save</span>
            </jet-button>

            <progress-bar :progress="form.progress"/>

            <jet-action-message :on="form.wasSuccessful" class="ml-3">
                <span v-if="method === 'POST'">Created.</span>
                <span v-else>Saved.</span>
            </jet-action-message>
        </template>
    </jet-form-section>
</template>

<script>
    import JetButton from '@/Jetstream/Button.vue'
    import JetFormSection from '@/Jetstream/FormSection.vue'
    import JetInput from '@/Jetstream/Input.vue'
    import JetInputError from '@/Jetstream/InputError.vue'
    import JetLabel from '@/Jetstream/Label.vue'
    import JetActionMessage from '@/Jetstream/ActionMessage.vue'
    import JetSecondaryButton from '@/Jetstream/SecondaryButton.vue'
    import ProgressBar from '@/Shared/Elements/ProgressBar.vue'
    import Utils from '@/Shared/Utils'
    import Forms from '@/Shared/Forms'

    export default {
        components: {
            JetActionMessage,
            JetButton,
            JetFormSection,
            JetInput,
            JetInputError,
            JetLabel,
            JetSecondaryButton,
            ProgressBar,
        },

        props: {
            author: {
                type: Object,
                default: {
                    id: null,
                    name: '',
                    bio: '',
                    photo: null,
                }
            },
            blank_avatar_image: {
                type: String,
                default: null,
            },
            method: {
                type: String,
                default: 'POST',
            },
            disabled: {
                default: false
            }
        },

        data() {
            return {
                form: this.$inertia.form({
                    _method: this.method,
                    id: this.author.id,
                    name: this.author.name,
                    bio: this.author.bio,
                    photo: this.author.photo,
                }),

                formDetails: {
                    routes: {
                        store: route('authors.store'),
                        update: this.author.id 
                         ? route('authors.update', { author : this.author.id})
                         : null,
                    },
                    errorBag: 'createOrUpdateAuthor',
                },

                photo_preview: null,
            }
        },

        methods: Forms,

        computed: {
            photo_or_blank_image() {
                return this.author.photo ? this.author.photo : this.blank_avatar_image;
            },
            author_photo_is_blank() {
                return Utils.isBlankAvatarImage(this.author.photo);
            }
        }
    }
</script>

here is my Forms class:

var Forms = {
    save() {
        var self = this;

        // if method is NONE don't submit form
        if (this.method === 'NONE') {
            return false;
        }
        
        if (this.$refs.photo && this.$refs.photo.files[0]) {
            this.form.photo = this.$refs.photo.files[0];
        }

        var request = {
            errorBag: this.formDetails.errorBag,
            preserveScroll: true,
            forceFormData: true,
        };

        var route = this.formDetails.routes.store;

        if (this.method === 'PUT') {
            route = this.formDetails.routes.update;
        }

        this.form.wasSuccessful = false;
        this.form.post(route, request);
    },

    selectNewPhoto() {
        this.$refs.photo.click();
    },

    updatePhotoPreview() {
        const photo = this.$refs.photo.files[0];

        if (! photo) return;

        this.author.photo = photo;

        const reader = new FileReader();

        reader.onload = (e) => {
            this.photo_preview = e.target.result;
        };

        reader.readAsDataURL(photo);
    },

    deletePhoto() {
        this.author.photo = null;
        this.form.photo = null;
        this.photo_preview = this.blank_avatar_image;
        this.clearPhotoFileInput();
    },

    clearPhotoFileInput() {
        if (this.$refs.photo?.value) {
            this.$refs.photo.value = null;
        }
    }
}

export default Forms;

here is my AuthorsController which handles the requests, I’ve only copied the edit and update methods as they are the only ones relevant:

<?php

namespace AppHttpControllers;

use AppActionsZavoonAuthorsAuthorActions;
use AppModelsAuthor;
use IlluminateHttpRequest;
use LaravelJetstreamJetstream;
use IlluminateSupportFacadesAuth;
use IlluminateSupportFacadesRedirect;
use InertiaInertia;

class AuthorsController extends Controller
{
    /**
     * Show the form for editing the specified resource.
     *
     * @param  int  $id
     * @return IlluminateHttpResponse
     */
    public function edit($id)
    {
        $author = Author::where('id', $id)
            ->where('team_id', Auth::user()->currentTeam->id)
            ->first();

        if (!$author) {
            abort(404);
        }

        return Inertia::render('Authors/Edit', ['author' => $author->id])
            ->with('author', $author);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  IlluminateHttpRequest  $request
     * @param  int  $id
     * @return IlluminateHttpResponse
     */
    public function update(Request $request, $id, AuthorActions $action)
    {
        $author = $action->update($request->all());

        $success = 'Author information updated.';

        session()->flash('success', $success);

        return Redirect::route('authors.edit', ['author' => $author->id]);
    }
}

here is my AuthorActions class which handles authors related logic:

<?php

namespace AppActionsZavoonAuthors;

use IlluminateSupportFacadesAuth;
use IlluminateSupportFacadesValidator;
use AppModelsAuthor;
use AppRulesRules;
use IlluminateValidationRule;

class AuthorActions
{
    /**
     * Creates new author
     */
    public function create(array $input)
    {
        if ($this->validate($input, 'create')) {
            $user = Auth::user();
            $input['user_id'] = $user->id;
            $input['team_id'] = $user->currentTeam->id;
            $author = new Author();

            $author->fill($input);
            $author->save();

            $author->updatePhoto($input);

            return $author;
        }

        return false;
    }
    
    /**
     * Updates author
     *
     * @param  array  $input
     * @return boolean|Author
     */
    public function update(array $input)
    {
        if ($this->validate($input, 'update')) {
            $author = Author::find($input['id']);

            $author->fill($input);
            $author->save();

            $author->updatePhoto($input);

            return $author;
        }

        return false;
    }

    /**
     * Validates input
     */
    public function validate(array $input, $type)
    {
        $user = Auth::user();
     
        $rules = [
            'name' => ['required', 'string', 'max:255'],
            'bio' => ['required', 'string', 'max:4000'],
            'photo' => Rules::photo($type),
        ];

        if ($type === 'update') {
            $rules['id'] = [
                'required', 
                Rule::exists('authors')->where(function ($query) use ($input, $user) {
                    return $query
                        ->where('id', $input['id'])
                        ->where('team_id', $user->currentTeam->id);
                }),
            ];
        }

        Validator::make($input, $rules)->validateWithBag('createOrUpdateAuthor');

        return true;
    }
}

here is my Rules class which gives me some of the validation rules:

<?php

namespace AppRules;

use AppRulesImageOrLink;

class Rules
{
    public static function photo($type = 'create')
    {
        if ($type === 'create') {
            return ['nullable', self::mimes(), self::maxImageSize()];
        } else {
            return ['nullable', new ImageOrLink()];
        }
    }

    public static function mimes()
    {
        return 'mimes:jpg,jpeg,png';
    }

    public static function maxImageSize() 
    {
        return 'max:5120';
    }
}

and finally here is my ImageOrLink rule which is used in validating the image uploads:

<?php

namespace AppRules;

use IlluminateContractsValidationRule;
use IlluminateSupportFacadesValidator;
use AppRulesLink;

class ImageOrLink implements Rule
{
    /**
     * Create a new rule instance.
     *
     * @return void
     */
    public function __construct()
    {
        //
    }

    /**
     * Determine if the validation rule passes.
     * True if it is an image upload or a valid url.
     *
     * @param  string  $attribute
     * @param  mixed  $value
     * @return bool
     */
    public function passes($attribute, $value)
    {
        if (Link::isLink($value)) { 
            return true;    
        } else {
            $validator = Validator::make([
                'image' => $value
            ], [
                'image' => ['required', Rules::mimes(), Rules::maxImageSize()],
            ]);

            if ($validator->fails()) {
                return false;
            }

            return true;
        }

        return false;
    }

    /**
     * Get the validation error message.
     *
     * @return string
     */
    public function message()
    {
        return 'This needs to be an image upload or a link to an image.';
    }
}

Any ideas appreciated! I really don’t get why it’s happening.

EDIT 1: Also, the uploading seems to take quite a while even though I am working locally. No idea why.

Source: Laravel

Leave a Reply