23. Cómo crear ventanas modales
23: Cómo Crear Ventanas Modales
En este tutorial, aprenderemos a crear y gestionar ventanas modales en una aplicación Vue.js con Inertia.js. Implementaremos un modal para el formulario de creación de fuentes de conocimiento, utilizando los componentes base de Laravel Breeze/Jetstream.
1. Preparación: Agregar el Botón que Abre el Modal
Primero, necesitamos un botón en la vista principal que dispare la apertura del modal.
1.1. Modificar la Vista Show del Chatbot
resources/js/Pages/Chatbots/Show.vue
<template> <AppLayout> <template #header> <div class="flex items-center justify-between"> <h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight"> {{ chatbot.name }} </h2> <div class="flex space-x-3"> <!-- Botón para abrir el chat --> <Link :href="route('chatbots.chat', chatbot.id)" class="inline-flex items-center px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition" > <ChatIcon class="w-5 h-5 mr-2" /> Chat </Link> <!-- Botón para abrir el modal de fuentes de conocimiento --> <button @click="openModal" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition" > <BookIcon class="w-5 h-5 mr-2" /> Gestionar Conocimiento </button> </div> </div> </template> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900 dark:text-gray-100"> <!-- Contenido del chatbot --> <div class="space-y-4"> <div> <h3 class="text-lg font-medium">Descripción</h3> <p class="text-gray-600 dark:text-gray-400">{{ chatbot.description || 'Sin descripción' }}</p> </div> <div> <h3 class="text-lg font-medium">Modelo</h3> <p class="text-gray-600 dark:text-gray-400">{{ chatbot.model }}</p> </div> <div> <h3 class="text-lg font-medium">System Prompt</h3> <pre class="bg-gray-100 dark:bg-gray-700 p-4 rounded-md text-sm whitespace-pre-wrap">{{ chatbot.system_prompt || 'No definido' }}</pre> </div> </div> </div> </div> </div> </div> <!-- Modal para crear fuentes de conocimiento --> <KnowledgeSourceCreateModal :show="showModal" @close="closeModal" :chatbot="chatbot" /> </AppLayout> </template> <script setup> import AppLayout from '@/Layouts/AppLayout.vue'; import { Link } from '@inertiajs/vue3'; import { ref } from 'vue'; import ChatIcon from '@/Components/Icons/ChatIcon.vue'; import BookIcon from '@/Components/Icons/BookIcon.vue'; import KnowledgeSourceCreateModal from '@/Components/KnowledgeSources/KnowledgeSourceCreateModal.vue'; const props = defineProps({ chatbot: Object, }); // Estado del modal const showModal = ref(false); // Abrir el modal const openModal = () => { showModal.value = true; }; // Cerrar el modal const closeModal = () => { showModal.value = false; }; </script>
2. Crear el Componente del Modal
2.1. Estructura del Componente
resources/js/Components/KnowledgeSources/KnowledgeSourceCreateModal.vue
<template> <DialogModal :show="show" @close="handleClose"> <!-- Título del modal --> <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> <!-- Contenido del modal --> <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. Puedes subir archivos, agregar URLs de websites o ingresar texto manualmente. </p> <!-- Formulario --> <form @submit.prevent="handleSubmit" class="space-y-4"> <!-- Campo: Nombre --> <div> <label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Nombre de la Fuente * </label> <input id="name" v-model="form.name" type="text" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 focus:ring-indigo-500" placeholder="Ej: Manual de Usuario" required /> <p v-if="form.errors.name" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ form.errors.name }} </p> </div> <!-- Campo: Tipo de fuente --> <div> <label for="type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Tipo de Fuente * </label> <select id="type" v-model="form.type" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 focus:ring-indigo-500" required > <option value="">Selecciona un tipo</option> <option value="pdf">PDF</option> <option value="website">Website</option> <option value="text">Texto Manual</option> <option value="csv">CSV</option> <option value="excel">Excel</option> <option value="json">JSON</option> </select> <p v-if="form.errors.type" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ form.errors.type }} </p> </div> <!-- Campo: URL o Ruta (para PDF, Website, CSV, Excel, JSON) --> <div v-if="form.type && form.type !== 'text'"> <label for="path" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> {{ getPathLabel(form.type) }} * </label> <input id="path" v-model="form.path" type="url" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 focus:ring-indigo-500" :placeholder="getPathPlaceholder(form.type)" /> <p v-if="form.errors.path" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ form.errors.path }} </p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> {{ getPathHelp(form.type) }} </p> </div> <!-- Campo: Contenido (para tipo text) --> <div v-if="form.type === 'text'"> <label for="content" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Contenido * </label> <textarea id="content" v-model="form.content" rows="6" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 focus:ring-indigo-500 font-mono text-sm" placeholder="Ingresa el texto que será utilizado como fuente de conocimiento..." ></textarea> <p v-if="form.errors.content" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ form.errors.content }} </p> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> Este texto será procesado y utilizado para responder preguntas. </p> </div> <!-- Campo: Subir archivo (para tipos que requieren archivo) --> <div v-if="isFileType(form.type)"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Subir Archivo * </label> <div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed rounded-md"> <div class="space-y-1 text-center"> <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true"> <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> </svg> <div class="flex text-sm text-gray-600 dark:text-gray-400"> <label for="file-upload" class="relative cursor-pointer bg-white dark:bg-gray-800 rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"> <span>Sube un archivo</span> <input id="file-upload" type="file" class="sr-only" @change="handleFileUpload" :accept="getAcceptedExtensions(form.type)" /> </label> <p class="pl-1">o arrastra y suelta</p> </div> <p class="text-xs text-gray-500 dark:text-gray-400"> {{ getFileHelp(form.type) }} </p> </div> </div> <p v-if="selectedFile" class="mt-2 text-sm text-gray-600 dark:text-gray-400"> Archivo seleccionado: <span class="font-medium">{{ selectedFile.name }}</span> ({{ formatFileSize(selectedFile.size) }}) </p> </div> </form> </div> </template> <!-- Footer del modal --> <template #footer> <div class="flex justify-end space-x-3"> <button @click="handleClose" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition" > Cancelar </button> <button @click="handleSubmit" :disabled="form.processing" class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 transition disabled:opacity-50 disabled:cursor-not-allowed" > <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>Agregar Fuente</span> </button> </div> </template> </DialogModal> </template> <script setup> import DialogModal from '@/Components/DialogModal.vue'; import BookIcon from '@/Components/Icons/BookIcon.vue'; import { useForm } from '@inertiajs/vue3'; import { ref, watch } from 'vue'; const props = defineProps({ show: { type: Boolean, default: false, }, chatbot: { type: Object, required: true, }, }); const emit = defineEmits(['close']); // Estado del formulario const form = useForm({ name: '', type: '', path: '', content: '', file: null, }); // Archivo seleccionado const selectedFile = ref(null); // Etiquetas y placeholders según el tipo const getPathLabel = (type) => { const labels = { pdf: 'URL del PDF', website: 'URL del Website', csv: 'URL del CSV', excel: 'URL del Excel', json: 'URL del JSON', }; return labels[type] || 'URL'; }; const getPathPlaceholder = (type) => { const placeholders = { pdf: 'https://ejemplo.com/documento.pdf', website: 'https://ejemplo.com/pagina', csv: 'https://ejemplo.com/datos.csv', excel: 'https://ejemplo.com/datos.xlsx', json: 'https://ejemplo.com/datos.json', }; return placeholders[type] || 'Ingresa la URL...'; }; const getPathHelp = (type) => { const helps = { pdf: 'El PDF debe ser accesible públicamente', website: 'La página web será scrapeada para extraer contenido', csv: 'El CSV debe tener una estructura clara para el procesamiento', excel: 'El Excel debe tener una estructura clara para el procesamiento', json: 'El JSON debe tener una estructura clara para el procesamiento', }; return helps[type] || ''; }; const isFileType = (type) => { return ['pdf', 'csv', 'excel', 'json'].includes(type); }; const getAcceptedExtensions = (type) => { const extensions = { pdf: '.pdf', csv: '.csv', excel: '.xlsx,.xls', json: '.json', }; return extensions[type] || '*'; }; const getFileHelp = (type) => { const helps = { pdf: 'PDF de hasta 10MB', csv: 'CSV de hasta 5MB', excel: 'Excel de hasta 5MB', json: 'JSON de hasta 5MB', }; return helps[type] || ''; }; 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'; }; // Manejar la subida de archivos const handleFileUpload = (event) => { const file = event.target.files[0]; if (file) { selectedFile.value = file; form.file = file; // Si el nombre está vacío, usar el nombre del archivo if (!form.name) { form.name = file.name.replace(/\.[^/.]+$/, ''); } } }; // Manejar el envío del formulario const handleSubmit = () => { // Validar según el tipo if (form.type === 'text' && !form.content) { form.errors.content = 'El contenido es requerido'; return; } if (isFileType(form.type) && !form.file && !form.path) { alert('Por favor, sube un archivo o proporciona una URL'); return; } // Enviar el formulario const data = new FormData(); data.append('name', form.name); data.append('type', form.type); if (form.type === 'text') { data.append('content', form.content); } else if (form.file) { data.append('file', form.file); } else if (form.path) { data.append('path', form.path); } form.post(route('knowledge-sources.store', props.chatbot.id), { data, onSuccess: () => { // Cerrar el modal y resetear el formulario handleClose(); form.reset(); selectedFile.value = null; }, onError: (errors) => { console.error('Errores de validación:', errors); }, }); }; // Manejar el cierre del modal const handleClose = () => { emit('close'); }; // Resetear el formulario cuando se cierra el modal watch( () => props.show, (newValue) => { if (!newValue) { form.reset(); selectedFile.value = null; form.errors = {}; } } ); </script>
3. Explicación del Flujo
3.1. Comunicación Padre-Hijo
Padre (Chatbots/Show.vue):
Define
showModal(ref)Define
openModal()ycloseModal()Pasa
:show="showModal"al componente hijoEscucha el evento
@closedel hijo
Hijo (KnowledgeSourceCreateModal.vue):
Recibe
showcomo propEmite el evento
closecuando se debe cerrarEl padre captura el evento y ejecuta
closeModal()
3.2. Ciclo de Vida del Modal
Apertura: Usuario hace clic en "Gestionar Conocimiento"
Renderizado:
showModal = true, el modal se muestraInteracción: Usuario completa el formulario
Envío: Se envía el formulario a través de Inertia
Éxito: Se cierra el modal automáticamente
Cierre manual: Usuario hace clic fuera o presiona ESC
4. Mejoras Adicionales
4.1. Modal con Animaciones
<template> <DialogModal :show="show" @close="handleClose"> <!-- ... --> </DialogModal> </template> <style scoped> /* Animaciones de entrada/salida */ .modal-enter-active, .modal-leave-active { transition: opacity 0.3s ease, transform 0.3s ease; } .modal-enter-from, .modal-leave-to { opacity: 0; transform: scale(0.95); } </style>
4.2. Modal con Confirmación
Para acciones que requieren confirmación:
<template> <DialogModal :show="show" @close="handleClose"> <template #title>Confirmar Eliminación</template> <template #content> <p>¿Estás seguro de que quieres eliminar esta fuente de conocimiento?</p> </template> <template #footer> <button @click="handleClose">Cancelar</button> <button @click="confirmDelete" class="bg-red-600 text-white">Eliminar</button> </template> </DialogModal> </template>
4.3. Modal con Carga Asíncrona
<template> <DialogModal :show="show" @close="handleClose"> <template #content> <div v-if="isLoading" class="flex justify-center py-8"> <svg class="animate-spin h-8 w-8 text-indigo-600" 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> </div> <div v-else> <!-- Contenido del modal --> </div> </template> </DialogModal> </template>
5. Componentes Base de Modal
5.1. Componente Modal Base
resources/js/Components/Modal.vue
<template> <Teleport to="body"> <div v-if="show" class="fixed inset-0 z-50 overflow-y-auto"> <div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <!-- Backdrop --> <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" @click="close" ></div> <!-- Contenido del modal --> <div class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6"> <slot /> </div> </div> </div> </Teleport> </template> <script setup> const props = defineProps({ show: { type: Boolean, default: false, }, }); const emit = defineEmits(['close']); const close = () => { emit('close'); }; </script>
5.2. Componente DialogModal (el que estamos usando)
resources/js/Components/DialogModal.vue
<template> <Modal :show="show" @close="close"> <div class="p-6"> <div class="flex items-start"> <div class="mt-4 text-left"> <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100"> <slot name="title" /> </h3> <div class="mt-2"> <slot name="content" /> </div> </div> </div> <div class="mt-6 flex justify-end space-x-3"> <slot name="footer" /> </div> </div> </Modal> </template> <script setup> import Modal from './Modal.vue'; const props = defineProps({ show: { type: Boolean, default: false, }, }); const emit = defineEmits(['close']); const close = () => { emit('close'); }; </script>
6. Resumen y Mejores Prácticas
Lo que hemos logrado:
✅ Creación de un modal reutilizable con DialogModal
✅ Comunicación padre-hijo mediante props y eventos
✅ Formulario completo para crear fuentes de conocimiento
✅ Validación en tiempo real
✅ Manejo de diferentes tipos de fuentes
✅ Soporte para subida de archivos
✅ Cierre del modal al hacer clic fuera o presionar ESC
Mejores Prácticas:
Comunicación: Usar
propspara pasar datos hacia abajo yemitpara eventos hacia arribaLimpieza: Resetear el formulario al cerrar el modal
Accesibilidad: El modal debe ser accesible (aria, focus trap)
Escalabilidad: Mantener el modal enfocado en una sola tarea
Rendimiento: Usar
<Teleport>para evitar problemas de z-index
Próximos Pasos:
Implementar la edición de fuentes de conocimiento
Agregar drag & drop para archivos
Mostrar el progreso de procesamiento
Integrar el sistema de embeddings para búsqueda semántica
Crear modales de confirmación para acciones destructivas
¡Excelente trabajo! Ahora tienes un sistema completo de modales para gestionar las fuentes de conocimiento de tus chatbots.
Comentarios
Publicar un comentario