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

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

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

  1. Padre (Chatbots/Show.vue):

    • Define showModal (ref)

    • Define openModal() y closeModal()

    • Pasa :show="showModal" al componente hijo

    • Escucha el evento @close del hijo

  2. Hijo (KnowledgeSourceCreateModal.vue):

    • Recibe show como prop

    • Emite el evento close cuando se debe cerrar

    • El padre captura el evento y ejecuta closeModal()

3.2. Ciclo de Vida del Modal

  1. Apertura: Usuario hace clic en "Gestionar Conocimiento"

  2. Renderizado: showModal = true, el modal se muestra

  3. Interacción: Usuario completa el formulario

  4. Envío: Se envía el formulario a través de Inertia

  5. Éxito: Se cierra el modal automáticamente

  6. Cierre manual: Usuario hace clic fuera o presiona ESC


4. Mejoras Adicionales

4.1. Modal con Animaciones

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

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

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

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

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:

  1. Comunicación: Usar props para pasar datos hacia abajo y emit para eventos hacia arriba

  2. Limpieza: Resetear el formulario al cerrar el modal

  3. Accesibilidad: El modal debe ser accesible (aria, focus trap)

  4. Escalabilidad: Mantener el modal enfocado en una sola tarea

  5. Rendimiento: Usar <Teleport> para evitar problemas de z-index

Próximos Pasos:

  1. Implementar la edición de fuentes de conocimiento

  2. Agregar drag & drop para archivos

  3. Mostrar el progreso de procesamiento

  4. Integrar el sistema de embeddings para búsqueda semántica

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

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