Mejores Prácticas de Desarrollo

Guía de estándares de código y prácticas recomendadas para el desarrollo frontend en RedCollege

Introducción

Esta guía establece las mejores prácticas y estándares de código para el desarrollo frontend en RedCollege. Seguir estas recomendaciones nos ayudará a mantener un código consistente, legible y fácil de mantener, mejorando la eficiencia del equipo y la calidad del producto.

Nuestro stack tecnológico principal incluye:

  • Nuxt 3: Framework de Vue.js para desarrollo universal
  • Pug: Preprocesador HTML para plantillas más concisas
  • TypeScript: Tipado estático para JavaScript
  • Tailwind CSS: Framework de utilidades CSS
  • @redcollege/ui-nuxt-module: Módulo Nuxt centralizado con componentes personalizados basados en shadcn/vue

Estructura de Código

Organización de Archivos

pages/
  ├── index.vue
  └── [establecimientoid]/
      └── planificaciones/
          ├── index.vue
          └── [id].vue
components/
  ├── ui/         # Componentes básicos (botones, inputs, etc.)
  └── domain/     # Componentes específicos del dominio
composables/
  └── use[NombreFuncionalidad].ts
layouts/
  └── default.vue

Estructura de Componentes

Cada componente debe seguir este orden:

  1. Imports
  2. Props y Emits
  3. Variables y Referencias
  4. Computados
  5. Funciones
  6. Hooks de ciclo de vida
  7. Watchers

TypeScript

Importación de Tipos

✅ Correcto:

import type { IUsuario } from '@redcollege/ui-nuxt-module/runtime/models/Auth/usuario'
import type { IPlanificacion } from '@redcollege/ui-nuxt-module/runtime/models'

❌ Incorrecto:

import { IUsuario } from '@redcollege/ui-nuxt-module/runtime/models/Auth/usuario'

Definición de Props con Interface

✅ Correcto:

interface Props {
  planificacion: IPlanificacion
  isEditable?: boolean
}

const props = defineProps<Props>()

❌ Incorrecto:

const props = defineProps<{
  planificacion: IPlanificacion,
  isEditable?: boolean
}>()

Arrow Functions

Usar siempre arrow functions para mantener consistencia y evitar problemas con el contexto this.

✅ Correcto:

const handleClick = (id: number): void => {
  console.log(`Elemento ${id} clickeado`)
}

const calcularTotal = (items: IItem[]): number => {
  return items.reduce((total, item) => total + item.precio, 0)
}

❌ Incorrecto:

function handleClick(id) {
  console.log(`Elemento ${id} clickeado`)
}

Type Assertions

Evitar el uso de as cuando sea posible. Preferir tipos genéricos.

✅ Correcto:

const resultado = ref<IResultado | null>(null)

❌ Incorrecto:

const resultado = ref(null as IResultado | null)

Vue y Nuxt

Composables

Crear composables para lógica reutilizable con nombres descriptivos que empiecen con "use".

✅ Correcto:

// composables/useEstablecimiento.ts
export const useEstablecimiento = () => {
  const route = useRoute()
  const establecimientoId = computed(() => Number(route.params.establecimientoid))
  
  const fetchData = async (): Promise<IEstablecimiento | null> => {
    // Lógica para obtener datos
  }
  
  return {
    establecimientoId,
    fetchData
  }
}

Computed Properties

Usar propiedades computadas para derivar valores en lugar de métodos.

✅ Correcto:

const nombreCompleto = computed((): string => {
  return `${usuario.nombre} ${usuario.apellido}`
})

❌ Incorrecto:

const getNombreCompleto = (): string => {
  return `${usuario.nombre} ${usuario.apellido}`
}

Async/Await con Promise.all

Preferir async/await en lugar de promises encadenadas y utilizar Promise.all para múltiples peticiones simultáneas.

✅ Correcto:

const { data, execute } = await useAsyncData(async () => {
  const [nivelesSige, unidad, planificacion, contenido] = await Promise.all([
    $apis.establecimiento.curso.getGradosSige(Number(establecimientoid), Number(props.planificacion.periodo)),
    $apis.planificaciones.unidades.getUnidadById(Number(unidadid)),
    $apis.planificaciones.planificacion.getPlanificacionById(Number(props.planificacion.id)),
    $apis.planificaciones.contenidos.obtenerContenidoPorId(Number(contenidoid))
  ])
  
  return { nivelesSige, unidad, planificacion, contenido }
})

