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:
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.
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>
);
}
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