Search code examples
phpreactjslaravelinertiajslaravel-11

Why Aren't File Uploads (Banner, Images, Slider) Working in Laravel with React and Inertia.js?


Context I'm developing a Laravel application with React (using Inertia.js) where users can update "achievements". These achievements include a banner image and a description section that can contain multiple images, a slider, and YouTube video links. The update form seems to work (no client or server-side errors), but the files (banner and description images) are not being updated.

Problem When I try to update an achievement by changing the banner image or adding/modifying description

Images:

  • The form submits without any apparent error.
  • I'm redirected as if everything was validated.
  • Server logs show that the update was performed.
  • However, when I return to the edit page, the new images are not there.

Maybe I shouldn't be using Inertia JS? I can create an achievement without any problem, and I'm able to modify all the text elements. However, when I modify things like the title and the banner, for example, the changes seem to be saved, but when I return to the page, neither the title nor the image is updated.

What I've Tried

  • Checking server-side logs
  • Adding additional logging on both client and server sides
  • Verifying server configuration for file uploads
  • Ensuring that the enctype="multipart/form-data" attribute is set on the form
php

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreAchievementRequest;
use Illuminate\Http\Request;
use App\Models\Achievement;
use Inertia\Inertia;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;

class AdminAchievementController extends Controller
{
    public function edit(Achievement $achievement)
    {
        $tags = [
            'La stratégie',
            'Les fondations',
            'Le studio',
            'Le web',
            'Les relations presse',
            'L\'évènementiel',
            'Les expertises complémentaires',
            'Le social media',
        ];

        return Inertia::render('Achievements/Admin/Edit', [
            'achievement' => $achievement,
            'tags' => $tags,
        ]);
    }