❌ Incorrecto:

const { data } = await useAsyncData(async () => {
  const nivelesSige = await $apis.establecimiento.curso.getGradosSige(Number(establecimientoid))
  const unidad = await $apis.planificaciones.unidades.getUnidadById(Number(unidadid))
  const planificacion = await $apis.planificaciones.planificacion.getPlanificacionById(Number(planificacionid))
  
  return { nivelesSige, unidad, planificacion }
})

Pug

Indentación

Mantener una indentación consistente de 2 espacios.

✅ Correcto:

div.container
  h1.title Planificaciones
  .card-list
    CardPlanificacion(
      v-for="plan in planificaciones"
      :key="plan.id"
      :planificacion="plan"
      @click="seleccionarPlan(plan.id)"
    )

Atributos

Colocar cada atributo en una nueva línea cuando hay más de 5.

✅ Correcto:

CommandItem(
  v-for="curso in listaCursosFiltrada"
  :key="curso.id"
  :value="curso"
  @select.prevent="() => toggleCurso(curso)"
  class="cursor-pointer"
) {{ curso.nombre }}

❌ Incorrecto:

CommandItem(v-for="curso in listaCursosFiltrada" :key="curso.id" :value="curso" @select.prevent="() => toggleCurso(curso)" class="cursor-pointer") {{ curso.nombre }}

v-for con v-if

Nunca usar v-for y v-if en el mismo elemento. Usar un elemento contenedor o computed property.

✅ Correcto:

template(v-for="item in items" :key="item.id")
  div(v-if="item.visible") {{ item.nombre }}

Clases Tailwind

Encadenar clases de Tailwind con sintaxis de puntos cuando no contienen caracteres especiales.

✅ Correcto:

div.flex.flex-col.gap-4.p-6.rounded-lg
  h2.text-xl.font-bold Título
  p.text-gray-500 Descripción

Solo usar el atributo class cuando hay caracteres especiales o variantes:

