21. Componente Select
21: Componente Select
En este tutorial, crearemos un componente Select reutilizable para nuestra aplicación, que permitirá a los usuarios seleccionar modelos de IA de una lista predefinida. Esto evitará que los usuarios ingresen manualmente valores incorrectos y mejorará la experiencia de usuario.
1. Problema: Entrada Manual de Modelos de IA
Actualmente, los usuarios pueden escribir manualmente el modelo de IA que desean utilizar. Esto puede llevar a errores, inconsistencias y problemas de validación. Necesitamos un componente Select que proporcione una lista controlada de opciones.
2. Crear el Componente Select
2.1. Estructura del Componente
Basado en el componente TextInput, crearemos un SelectInput reutilizable.
resources/js/Components/SelectInput.vue
<template> <select :ref="selectRef" :value="modelValue" @change="$emit('update:modelValue', $event.target.value)" v-bind="$attrs" :class="[ 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300', 'focus:border-indigo-500 dark:focus:border-indigo-600', 'focus:ring-indigo-500 dark:focus:ring-indigo-600', 'rounded-md shadow-sm', props.class, ]" > <!-- Placeholder como opción vacía --> <option v-if="placeholder" value="" disabled :selected="!modelValue" class="text-gray-400" > {{ placeholder }} </option> <!-- Opciones dinámicas --> <option v-for="(option, index) in options" :key="`${option.value}-${index}`" :value="option.value" :selected="option.value === modelValue" > {{ option.label }} </option> </select> </template> <script setup> import { ref, onMounted, nextTick } from 'vue'; const props = defineProps({ modelValue: { type: [String, Number], default: '', }, class: { type: String, default: '', }, options: { type: Array, required: true, validator: (value) => { return value.every(item => item.hasOwnProperty('value') && item.hasOwnProperty('label') ); } }, placeholder: { type: String, default: 'Selecciona una opción', }, focus: { type: Boolean, default: false, }, }); const emit = defineEmits(['update:modelValue']); const selectRef = ref(null); // Exponer el método focus al componente padre defineExpose({ focus: () => { if (selectRef.value) { selectRef.value.focus(); } }, }); // Auto-focus cuando el componente se monta onMounted(() => { if (props.focus && selectRef.value) { nextTick(() => { selectRef.value.focus(); }); } }); </script>
3. Mejorar el Componente Select con Validación
Agregamos validación de opciones y un indicador de error:
resources/js/Components/SelectInput.vue (Versión mejorada)
<template> <div> <select :ref="setSelectRef" :value="modelValue" @change="handleChange" v-bind="$attrs" :class="[ 'w-full rounded-md shadow-sm', 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300', 'focus:border-indigo-500 dark:focus:border-indigo-600', 'focus:ring-indigo-500 dark:focus:ring-indigo-600', { 'border-red-500 dark:border-red-500 focus:border-red-500 focus:ring-red-500': hasError, 'border-gray-300 dark:border-gray-700': !hasError, }, props.class, ]" > <!-- Placeholder --> <option v-if="placeholder" value="" disabled :selected="!modelValue" class="text-gray-400 dark:text-gray-500" > {{ placeholder }} </option> <!-- Opciones agrupadas opcionalmente --> <template v-if="groupedOptions.length"> <optgroup v-for="group in groupedOptions" :key="group.label" :label="group.label" > <option v-for="option in group.options" :key="`${option.value}`" :value="option.value" :selected="option.value === modelValue" > {{ option.label }} </option> </optgroup> </template> <!-- Opciones sin agrupar --> <template v-else> <option v-for="option in normalizedOptions" :key="`${option.value}`" :value="option.value" :selected="option.value === modelValue" > {{ option.label }} </option> </template> </select> <!-- Mensaje de error --> <p v-if="hasError && errorMessage" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ errorMessage }} </p> </div> </template> <script setup> import { ref, computed, onMounted, nextTick, watch } from 'vue'; const props = defineProps({ modelValue: { type: [String, Number], default: '', }, class: { type: String, default: '', }, options: { type: Array, required: true, validator: (value) => { if (!Array.isArray(value)) return false; return value.every(item => { // Soporte para formato simple: ['opcion1', 'opcion2'] if (typeof item === 'string' || typeof item === 'number') { return true; } // Soporte para formato objeto: { value: 'val', label: 'Label' } if (typeof item === 'object') { return item.hasOwnProperty('value') && item.hasOwnProperty('label'); } return false; }); } }, placeholder: { type: String, default: 'Selecciona una opción', }, focus: { type: Boolean, default: false, }, error: { type: [String, Boolean], default: false, }, }); const emit = defineEmits(['update:modelValue', 'change']); const selectRef = ref(null); // Normalizar opciones (soporte para varios formatos) const normalizedOptions = computed(() => { return props.options.map(option => { if (typeof option === 'string' || typeof option === 'number') { return { value: option, label: String(option) }; } return option; }); }); // Detectar si hay opciones agrupadas const groupedOptions = computed(() => { const firstOption = normalizedOptions.value[0]; if (firstOption && firstOption.hasOwnProperty('options')) { return normalizedOptions.value; } return []; }); // Estado de error const hasError = computed(() => !!props.error); const errorMessage = computed(() => { if (typeof props.error === 'string') { return props.error; } return null; }); // Manejar cambio de valor const handleChange = (event) => { const value = event.target.value; emit('update:modelValue', value); emit('change', value); }; // Establecer referencia y validar opción const setSelectRef = (el) => { selectRef.value = el; if (el) { // Validar si el valor actual es una opción válida validateCurrentValue(); } }; // Validar que el valor actual sea una opción válida const validateCurrentValue = () => { if (!selectRef.value || !props.modelValue) return; const validValues = normalizedOptions.value.map(opt => String(opt.value)); const currentValue = String(props.modelValue); // Si el valor no está en las opciones, resetear if (!validValues.includes(currentValue)) { // No emitir para evitar bucles infinitos selectRef.value.value = ''; } }; // Watcher para cuando cambian las opciones watch( () => props.options, () => { validateCurrentValue(); }, { deep: true } ); // Exponer métodos defineExpose({ focus: () => { if (selectRef.value) { selectRef.value.focus(); } }, validate: () => { validateCurrentValue(); return !hasError.value; } }); // Auto-focus onMounted(() => { if (props.focus && selectRef.value) { nextTick(() => { selectRef.value.focus(); }); } }); </script>
4. Integrar el Componente Select en el Formulario
4.1. Definir las Opciones de Modelos de IA
resources/js/constants/models.js (Archivo de constantes)
// Modelos de OpenAI disponibles export const AI_MODELS = [ { value: 'gpt-4', label: 'GPT-4' }, { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' }, { value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo' }, { value: 'gpt-3.5-turbo-16k', label: 'GPT-3.5 Turbo 16K' }, { value: 'gpt-4o', label: 'GPT-4o' }, { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, { value: 'gpt-4-1106-preview', label: 'GPT-4 1106 Preview' }, { value: 'gpt-4-vision-preview', label: 'GPT-4 Vision Preview' }, ]; // Opciones agrupadas por categoría export const AI_MODELS_GROUPED = [ { label: 'Modelos Avanzados', options: [ { value: 'gpt-4', label: 'GPT-4' }, { value: 'gpt-4-turbo', label: 'GPT-4 Turbo' }, { value: 'gpt-4o', label: 'GPT-4o' }, { value: 'gpt-4o-mini', label: 'GPT-4o Mini' }, ] }, { label: 'Modelos Económicos', options: [ { value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo' }, { value: 'gpt-3.5-turbo-16k', label: 'GPT-3.5 Turbo 16K' }, ] }, { label: 'Modelos en Preview', options: [ { value: 'gpt-4-1106-preview', label: 'GPT-4 1106 Preview' }, { value: 'gpt-4-vision-preview', label: 'GPT-4 Vision Preview' }, ] } ]; // Valores por defecto export const DEFAULT_MODEL = 'gpt-4o';
4.2. Actualizar el Formulario de Chatbot
resources/js/Components/ChatbotForm.vue
<template> <form @submit.prevent="handleSubmit" class="space-y-6"> <!-- Campo: Nombre --> <div> <label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Nombre del Chatbot * </label> <TextInput id="name" v-model="form.name" type="text" class="w-full" placeholder="Ej: Asistente de Ventas" :error="form.errors.name" :focus="true" /> <p v-if="form.errors.name" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ form.errors.name }} </p> </div> <!-- Campo: Descripción --> <div> <label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Descripción </label> <Textarea id="description" v-model="form.description" class="w-full" rows="3" placeholder="Describe el propósito de este chatbot..." :autosize="true" /> <p v-if="form.errors.description" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ form.errors.description }} </p> </div> <!-- Campo: Modelo de IA (Nuevo Select) --> <div> <label for="model" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> Modelo de IA * </label> <SelectInput id="model" v-model="form.model" :options="models" placeholder="Selecciona un modelo de IA" class="w-full" :error="form.errors.model" :focus="false" /> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> Selecciona el modelo de IA que utilizará tu chatbot </p> <p v-if="form.errors.model" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ form.errors.model }} </p> </div> <!-- Campo: System Prompt --> <div> <label for="system_prompt" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"> System Prompt </label> <Textarea id="system_prompt" v-model="form.system_prompt" class="w-full font-mono" rows="5" placeholder="Define el comportamiento del chatbot..." :autosize="true" /> <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"> Define cómo el chatbot debe comportarse y responder a los usuarios </p> <p v-if="form.errors.system_prompt" class="mt-1 text-sm text-red-600 dark:text-red-400"> {{ form.errors.system_prompt }} </p> </div> <!-- Botones --> <div class="flex items-center justify-end space-x-3 pt-4 border-t border-gray-200 dark:border-gray-700"> <Link :href="cancelRoute || route('chatbots.index')" 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 </Link> <button type="submit" :disabled="form.processing" class="px-6 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-md hover:bg-blue-700 dark:hover:bg-blue-600 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> {{ submitText || 'Guardar' }} </span> </button> </div> </form> </template> <script setup> import { Link } from '@inertiajs/vue3'; import TextInput from '@/Components/TextInput.vue'; import Textarea from '@/Components/Textarea.vue'; import SelectInput from '@/Components/SelectInput.vue'; import { AI_MODELS } from '@/constants/models'; const props = defineProps({ form: { type: Object, required: true }, submitText: { type: String, default: 'Guardar' }, cancelRoute: { type: String, default: null }, handleSubmit: { type: Function, required: true }, models: { type: Array, default: () => AI_MODELS } }); </script>
4.3. Actualizar el Archivo de Formulario
resources/js/forms/chatbot.js
import { useForm } from '@inertiajs/vue3'; import { DEFAULT_MODEL } from '@/constants/models'; // Opciones por defecto const getDefaultOptions = (customOptions = {}) => { const defaultOptions = { preserveScroll: true, preserveState: true, onSuccess: () => {}, onError: () => {}, onFinish: () => {}, }; return { ...defaultOptions, ...customOptions }; }; // Crear el formulario con el campo model export const createForm = (chatbot = {}) => { return useForm({ name: chatbot.name || '', description: chatbot.description || '', model: chatbot.model || DEFAULT_MODEL, // Nuevo campo con valor por defecto system_prompt: chatbot.system_prompt || '', }); }; // Enviar para crear export const store = (form, options = {}) => { const finalOptions = getDefaultOptions(options); return form.post(route('chatbots.store'), finalOptions); }; // Enviar para actualizar export const update = (form, chatbotId, options = {}) => { const finalOptions = getDefaultOptions(options); return form.put(route('chatbots.update', chatbotId), finalOptions); };
5. Actualizar Backend
5.1. Actualizar la Validación
app/Http/Requests/ChatbotRequest.php
namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class ChatbotRequest extends FormRequest { // Modelos de IA permitidos (debe coincidir con el frontend) private const ALLOWED_MODELS = [ 'gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo', 'gpt-3.5-turbo-16k', 'gpt-4o', 'gpt-4o-mini', 'gpt-4-1106-preview', 'gpt-4-vision-preview', ]; public function authorize() { return true; } public function rules() { return [ 'name' => 'required|string|max:255|unique:chatbots,name,' . $this->chatbot?->id, 'description' => 'nullable|string|max:1000', 'model' => ['required', 'string', Rule::in(self::ALLOWED_MODELS)], 'system_prompt' => 'nullable|string|max:5000', ]; } public function messages() { return [ 'name.required' => 'El nombre del chatbot es obligatorio', 'name.unique' => 'Ya existe un chatbot con este nombre', 'name.max' => 'El nombre no puede tener más de 255 caracteres', 'description.max' => 'La descripción no puede tener más de 1000 caracteres', 'model.required' => 'Debes seleccionar un modelo de IA', 'model.in' => 'El modelo seleccionado no es válido', 'system_prompt.max' => 'El system prompt no puede tener más de 5000 caracteres', ]; } }
5.2. Actualizar la Migración
database/migrations/xxxx_xx_xx_add_model_to_chatbots_table.php
use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up() { Schema::table('chatbots', function (Blueprint $table) { $table->string('model')->default('gpt-4o')->after('description'); }); } public function down() { Schema::table('chatbots', function (Blueprint $table) { $table->dropColumn('model'); }); } };
6. Componente Select con Búsqueda (Opcional)
Si necesitas un select con búsqueda para muchas opciones:
resources/js/Components/SearchableSelect.vue
<template> <div class="relative"> <input type="text" :value="searchTerm" @input="handleSearch" @focus="isOpen = true" @blur="handleBlur" placeholder="Buscar..." class="w-full rounded-md border border-gray-300 px-3 py-2 focus:border-indigo-500 focus:ring-indigo-500" /> <div v-show="isOpen && filteredOptions.length > 0" class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto" > <div v-for="option in filteredOptions" :key="option.value" @mousedown.prevent="selectOption(option)" class="px-4 py-2 hover:bg-indigo-50 dark:hover:bg-gray-700 cursor-pointer" :class="{ 'bg-indigo-100 dark:bg-gray-700': selectedValue === option.value }" > {{ option.label }} </div> </div> <div v-if="isOpen && filteredOptions.length === 0" class="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 rounded-md shadow-lg p-4 text-center text-gray-500"> No hay resultados </div> </div> </template> <script setup> import { ref, computed } from 'vue'; const props = defineProps({ options: { type: Array, required: true }, modelValue: { type: [String, Number], default: '' } }); const emit = defineEmits(['update:modelValue']); const searchTerm = ref(''); const isOpen = ref(false); const selectedValue = ref(props.modelValue); const filteredOptions = computed(() => { if (!searchTerm.value) return props.options; const term = searchTerm.value.toLowerCase(); return props.options.filter(option => option.label.toLowerCase().includes(term) || String(option.value).toLowerCase().includes(term) ); }); const handleSearch = (event) => { searchTerm.value = event.target.value; isOpen.value = true; }; const selectOption = (option) => { selectedValue.value = option.value; searchTerm.value = option.label; emit('update:modelValue', option.value); isOpen.value = false; }; const handleBlur = () => { setTimeout(() => { isOpen.value = false; }, 200); }; </script>
7. Resumen y Mejores Prácticas
Lo que hemos logrado:
✅ Componente SelectInput reutilizable y configurable
✅ Validación de opciones en el frontend y backend
✅ Soporte para placeholders y opciones agrupadas
✅ Integración con el formulario de chatbots
✅ Actualización de la base de datos y validación
Mejores Prácticas:
Validación en ambos lados: Validar en frontend (UX) y backend (seguridad)
Constantes centralizadas: Mantener las opciones en un archivo de constantes
Valores por defecto: Siempre establecer un valor predeterminado
Placeholders: Usar placeholders para guiar al usuario
Accesibilidad: Asegurar etiquetas y atributos ARIA cuando sea posible
Características del Componente:
✨ Soporte para opciones simples (array de strings)
✨ Soporte para opciones con valor y etiqueta
✨ Soporte para opciones agrupadas (optgroup)
✨ Placeholder configurable
✨ Auto-focus opcional
✨ Validación y mensajes de error
✨ Clases personalizables
✨ Exposición de métodos (focus, validate)
Próximos Pasos:
Integrar con la API de OpenAI para obtener modelos dinámicamente
Agregar búsqueda en el select para muchas opciones
Crear un componente Select múltiple
Implementar select con iconos o imágenes
8. Código Final del Componente Select
Aquí tienes el código completo del componente para copiar y usar:
resources/js/Components/SelectInput.vue
<template> <select :ref="setSelectRef" :value="modelValue" @change="handleChange" v-bind="$attrs" :class="[ 'w-full rounded-md shadow-sm', 'border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300', 'focus:border-indigo-500 dark:focus:border-indigo-600', 'focus:ring-indigo-500 dark:focus:ring-indigo-600', { 'border-red-500 dark:border-red-500 focus:border-red-500 focus:ring-red-500': hasError, 'border-gray-300 dark:border-gray-700': !hasError, }, props.class, ]" > <option v-if="placeholder" value="" disabled :selected="!modelValue" class="text-gray-400 dark:text-gray-500" > {{ placeholder }} </option> <option v-for="option in normalizedOptions" :key="String(option.value)" :value="option.value" :selected="String(option.value) === String(modelValue)" > {{ option.label }} </option> </select> </template> <script setup> import { ref, computed, onMounted, nextTick } from 'vue'; const props = defineProps({ modelValue: { type: [String, Number], default: '', }, class: { type: String, default: '', }, options: { type: Array, required: true, }, placeholder: { type: String, default: 'Selecciona una opción', }, focus: { type: Boolean, default: false, }, error: { type: [String, Boolean], default: false, }, }); const emit = defineEmits(['update:modelValue']); const selectRef = ref(null); const normalizedOptions = computed(() => { return props.options.map(option => { if (typeof option === 'string' || typeof option === 'number') { return { value: option, label: String(option) }; } return option; }); }); const hasError = computed(() => !!props.error); const handleChange = (event) => { emit('update:modelValue', event.target.value); }; const setSelectRef = (el) => { selectRef.value = el; }; defineExpose({ focus: () => { if (selectRef.value) { selectRef.value.focus(); } }, }); onMounted(() => { if (props.focus && selectRef.value) { nextTick(() => { selectRef.value.focus(); }); } }); </script>
¡Componente Select listo para usar
Comentarios
Publicar un comentario