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

vue
<!-- ❌ 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

vue
<!-- ✅ 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

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

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

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
<?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

javascript
// 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

vue
<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

vue
<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

  1. Usuario selecciona un archivo

  2. FileInput captura el evento @input y obtiene event.target.files[0]

  3. Vue actualiza form.pdf_file con el objeto File

  4. Usuario hace clic en "Guardar"

  5. Modal crea un FormData y agrega el archivo

  6. Inertia envía la petición con forceFormData: true

  7. Backend recibe el archivo en $request->file('file')

  8. Laravel almacena el archivo con store()

  9. Registro se guarda en la base de datos con la ruta del archivo

  10. 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

vue
<!-- 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

Entradas más populares de este blog

1. Introducción al curso de Chatbots personalizados con Laravel & OpenAI

2. Prerequisitos para el Curso de Chatbots con Laravel y OpenAI

4. Herramientas de Calidad