div(class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")
button(class="bg-primary hover:bg-primary/80")
span(class="w-[200px] h-[50px]")

Tailwind CSS

Organización de Clases

Organizar las clases siguiendo un orden lógico:

  1. Layout (display, position)
  2. Spacing (margin, padding)
  3. Sizing (width, height)
  4. Typography (font, text)
  5. Visual (colors, borders)
  6. Interactions (hover, transitions)

✅ Correcto:

div.flex.flex-col.p-4.gap-2.w-full.text-sm.font-medium.bg-white.rounded-lg.shadow(class="hover:shadow-md")

Breakpoints

Usar el enfoque mobile-first de Tailwind.

✅ Correcto:

div(class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4")

Variables de Color

Usar clases semánticas de Tailwind en lugar de valores hexadecimales directos.

✅ Correcto:

button.bg-primary.text-primary-foreground(class="hover:bg-primary/80")

❌ Incorrecto:

button(class="bg-[#3B82F6] hover:bg-[#60A5FA] text-white")

APIs y Estado

Manejo de APIs

Utilizar el objeto $apis del módulo nuxt para todas las llamadas a API.

✅ Correcto:

const { data: usuarios } = await useAsyncData('usuarios', () => {
  return $apis.usuarios.getAll(filtros)
})

Carga de Datos en Nuxt

Siempre utilizar useAsyncData para la carga inicial de datos en lugar de onMounted o fetch.

✅ Correcto:

const { data: planificaciones, pending, refresh, error } = await useAsyncData(
  'planificaciones',
  () => $apis.planificaciones.planificacion.getPlanificacionesByEstablecimiento(establecimientoId)
)

// Si necesitas transformar los datos después de la carga
const planificacionesOrdenadas = computed(() => {
  if (!planificaciones.value) return []
  return [...planificaciones.value].sort((a, b) => new Date(b.fechaCreacion) - new Date(a.fechaCreacion))
})

❌ Incorrecto:

const planificaciones = ref<IPlanificacion[]>([])
const isLoading = ref<boolean>(true)

const cargarPlanificaciones = async (): Promise<void> => {
  try {
    isLoading.value = true
    planificaciones.value = await $apis.planificaciones.planificacion.getPlanificacionesByEstablecimiento(establecimientoId)
  } catch (error) {
    // Manejo de error
  } finally {
    isLoading.value = false
  }
}

onMounted(() => {
  cargarPlanificaciones()
})

Manejo de Formularios

Validación con Zod

Utilizar el esquema de validación con zod para formularios.

✅ Correcto:

const formSchema = toTypedSchema(z.object({
  titulo: z.string({
    required_error: "El nombre es requerido"
  }).trim().min(1, "El nombre no puede estar vacío"),
  periodo: z.string({
    invalid_type_error: "El año es requerido"
  }),
  asignaturas: z.array(z.string()).min(1, {
    message: "Debe seleccionar al menos una asignatura"
  }).default([])
}))

const form = useForm({
  validationSchema: formSchema,
  initialValues
})

Manejo de Eventos de Formulario

Usar funciones dedicadas para el manejo de eventos de formulario.

✅ Correcto:

const onSubmit = form.handleSubmit(async (values) => {
  try {
    if (isEdit.value) {
      await actualizarRegistro(values)
    } else {
      await crearRegistro(values)
    }
    
    useNotification().toast({
      title: 'Todo correcto',
      description: 'Operación exitosa'
    })
  } catch (e) {
    useNotification().toast({
      title: 'Algo ha salido mal',
      description: 'No se ha podido completar la operación',
      variant: 'destructive'
    })
  }
})

Ejemplos Completos

Componente de Formulario

<script setup lang="ts">
import type { ICursoAsignatura, ICurso, IPlanificacion } from '@redcollege/ui-nuxt-module/runtime/models'
import type { IUsuario } from '@redcollege/ui-nuxt-module/runtime/models/Auth/usuario'

// Props y emits
interface Props {
  planificacion?: IPlanificacion
}

const props = defineProps<Props>()
const emit = defineEmits(['update', 'nextStep', 'refreshDataTable'])
const { $apis } = useNuxtApp()
const establecimientoId = Number(useRoute()?.params?.establecimientoid)

// Variables
const isEdit = ref<boolean>(!!props.planificacion)
const loaderRef = ref<boolean>(false)
const asignaturasSeleccionadas = ref<string[]>([])
const listaAsignaturas = ref<ICursoAsignatura[]>([])

// Formulario
const formSchema = toTypedSchema(z.object({
  titulo: z.string({
    required_error: "El nombre de la planificación es requerido"
  }).trim().min(1, "El nombre de la planificación no puede estar vacío"),
  periodo: z.string({
    invalid_type_error: "El año es requerido"
  }),
  cursoId: z.number({
    required_error: "Debes elegir un curso"
  }),
  asignaturas: z.array(z.string()).min(1, {
    message: "Debe seleccionar al menos una asignatura"
  }).default([])
}))

const form = useForm({
  validationSchema: formSchema,
  initialValues: isEdit.value ? {
    titulo: props.planificacion?.titulo,
    periodo: props.planificacion?.periodo,
    cursoId: props.planificacion?.curso.id,
    asignaturas: props.planificacion?.asignaturas.map(a => a.id.toString()) || []
  } : {
    asignaturas: []
  }
})

// Async data
const { data } = await useAsyncData(async () => {
  const [periodos, cursos] = await Promise.all([
    $apis.establecimiento.periodoEscolar.getByEstablecimiento(establecimientoId),
    form.values.periodo ? $apis.establecimiento.establecimiento.getAllCursosByEstablecimiento(
      establecimientoId, 
      Number(form.values.periodo), 
      false
    ) : []
  ])
  
  return { periodos, cursos }
})

// Computeds
const asignaturasOptions = computed(() => {
  return listaAsignaturas.value?.map((a: ICursoAsignatura) => ({
    label: a.alias || a.asignaturaSige?.nombre || a.asignaturaCurricular?.nombre || '',
    value: `${a.id}`
  }))
})

// Functions
const cargarAsignaturas = async (): Promise<void> => {
  if (!form.values.cursoId) return
  
  try {
    const response = await $apis.establecimiento.establecimiento.getAsignaturasByCurso(form.values.cursoId)
    if (response) {
      listaAsignaturas.value = response
    }
  } catch (error) {
    useNotification().toast({
      title: 'Error',
      description: 'No se pudieron cargar las asignaturas',
      variant: 'destructive'
    })
  }
}

const onSubmit = form.handleSubmit(async (values) => {
  try {
    loaderRef.value = true
    
    const data = {
      ...values,
      establecimientoId,
      estadoId: isEdit.value ? props.planificacion?.estadoId : 1
    }
    
    if (isEdit.value) {
      await $apis.planificaciones.planificacion.updatePlanificacion({
        id: props.planificacion!.id,
        data
      })
      emit('refreshDataTable')
    } else {
      const nuevaPlanificacion = await $apis.planificaciones.planificacion.savePlanificacion(data)
      emit('nextStep', nuevaPlanificacion)
    }
    
    useNotification().toast({
      title: 'Todo correcto',
      description: `Planificación ${isEdit.value ? 'actualizada' : 'creada'} exitosamente`
    })
  } catch (error) {
    useNotification().toast({
      title: 'Algo ha salido mal',
      description: `No se ha podido ${isEdit.value ? 'actualizar' : 'crear'} la planificación`,
      variant: 'destructive'
    })
  } finally {
    loaderRef.value = false
  }
})

// Watchers
watch(() => form.values.cursoId, async () => {
  await cargarAsignaturas()
  
  // Si estamos editando, mantener las asignaturas seleccionadas
  if (isEdit.value && props.planificacion?.asignaturas) {
    form.setFieldValue('asignaturas', props.planificacion.asignaturas.map(a => a.id.toString()))
    asignaturasSeleccionadas.value = props.planificacion.asignaturas.map(a => a.id.toString())
  } else {
    form.setFieldValue('asignaturas', [])
    asignaturasSeleccionadas.value = []
  }
}, { immediate: true })
</script>

<template lang="pug">
form.grid.gap-5.grid-cols-5(@submit.prevent="onSubmit")
  .col-span-5
    FormField(name="titulo" v-slot="{ componentField }")
      FormItem
        FormLabel Nombre
        FormControl
          Input(
            type="text"
            placeholder="Nombre de la planificación"
            v-bind="componentField"
          )
        FormMessage
  
  .col-span-3
    FormField(name="periodo" v-slot="{ componentField }")
      FormItem
        FormLabel Año escolar
        Select(v-bind="componentField")
          FormControl
            SelectTrigger
              SelectValue(placeholder="Seleccionar año")
          SelectContent
            SelectGroup
              SelectItem(
                v-for="periodo in data.periodos"
                :key="periodo.id"
                :value="`${periodo.periodo}`"
              ) {{ periodo.periodo }}
        FormMessage
  
  .col-span-5
    FormField(name="cursoId" v-slot="{ componentField }")
      FormItem
        FormLabel Curso
        FormControl
          Select(v-bind="componentField")
            SelectTrigger
              SelectValue(placeholder="Seleccionar curso")
            SelectContent
              SelectGroup
                SelectItem(
                  v-for="curso in data.cursos"
                  :key="curso.id"
                  :value="curso.id"
                ) {{ curso.nombre || `${curso.sige?.descripcion} ${curso.seccion}` }}
        FormMessage
  
  .col-span-5
    FormField(name="asignaturas" v-slot="{ componentField }")
      FormItem
        FormLabel Asignaturas
        FormControl
          MultiSelect(
            v-model="asignaturasSeleccionadas"
            placeholder="Elegir asignatura(s)"
            :max-count="3"
            @select="(value) => form.setFieldValue('asignaturas', value)"
            :options="asignaturasOptions"
          )
        FormMessage
  
  .col-span-5
    Button.w-full.flex.gap-2(type="submit")
      Loader.w-4.h-4.animate-spin(v-if="loaderRef")
      span {{ isEdit ? "Actualizar" : "Guardar" }} Planificación
</template>

Composable para Filtrado de Datos

// composables/useFilteredData.ts
import type { IItem } from '@redcollege/ui-nuxt-module/runtime/models'

export const useFilteredData = <T extends IItem>(items: Ref<T[]>) => {
  const searchTerm = ref<string>('')
  const selectedFilter = ref<string>('todos')
  
  const removeAccents = (str: string): string => {
    return str.normalize('NFD')
      .replace(/[\u0300-\u036f]/g, '')
      .toLowerCase()
  }
  
  const filteredItems = computed<T[]>(() => {
    if (!items.value) return []
    
    let result = [...items.value]
    
    // Aplicar filtro por categoría si no es 'todos'
    if (selectedFilter.value !== 'todos') {
      result = result.filter(item => item.categoriaId.toString() === selectedFilter.value)
    }
    
    // Aplicar búsqueda por texto
    if (searchTerm.value.trim()) {
      const normalized = removeAccents(searchTerm.value.trim())
      result = result.filter(item => {
        const title = removeAccents(item.titulo || '')
        const description = removeAccents(item.descripcion || '')
        return title.includes(normalized) || description.includes(normalized)
      })
    }
    
    return result
  })
  
  const totalItems = computed(() => filteredItems.value.length)
  
  return {
    searchTerm,
    selectedFilter,
    filteredItems,
    totalItems
  }
}

Página con Carga de Datos

<script setup lang="ts">
import type { IPlanificacion } from '@redcollege/ui-nuxt-module/runtime/models'

// Variables
const { $apis } = useNuxtApp()
const route = useRoute()
const establecimientoId = Number(route.params.establecimientoid)
const searchTerm = ref<string>('')

// Carga inicial de datos con useAsyncData
const { data: planificaciones, pending, refresh } = await useAsyncData(
  'planificaciones',
  () => $apis.planificaciones.planificacion.getPlanificacionesByEstablecimiento(establecimientoId),
  {
    onError: (error) => {
      useNotification().toast({
        title: 'Error',
        description: 'No se pudieron cargar las planificaciones',
        variant: 'destructive'
      })
    }
  }
)

// Filtrado
const filteredItems = computed(() => {
  if (!planificaciones.value) return []
  
  if (!searchTerm.value.trim()) return planificaciones.value
  
  const normalizedSearch = searchTerm.value.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
  
  return planificaciones.value.filter(plan => {
    const normalizedTitle = plan.titulo.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
    return normalizedTitle.includes(normalizedSearch)
  })
})
</script>

<template lang="pug">
.container.mx-auto.py-8
  .flex.justify-between.items-center.mb-6
    h1.text-2xl.font-bold Planificaciones
    
    Button(
      variant="primary"
      :to="`/${establecimientoId}/planificaciones/crear`"
    )
      Icon(name="tabler:plus" class="mr-2")
      span Nueva Planificación
  
  .mb-6
    Input.w-full(
      placeholder="Buscar planificaciones..."
      v-model="searchTerm"
    )
      template(#prefix)
        Icon.w-5.h-5.text-muted-foreground(name="tabler:search")
  
  .grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-6
    template(v-if="pending")
      CardSkeleton(v-for="n in 6" :key="n")
    
    template(v-else-if="filteredItems.length === 0")
      .col-span-full.flex.flex-col.items-center.py-12
        Icon.w-16.h-16.text-muted-foreground.mb-4(name="tabler:file-off")
        h3.text-xl.font-medium No se encontraron planificaciones
        p.text-muted-foreground Intenta con otra búsqueda o crea una nueva planificación
    
    CardPlanificacion(
      v-for="plan in filteredItems"
      :key="plan.id"
      :planificacion="plan"
      :to="`/${establecimientoId}/planificaciones/${plan.id}`"
    )
</template>

Resumen de Buenas Prácticas

  1. TypeScript
    • Usar import type para importar tipos
    • Definir interface para props
    • Usar arrow functions
    • Evitar type assertions innecesarios
  2. Vue/Nuxt
    • Mantener una estructura de componente clara
    • Usar composables para lógica reutilizable
    • Preferir ref para primitivos y reactive para objetos
    • Usar computed para valores derivados
  3. Pug
    • Mantener una indentación consistente
    • Colocar atributos en líneas separadas cuando hay más de 5
    • Evitar v-for con v-if en el mismo elemento
    • Usar clases encadenadas con sintaxis de punto para Tailwind
  4. Tailwind
    • Organizar clases en un orden lógico
    • Aplicar enfoque mobile-first
    • Usar class solo para clases con caracteres especiales
    • Preferir clases semánticas sobre valores hexadecimales
  5. APIs y Datos
    • Usar useAsyncData para carga inicial de datos
    • Usar Promise.all para peticiones paralelas
    • Aprovechar los estados pending/error proporcionados por useAsyncData
    • Centralizar lógica de API en composables