    public function update(Request $request, Achievement $achievement)
    {

        // Log all request data
        Log::info('Contenu de la requête:', $request->all());

        // Log file information if present
        if ($request->hasFile('banner')) {
            $file = $request->file('banner');
            Log::info('Informations sur le fichier banner:', [
                'name' => $file->getClientOriginalName(),
                'size' => $file->getSize(),
                'mime' => $file->getMimeType()
            ]);
        } else {
            Log::info('Aucun fichier banner dans la requête');
        }

        // Log headers
        Log::info('En-têtes de la requête:', $request->headers->all());

        $rules = [
            'title' => 'sometimes|required|string|max:255',
            'sub_title' => 'sometimes|required|string|max:255',
            'script' => 'sometimes|required|array',
            'script.*' => 'required|string',
            'answer' => 'sometimes|required|array',
            'answer.*' => 'required|string',
            'tag' => 'sometimes|required|array',
            'tag.*' => 'boolean',
            'description' => 'sometimes|nullable|array',
            'description.*.type' => 'sometimes|required|in:image,slider,youtube',
            'description.*.position' => 'sometimes|required|integer|min:1',
            'description.*.legend' => 'sometimes|nullable|string',
            'description.*.url' => 'sometimes|nullable|url',
            'site_url' => 'sometimes|nullable|array|max:3',
            'site_url.*.url' => 'required|url',
            'site_url.*.url_text' => 'required|string|max:255',
            'published' => 'sometimes|required|boolean',
            'show_on_homepage' => 'sometimes|required|boolean',
        ];

        // Ajout conditionnel des règles pour les fichiers
        if ($request->hasFile('banner')) {
            $rules['banner'] = 'required|file|mimes:jpeg,png,jpg,gif,svg,webp|max:5000';
        }

        if ($request->has('description')) {
            foreach ($request->input('description') as $key => $item) {
                if (isset($item['type']) && $item['type'] === 'image' && $request->hasFile("description.{$key}.image")) {
                    $rules["description.{$key}.image"] = 'required|file|mimes:jpeg,png,jpg,gif,svg,webp|max:5000';
                }
                if (isset($item['type']) && $item['type'] === 'slider') {
                    foreach ($item['slides'] as $slideKey => $slide) {
                        if ($request->hasFile("description.{$key}.slides.{$slideKey}")) {
                            $rules["description.{$key}.slides.{$slideKey}"] = 'required|file|mimes:jpeg,png,jpg,gif,svg,webp|max:5000';
                        }
                    }
                }
            }
        }

        $validator = Validator::make($request->all(), $rules);

        if ($validator->fails()) {
            return redirect()->back()->withErrors($validator)->withInput();
        }

        $validated = $validator->validated();

        if (isset($validated['tag'])) {
            $validated['tag'] = array_map('intval', $validated['tag']);
        }

        if ($request->hasFile('banner')) {
            Log::info('Fichier banner reçu', ['filename' => $request->file('banner')->getClientOriginalName()]);

            if ($achievement->banner) {
                Log::info('Suppression de l\'ancienne bannière', ['old_banner' => $achievement->banner]);
                Storage::delete('public/' . $achievement->banner);
            }

            $fileName = time() . '-' . $request->file('banner')->getClientOriginalName();
            $bannerPath = $request->file('banner')->storeAs('images', $fileName, 'public');
            $validated['banner'] = $bannerPath;

            Log::info('Nouvelle bannière enregistrée', ['new_banner' => $bannerPath]);
        } else {
            Log::info('Aucun nouveau fichier banner reçu');
        }

        // Traitement de la description
        if (isset($validated['description'])) {
            foreach ($validated['description'] as $key => $item) {
                if ($item['type'] === 'image' && $request->hasFile("description.{$key}.image")) {
                    if (isset($achievement->description[$key]['image'])) {
                        Storage::delete('public/' . $achievement->description[$key]['image']);
                    }
                    $fileName = time() . '-' . $request->file("description.{$key}.image")->getClientOriginalName();
                    $imagePath = $request->file("description.{$key}.image")->storeAs('images', $fileName, 'public');
                    $validated['description'][$key]['image'] = $imagePath;
                } elseif ($item['type'] === 'slider' && isset($item['slides'])) {
                    foreach ($item['slides'] as $slideKey => $slide) {
                        if ($request->hasFile("description.{$key}.slides.{$slideKey}")) {
                            if (isset($achievement->description[$key]['slides'][$slideKey])) {
                                Storage::delete('public/' . $achievement->description[$key]['slides'][$slideKey]);
                            }
                            $fileName = time() . '-' . $request->file("description.{$key}.slides.{$slideKey}")->getClientOriginalName();
                            $slidePath = $request->file("description.{$key}.slides.{$slideKey}")->storeAs('images', $fileName, 'public');
                            $validated['description'][$key]['slides'][$slideKey] = $slidePath;
                        }
                    }
                }
            }
        }

        // Mise à jour de la date de publication si nécessaire
        if (isset($validated['published'])) {
            $validated['published_at'] = $validated['published'] ? now() : null;
        }

        try {
            Log::info('Tentative de mise à jour de l\'achievement', ['data' => $validated]);
            $achievement->update($validated);
            Log::info('Achievement mis à jour avec succès');
            return redirect()->route('admin.achievements.index')->with('success', 'Réalisation mise à jour avec succès.');
        } catch (\Exception $e) {
            Log::error('Erreur lors de la mise à jour de l\'achievement', ['error' => $e->getMessage()]);
            return redirect()->back()->withErrors(['error' => 'Erreur lors de la mise à jour de la réalisation: ' . $e->getMessage()]);
        }
    }
}
import React, { useState, useEffect } from 'react';
import { Head, useForm } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
import { SCII_URL } from '@/config';

