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

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)

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

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

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

javascript
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

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

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

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:

  1. Validación en ambos lados: Validar en frontend (UX) y backend (seguridad)

  2. Constantes centralizadas: Mantener las opciones en un archivo de constantes

  3. Valores por defecto: Siempre establecer un valor predeterminado

  4. Placeholders: Usar placeholders para guiar al usuario

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

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

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