27. Cómo subir archivos con Inertia
27: Cómo Subir Archivos con Inertia
En este tutorial, aprenderemos a manejar la subida de archivos en formularios con Inertia.js. Implementaremos un componente FileInput personalizado y configuraremos correctamente el envío de archivos a través de FormData.
1. El Problema con los Inputs de Tipo File
Cuando usamos un input de tipo file con v-model en Vue.js, tenemos un problema: el v-model no funciona correctamente porque los inputs de tipo file son de solo lectura y no podemos asignarles un valor.
1.1. El Error Común
<!-- ❌ Esto no funciona correctamente --> <TextInput type="file" v-model="form.pdf_file" class="mt-1 block w-full" />
El problema es que form.pdf_file recibe un string con la ruta fake del archivo, no el objeto File real.
1.2. La Solución Correcta
<!-- ✅ Usar el evento @input directamente --> <input type="file" @input="event => form.pdf_file = event.target.files[0]" class="mt-1 block w-full" />
2. Crear el Componente FileInput
2.1. Componente FileInput
resources/js/Components/FileInput.vue
<template> <div> <input :ref="inputRef" type="file" :accept="accept" :multiple="multiple" :disabled="disabled" :class="[ 'block w-full text-sm text-gray-500 dark:text-gray-400', 'file:mr-4 file:py-2 file:px-4', 'file:rounded-md file:border-0', 'file:text-sm file:font-semibold', 'file:bg-indigo-50 file:text-indigo-700', 'dark:file:bg-indigo-900/20 dark:file:text-indigo-400', 'hover:file:bg-indigo-100 dark:hover:file:bg-indigo-900/30', 'focus:outline-none', props.class, ]" @input="handleInput" @change="handleChange" /> <!-- Información del archivo seleccionado --> <div v-if="modelValue && !multiple" class="mt-2"> <div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded-md"> <div class="flex items-center space-x-2"> <FileIcon class="w-5 h-5 text-gray-500" /> <span class="text-sm text-gray-700 dark:text-gray-300"> {{ modelValue.name }} </span> <span class="text-xs text-gray-500"> ({{ formatFileSize(modelValue.size) }}) </span> </div> <button type="button" @click="removeFile" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" > <XMarkIcon class="w-5 h-5" /> </button> </div> </div> <!-- Múltiples archivos --> <div v-if="multiple && modelValue && modelValue.length > 0" class="mt-2 space-y-1"> <div v-for="(file, index) in modelValue" :key="index" class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded-md" > <div class="flex items-center space-x-2"> <FileIcon class="w-5 h-5 text-gray-500" /> <span class="text-sm text-gray-700 dark:text-gray-300"> {{ file.name }} </span> <span class="text-xs text-gray-500"> ({{ formatFileSize(file.size) }}) </span> </div> <button type="button" @click="removeFileAtIndex(index)" class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300" > <XMarkIcon class="w-5 h-5" /> </button> </div> </div> <!-- Mensaje de error --> <p v-if="error" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ error }} </p> </div> </template> <script setup> import { ref } from 'vue'; import FileIcon from '@/Components/Icons/FileIcon.vue'; import XMarkIcon from '@/Components/Icons/XMarkIcon.vue'; const props = defineProps({ modelValue: { type: [File, Array, null], default: null, }, accept: { type: String, default: '', }, multiple: { type: Boolean, default: false, }, disabled: { type: Boolean, default: false, }, class: { type: String, default: '', }, error: { type: String, default: '', }, maxSize: { type: Number, default: 10240, // 10MB en KB }, }); const emit = defineEmits(['update:modelValue', 'change', 'error']); const inputRef = ref(null); // Manejar el input del archivo const handleInput = (event) => { const files = event.target.files; if (!files || files.length === 0) { emit('update:modelValue', props.multiple ? [] : null); return; } // Validar tamaño const invalidFiles = []; const validFiles = []; for (const file of files) { if (file.size > props.maxSize * 1024) { invalidFiles.push(file.name); } else { validFiles.push(file); } } if (invalidFiles.length > 0) { const message = `Los siguientes archivos exceden el límite de ${props.maxSize}KB: ${invalidFiles.join(', ')}`; emit('error', message); // Resetear el input if (inputRef.value) { inputRef.value.value = ''; } return; } if (props.multiple) { emit('update:modelValue', validFiles); } else { emit('update:modelValue', validFiles[0] || null); } emit('change', props.multiple ? validFiles : (validFiles[0] || null)); }; // Manejar cambio de archivo const handleChange = (event) => { // El cambio ya está manejado en handleInput }; // Eliminar archivo const removeFile = () => { emit('update:modelValue', null); if (inputRef.value) { inputRef.value.value = ''; } }; // Eliminar archivo por índice (múltiple) const removeFileAtIndex = (index) => { if (props.multiple && Array.isArray(props.modelValue)) { const newFiles = [...props.modelValue]; newFiles.splice(index, 1); emit('update:modelValue', newFiles); } }; // Formatear tamaño de archivo const formatFileSize = (bytes) => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; // Exponer método para resetear defineExpose({ reset: () => { if (inputRef.value) { inputRef.value.value = ''; } emit('update:modelValue', props.multiple ? [] : null); }, }); </script>
3. Actualizar el Formulario para Usar FileInput
3.1. Modificar KnowledgeSourceForm
resources/js/Components/KnowledgeSources/KnowledgeSourceForm.vue
<template> <div class="space-y-4"> <!-- Nombre --> <div class="col-span-6 sm:col-span-4"> <InputLabel for="name" value="Nombre" /> <TextInput id="name" v-model="form.name" type="text" class="mt-1 block w-full" placeholder="Ej: Manual de Usuario" :error="form.errors.name" /> <InputError :message="form.errors.name" class="mt-2" /> </div> <!-- Tipo --> <div class="col-span-6 sm:col-span-4"> <InputLabel for="type" value="Tipo de Fuente" /> <SelectInput id="type" v-model="form.type" :options="sourceTypes" class="mt-1 block w-full" placeholder="Selecciona un tipo" :error="form.errors.type" /> <InputError :message="form.errors.type" class="mt-2" /> </div> <!-- Campo PDF (condicional) --> <div v-if="form.type === 'pdf'" class="col-span-6 sm:col-span-4"> <InputLabel for="pdf_file" value="Archivo PDF" /> <FileInput id="pdf_file" v-model="form.pdf_file" accept=".pdf,application/pdf" class="mt-1 block w-full" :error="form.errors.pdf_file" :max-size="10240" @error="handleFileError" /> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> Tamaño máximo: 10MB. Formatos permitidos: PDF </p> <InputError :message="form.errors.pdf_file" class="mt-2" /> </div> <!-- Campo Website (condicional) --> <div v-if="form.type === 'website'" class="col-span-6 sm:col-span-4"> <InputLabel for="website_url" value="URL del Website" /> <TextInput id="website_url" v-model="form.website_url" type="url" class="mt-1 block w-full" placeholder="https://ejemplo.com" :error="form.errors.website_url" /> <InputError :message="form.errors.website_url" class="mt-2" /> </div> </div> </template> <script setup> import InputLabel from '@/Components/InputLabel.vue'; import TextInput from '@/Components/TextInput.vue'; import SelectInput from '@/Components/SelectInput.vue'; import FileInput from '@/Components/FileInput.vue'; import InputError from '@/Components/InputError.vue'; const props = defineProps({ form: { type: Object, required: true, }, }); const sourceTypes = [ { value: 'pdf', label: '📄 PDF' }, { value: 'website', label: '🌐 Website' }, ]; // Manejar errores del archivo const handleFileError = (message) => { // Mostrar el error en el formulario props.form.errors.pdf_file = message; }; </script>
4. Configurar el Envío del Formulario
4.1. Actualizar el Modal
resources/js/Components/KnowledgeSources/KnowledgeSourceCreateModal.vue
<template> <DialogModal :show="show" @close="handleClose"> <template #title> <div class="flex items-center"> <BookIcon class="w-6 h-6 mr-2 text-indigo-600" /> <span>Agregar Fuente de Conocimiento</span> </div> </template> <template #content> <div class="space-y-4"> <p class="text-sm text-gray-600 dark:text-gray-400"> Agrega una nueva fuente de conocimiento para tu chatbot. </p> <KnowledgeSourceForm :form="form" /> </div> </template> <template #footer> <div class="flex justify-between w-full"> <SecondaryButton @click="handleClose"> Cerrar </SecondaryButton> <PrimaryButton @click="handleSubmit" :disabled="form.processing" > <span v-if="form.processing" class="flex items-center"> <svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> Guardando... </span> <span v-else>Guardar</span> </PrimaryButton> </div> </template> </DialogModal> </template> <script setup> import DialogModal from '@/Components/DialogModal.vue'; import PrimaryButton from '@/Components/PrimaryButton.vue'; import SecondaryButton from '@/Components/SecondaryButton.vue'; import BookIcon from '@/Components/Icons/BookIcon.vue'; import KnowledgeSourceForm from './KnowledgeSourceForm.vue'; import { useForm } from '@inertiajs/vue3'; import { watch } from 'vue'; const props = defineProps({ show: { type: Boolean, default: false, }, chatbotId: { type: String, required: true, }, }); const emit = defineEmits(['close']); // Estado del formulario const form = useForm({ name: '', type: 'pdf', pdf_file: null, // ✅ Esto será un objeto File website_url: '', }); // Manejar el envío del formulario const handleSubmit = () => { // Crear FormData para enviar archivos const data = new FormData(); // Datos básicos data.append('name', form.name); data.append('type', form.type); // Agregar archivo según el tipo if (form.type === 'pdf' && form.pdf_file) { data.append('file', form.pdf_file); } if (form.type === 'website' && form.website_url) { data.append('url', form.website_url); } // Enviar el formulario con forceFormData form.post(route('chatbots.knowledge-sources.store', props.chatbotId), { data: data, forceFormData: true, // ✅ Importante para archivos preserveScroll: true, onSuccess: () => { // Resetear y cerrar en caso de éxito handleClose(); form.reset(); }, onError: (errors) => { console.error('Errores de validación:', errors); }, }); }; // Cerrar modal const handleClose = () => { emit('close'); }; // Resetear formulario al cerrar watch( () => props.show, (newValue) => { if (!newValue) { form.reset(); } } ); </script>
5. Configurar el Backend para Archivos
5.1. Actualizar el Controlador
app/Http/Controllers/KnowledgeSourceController.php
<?php namespace App\Http\Controllers; use App\Models\Chatbot; use App\Models\KnowledgeSource; use App\Http\Requests\KnowledgeSourceRequest; use Illuminate\Support\Facades\Storage; class KnowledgeSourceController extends Controller { public function store(KnowledgeSourceRequest $request, Chatbot $chatbot) { $this->authorize('update', $chatbot); $validated = $request->validated(); $knowledgeSource = new KnowledgeSource(); $knowledgeSource->name = $validated['name']; $knowledgeSource->type = $validated['type']; $knowledgeSource->status = 'pending'; // ✅ Procesar archivo si existe if ($request->hasFile('file')) { $file = $request->file('file'); // Validar tamaño (10MB máximo) if ($file->getSize() > 10240 * 1024) { return back()->withErrors(['file' => 'El archivo no puede ser mayor a 10MB']); } $path = $file->store("knowledge-sources/{$chatbot->id}/pdfs", 'public'); $knowledgeSource->path = $path; } if ($validated['type'] === 'website') { $knowledgeSource->path = $validated['url']; } $chatbot->knowledgeSources()->save($knowledgeSource); return redirect() ->route('chatbots.knowledge-sources.index', $chatbot) ->with('success', 'Fuente de conocimiento creada exitosamente.'); } }
6. Mejoras y Validaciones Adicionales
6.1. Validación de Tamaño de Archivo en el Frontend
// En el componente FileInput const validateFile = (file) => { // Validar tamaño máximo (10MB) const maxSize = 10 * 1024 * 1024; // 10MB en bytes if (file.size > maxSize) { return 'El archivo no puede ser mayor a 10MB'; } // Validar tipo const allowedTypes = ['application/pdf']; if (!allowedTypes.includes(file.type)) { return 'Solo se permiten archivos PDF'; } return null; };
6.2. Validación de Múltiples Archivos
<template> <FileInput v-model="form.files" multiple accept=".pdf,.doc,.docx,.txt" :max-size="5120" class="mt-1 block w-full" :error="form.errors.files" /> </template>
7. Visualización de Archivos Subidos
7.1. Mostrar Archivos Subidos en el Listado
<template> <div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-md"> <h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> Archivos Adjuntos </h4> <ul class="space-y-1"> <li v-for="file in files" :key="file.id" class="flex items-center justify-between text-sm"> <span class="text-gray-600 dark:text-gray-400"> <FileIcon class="w-4 h-4 inline mr-2" /> {{ file.name }} </span> <span class="text-xs text-gray-500"> {{ formatFileSize(file.size) }} </span> </li> </ul> </div> </template>
8. Resumen del Flujo de Subida
8.1. Flujo Completo
Usuario selecciona un archivo
FileInput captura el evento
@inputy obtieneevent.target.files[0]Vue actualiza
form.pdf_filecon el objetoFileUsuario hace clic en "Guardar"
Modal crea un
FormDatay agrega el archivoInertia envía la petición con
forceFormData: trueBackend recibe el archivo en
$request->file('file')Laravel almacena el archivo con
store()Registro se guarda en la base de datos con la ruta del archivo
Modal se cierra y el formulario se resetea
8.2. Puntos Clave
✅ V-model en FileInput: No usar directamente, usar @input
✅ forceFormData: Siempre true cuando hay archivos
✅ Objeto File: El valor debe ser un objeto File, no un string
✅ Limpiar después de guardar: Resetear el formulario después del éxito
✅ Validación en ambos lados: Frontend y backend
9. Código Completo del Componente FileInput
<!-- resources/js/Components/FileInput.vue --> <template> <div> <input :ref="inputRef" type="file" :accept="accept" :multiple="multiple" :disabled="disabled" :class="[ 'block w-full text-sm text-gray-500 dark:text-gray-400', 'file:mr-4 file:py-2 file:px-4', 'file:rounded-md file:border-0', 'file:text-sm file:font-semibold', 'file:bg-indigo-50 file:text-indigo-700', 'dark:file:bg-indigo-900/20 dark:file:text-indigo-400', 'hover:file:bg-indigo-100 dark:hover:file:bg-indigo-900/30', 'focus:outline-none', props.class, ]" @input="handleInput" /> <!-- Mostrar archivo seleccionado --> <div v-if="modelValue && !multiple" class="mt-2"> <div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-800 rounded-md"> <span class="text-sm text-gray-700 dark:text-gray-300"> {{ modelValue.name }} ({{ formatFileSize(modelValue.size) }}) </span> <button type="button" @click="removeFile" class="text-red-600 hover:text-red-800" > <XMarkIcon class="w-5 h-5" /> </button> </div> </div> <!-- Mensaje de error --> <p v-if="error" class="mt-1 text-sm text-red-600"> {{ error }} </p> </div> </template> <script setup> import { ref } from 'vue'; import XMarkIcon from '@/Components/Icons/XMarkIcon.vue'; const props = defineProps({ modelValue: { type: [File, Array, null], default: null, }, accept: { type: String, default: '', }, multiple: { type: Boolean, default: false, }, disabled: { type: Boolean, default: false, }, class: { type: String, default: '', }, error: { type: String, default: '', }, }); const emit = defineEmits(['update:modelValue', 'error']); const inputRef = ref(null); const handleInput = (event) => { const files = event.target.files; if (!files || files.length === 0) { emit('update:modelValue', props.multiple ? [] : null); return; } if (props.multiple) { emit('update:modelValue', Array.from(files)); } else { emit('update:modelValue', files[0]); } }; const removeFile = () => { emit('update:modelValue', null); if (inputRef.value) { inputRef.value.value = ''; } }; const formatFileSize = (bytes) => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; defineExpose({ reset: () => { if (inputRef.value) { inputRef.value.value = ''; } emit('update:modelValue', props.multiple ? [] : null); }, }); </script>
10. Resumen
En este tutorial hemos aprendido a:
✅ Manejar inputs de tipo file correctamente en Vue.js
✅ Crear un componente FileInput reutilizable
✅ Enviar archivos con Inertia usando FormData
✅ Configurar forceFormData para archivos
✅ Validar archivos en frontend y backend
✅ Almacenar archivos en Laravel Storage
✅ Resetear el formulario después del envío
¡Excelente trabajo! Ahora tienes un sistema completo para subir archivos con Inertia.js
Comentarios
Publicar un comentario