export default function Edit({ auth, achievement, tags }) {
    const { data, setData, put, processing, errors } = useForm({
        banner: achievement.banner,
        title: achievement.title,
        sub_title: achievement.sub_title,
        script: achievement.script || [],
        answer: achievement.answer || [],
        tag: achievement.tag || {},
        description: achievement.description || [],
        site_url: achievement.site_url || [],
        published: achievement.published,
        show_on_homepage: achievement.show_on_homepage,
    });

    const [previewBanner, setPreviewBanner] = useState(achievement.banner ? `${SCII_URL}${achievement.banner}` : null);

    const [descriptionItems, setDescriptionItems] = useState(achievement.description || []);

    useEffect(() => {
        setData('description', descriptionItems);
    }, [descriptionItems]);

    const handleAddSiteUrl = () => {
        if (data.site_url.length < 3) {
            setData('site_url', [...data.site_url, { url: '', url_text: '' }]);
        }
    };

    const handleRemoveSiteUrl = (index) => {
        const newSiteUrls = data.site_url.filter((_, i) => i !== index);
        setData('site_url', newSiteUrls);
    };

    const handleChangeSiteUrl = (index, field, value) => {
        const newSiteUrls = data.site_url.map((site, i) =>
            i === index ? { ...site, [field]: value } : site
        );
        setData('site_url', newSiteUrls);
    };

    const handleAddField = (field) => {
        setData(field, [...data[field], '']);
    };

    const handleRemoveField = (field, index) => {
        const newData = data[field].filter((_, i) => i !== index);
        setData(field, newData);
    };

    const handleChangeField = (field, index, value) => {
        const newData = data[field].map((item, i) => (i === index ? value : item));
        setData(field, newData);
    };

    const handleAddDescriptionItem = (type) => {
        const newItem = {
            type,
            position: descriptionItems.length + 1,
            ...(type === 'image' && { image: null, legend: '', url: '' }),
            ...(type === 'slider' && { slides: [] }),
            ...(type === 'youtube' && { url: '' }),
        };
        setDescriptionItems([...descriptionItems, newItem]);
    };

    const handleRemoveDescriptionItem = (index) => {
        const newItems = descriptionItems.filter((_, i) => i !== index);
        setDescriptionItems(newItems);
    };

    const handleChangeDescriptionItem = (index, field, value) => {
        const newItems = descriptionItems.map((item, i) =>
            i === index ? { ...item, [field]: value } : item
        );
        setDescriptionItems(newItems);
    };

    const handleImageUpload = (index, e) => {
        const file = e.target.files[0];
        handleChangeDescriptionItem(index, 'image', file);
    };

    const handleAddSliderImage = (index) => {
        const newItems = descriptionItems.map((item, i) =>
            i === index ? { ...item, slides: [...item.slides, null] } : item
        );
        setDescriptionItems(newItems);
    };

    const handleRemoveSliderImage = (itemIndex, slideIndex) => {
        const newItems = descriptionItems.map((item, i) =>
            i === itemIndex ? { ...item, slides: item.slides.filter((_, j) => j !== slideIndex) } : item
        );
        setDescriptionItems(newItems);
    };

    const handleChangeSliderImage = (itemIndex, slideIndex, file) => {
        const newItems = descriptionItems.map((item, i) =>
            i === itemIndex ? {
                ...item,
                slides: item.slides.map((slide, j) => j === slideIndex ? file : slide)
            } : item
        );
        setDescriptionItems(newItems);
    };

    const handleBannerChange = (e) => {
        const file = e.target.files[0];
        if (file) {
            setData('banner', file);  // Stocke le fichier sélectionné dans l'état
            setPreviewBanner(URL.createObjectURL(file));  // Optionnel pour la prévisualisation
        }
    };

    const handleSubmit = (e) => {
        e.preventDefault();

        // Créer un objet FormData pour gérer les fichiers
        const formData = new FormData();

        // Ajouter les autres champs du formulaire dans FormData
        formData.append('title', data.title);
        formData.append('sub_title', data.sub_title);
        formData.append('published', data.published);
        formData.append('show_on_homepage', data.show_on_homepage);

        // Si vous avez des objets, vous devrez les sérialiser (comme description)
        formData.append('description', JSON.stringify(descriptionItems));

        if (data.banner instanceof File) {
            formData.append('banner', data.banner);
            console.log('Banner file added to FormData:', data.banner);
        } else {
            console.log('No new banner file selected');
        }

        // Si vous avez d'autres fichiers dans descriptionItems (par exemple pour les sliders)
        descriptionItems.forEach((item, index) => {
            if (item.type === 'image' && item.image instanceof File) {
                formData.append(`description[${index}][image]`, item.image);
            }
            if (item.type === 'slider') {
                item.slides.forEach((slide, slideIndex) => {
                    if (slide instanceof File) {
                        formData.append(`description[${index}][slides][${slideIndex}]`, slide);
                    }
                });
            }
        });

        // Ajouter tous les champs au FormData
        Object.keys(data).forEach(key => {
            if (key === 'banner' && data[key] instanceof File) {
                formData.append(key, data[key]);
            } else if (typeof data[key] !== 'undefined' && data[key] !== null) {
                formData.append(key, JSON.stringify(data[key]));
            }
        });

        // Log le contenu du FormData
        for (let [key, value] of formData.entries()) {
            console.log(key, value);
        }

        // Utiliser la méthode post ou put d'Inertia en mode multipart
        put(route('admin.achievements.update', achievement.id), formData, {
            forceFormData: true,  // Forcer Inertia à utiliser FormData au lieu de JSON
        });
    };


    return (
        <AuthenticatedLayout user={auth.user}>
            <Head title="Modification d'une réalisation" />

            <div className="achievements-create-form-container">
                <form onSubmit={handleSubmit} encType="multipart/form-data">
                    <div className="form-group-input-banner">
                        <label htmlFor="banner">Bannière</label>
                        <input
                            type="file"
                            id="banner"
                            onChange={handleBannerChange}
                        />
                        {errors.banner && <div className="text-red-500">{errors.banner}</div>}
                        {previewBanner && <img src={previewBanner} alt="Preview banner" className="mt-2 max-w-xs" />}
                    </div>

                    <div className="form-group-input-title-sub-title">
                        <div className="form-group-input-title">
                            <label htmlFor="title">Titre</label>
                            <input
                                type="text"
                                id="title"
                                value={data.title}
                                onChange={(e) => setData('title', e.target.value)}
                            />
                            {errors.title && <div className="text-red-500">{errors.title}</div>}
                        </div>

                        <div className="form-group-input-sub-title">
                            <label htmlFor="sub_title">Sous titre / Phrase d'accroche</label>
                            <input
                                type="text"
                                id="sub_title"
                                value={data.sub_title}
                                onChange={(e) => setData('sub_title', e.target.value)}
                            />
                            {errors.sub_title && <div className="text-red-500">{errors.sub_title}</div>}
                        </div>
                    </div>

                    <div className="form-group-input-script">
                        <label>Le Script</label>
                        {data.script.map((p, index) => (
                            <div key={index}>
                                <textarea
                                    value={p}
                                    onChange={(e) => handleChangeField('script', index, e.target.value)}
                                />
                                <button type="button" onClick={() => handleRemoveField('script', index)}>Supprimer</button>
                            </div>
                        ))}
                        <button type="button" onClick={() => handleAddField('script')}>Ajouter un paragraphe</button>
                        {errors.script && <div className="text-red-500">{errors.script}</div>}
                    </div>

                    <div className="form-group-input-answer">
                        <label>La Réponse</label>
                        {data.answer.map((p, index) => (
                            <div key={index}>
                                <textarea
                                    value={p}
                                    onChange={(e) => handleChangeField('answer', index, e.target.value)}
                                />
                                <button type="button" onClick={() => handleRemoveField('answer', index)}>Supprimer</button>
                            </div>
                        ))}
                        <button type="button" onClick={() => handleAddField('answer')}>Ajouter un paragraphe</button>
                        {errors.answer && <div className="text-red-500">{errors.answer}</div>}
                    </div>

                    <div className="form-group-input-tags">
                        <label>Tags</label>
                        <div className="form-group-input-tags-container">
                            {tags.map((tag, index) => (
                                <div key={tag}>
                                    <label htmlFor={`tag_${tag}`}>{tag}</label>
                                    <input
                                        type="checkbox"
                                        id={`tag_${tag}`}
                                        checked={data.tag[tag] || false}
                                        onChange={(e) => {
                                            setData('tag', {
                                                ...data.tag,
                                                [tag]: e.target.checked
                                            });
                                        }}
                                    />
                                </div>
                            ))}
                        </div>
                        {errors.tag && <div className="text-red-500">{errors.tag}</div>}
                    </div>

                    <div className="form-group-input-description">
                        <label>Description</label>
                        <button type="button" onClick={() => handleAddDescriptionItem('image')}>Ajouter une image</button>
                        <button type="button" onClick={() => handleAddDescriptionItem('slider')}>Ajouter un slider</button>
                        <button type="button" onClick={() => handleAddDescriptionItem('youtube')}>Ajouter une vidéo YouTube</button>

                        {descriptionItems.map((item, index) => (
                            <div key={index} className="mb-2">
                                {item.type === 'image' && (
                                    <div className="form-group-input-description-item-image">
                                        <input
                                            type="file"
                                            accept="image/*"
                                            onChange={(e) => handleImageUpload(index, e)}
                                        />
                                        {item.image && <img src={`${SCII_URL}${item.image}`} alt="Current image" className="mt-2 max-w-xs" />}
                                        <input
                                            type="text"
                                            placeholder="Légende"
                                            value={item.legend}
                                            onChange={(e) => handleChangeDescriptionItem(index, 'legend', e.target.value)}
                                        />
                                        <input
                                            type="url"
                                            placeholder="URL associée (optionnel)"
                                            value={item.url}
                                            onChange={(e) => handleChangeDescriptionItem(index, 'url', e.target.value)}
                                        />
                                    </div>
                                )}
                                {item.type === 'slider' && (
                                    <>
                                        {item.slides.map((slide, slideIndex) => (
                                            <div key={slideIndex}>
                                                <input
                                                    type="file"
                                                    accept="image/*"
                                                    onChange={(e) => handleChangeSliderImage(index, slideIndex, e.target.files[0])}
                                                />
                                                {slide && <img src={`${SCII_URL}${slide}`} alt={`Slide ${slideIndex + 1}`} className="mt-2 max-w-xs" />}
                                                <button type="button" onClick={() => handleRemoveSliderImage(index, slideIndex)}>Supprimer</button>
                                            </div>
                                        ))}
                                        <button type="button" onClick={() => handleAddSliderImage(index)}>Ajouter une image au slider</button>
                                    </>
                                )}
                                {item.type === 'youtube' && (
                                    <input
                                        type="text"
                                        placeholder="URL de la vidéo YouTube"
                                        value={item.url}
                                        onChange={(e) => handleChangeDescriptionItem(index, 'url', e.target.value)}
                                    />
                                )}
                                <button type="button" onClick={() => handleRemoveDescriptionItem(index)}>Supprimer</button>
                            </div>
                        ))}
                    </div>

                    <div className="form-group-input-site-url">
                        <label>Lien vers site(s) lié(s)</label>
                        {data.site_url.map((site, index) => (
                            <div key={index}>
                                <input
                                    type="url"
                                    placeholder="URL"
                                    value={site.url}
                                    onChange={(e) => handleChangeSiteUrl(index, 'url', e.target.value)}
                                />
                                <input
                                    type="text"
                                    placeholder="Texte du bouton"
                                    value={site.url_text}
                                    onChange={(e) => handleChangeSiteUrl(index, 'url_text', e.target.value)}
                                />
                                <button type="button" onClick={() => handleRemoveSiteUrl(index)}>Supprimer</button>
                            </div>
                        ))}
                        {data.site_url.length < 3 && (
                            <button type="button" onClick={handleAddSiteUrl}>Ajouter un site</button>
                        )}
                        {errors.site_url && <div className="text-red-500">{errors.site_url}</div>}
                    </div>

                    <div className='form-group-input-published-show-on-homepage'>
                        <div className="form-group-input-published">
                            <label htmlFor="published">Publié</label>
                            <input
                                type="checkbox"
                                id="published"
                                checked={data.published}
                                onChange={(e) => setData('published', e.target.checked)}
                            />
                            {errors.published && <div className="text-red-500">{errors.published}</div>}
                        </div>

                        <div className="form-group-input-show-on-homepage">
                            <label htmlFor="show_on_homepage">Mettre en avant sur la page d'accueil</label>
                            <input
                                type="checkbox"
                                id="show_on_homepage"
                                checked={data.show_on_homepage}
                                onChange={(e) => setData('show_on_homepage', e.target.checked)}
                            />
                            {errors.show_on_homepage && <div className="text-red-500">{errors.show_on_homepage}</div>}
                        </div>
                    </div>

                    <button type="submit" disabled={processing}>Mettre à jour</button>
                </form>
            </div>
        </AuthenticatedLayout>
    );
}

Solution

  • please refer to inertiajs doc in the Multipart limitations it shows that you have to use the post method, and then add this_method: 'put' to your FormData. also refer to this question