diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf54900..3b3e20a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,7 +193,6 @@ jobs: with: urls: | http://localhost:3000 - uploadArtifacts: true temporaryPublicStorage: true - name: Stop production server diff --git a/apps/lab/blog/2025-03-12-bienvenida-al-blog-lab.md b/apps/lab/blog/2025-03-12-bienvenida-al-blog-lab.md new file mode 100644 index 0000000..286420c --- /dev/null +++ b/apps/lab/blog/2025-03-12-bienvenida-al-blog-lab.md @@ -0,0 +1,27 @@ +--- +slug: bienvenida-al-blog-lab +title: Bienvenida al Blog de Lab +authors: [angel] +tags: [typescript, nextjs] +date: 2025-03-12 +--- + +# Bienvenida al Blog de Lab + +Bienvenido al blog técnico del Lab de DevPortfolio. Este espacio está pensado para compartir experimentos, aprendizajes y documentación de desarrollo. + +## ¿Qué encontrarás aquí? + +- **Tutoriales** sobre tecnologías del stack actual (Next.js, TypeScript, Docusaurus) +- **Reflexiones** sobre arquitectura, patrones y buenas prácticas +- **Cómo hacer** (how-tos) y guías prácticas +- **Experimentos** con nuevas herramientas o enfoques + +## El stack del proyecto + +El monorepo DevPortfolio combina: + +- **Portfolio**: Next.js 15, React 19, Tailwind, Framer Motion +- **Lab**: Docusaurus 2.4 para documentación y este blog + +Siéntete libre de explorar las [Guías](/docs/intro) para documentación más estructurada sobre arquitectura, i18n y testing. diff --git a/apps/lab/blog/tags.yml b/apps/lab/blog/tags.yml index bfaa778..f2ad8d7 100644 --- a/apps/lab/blog/tags.yml +++ b/apps/lab/blog/tags.yml @@ -1,19 +1,19 @@ -facebook: - label: Facebook - permalink: /facebook - description: Facebook tag description +typescript: + label: TypeScript + permalink: /typescript + description: Artículos sobre TypeScript y tipado estático -hello: - label: Hello - permalink: /hello - description: Hello tag description +nextjs: + label: Next.js + permalink: /nextjs + description: Contenido sobre Next.js y React Server Components -docusaurus: - label: Docusaurus - permalink: /docusaurus - description: Docusaurus tag description +devops: + label: DevOps + permalink: /devops + description: CI/CD, Docker, despliegues y automatización -hola: - label: Hola - permalink: /hola - description: Hola tag description +clean-architecture: + label: Clean Architecture + permalink: /clean-architecture + description: Patrones, capas y estructura de proyectos diff --git a/apps/lab/docs/intro.md b/apps/lab/docs/intro.md index 883b0d8..5fc2d69 100644 --- a/apps/lab/docs/intro.md +++ b/apps/lab/docs/intro.md @@ -16,9 +16,9 @@ Este es mi espacio de documentación técnica y experimentación, donde comparto Documentación detallada sobre los patrones y estrategias de arquitectura utilizados en mis proyectos: -- **[Clean Architecture](./guides/clean-architecture)**: Implementación de arquitectura limpia en 3 capas con Next.js -- **[Testing Strategy](./guides/testing-strategy)**: Estrategia de testing con Vitest y React Testing Library -- **[i18n Patterns](./guides/i18n-patterns)**: Patrones de internacionalización con next-intl +- **[Clean Architecture](/docs/guides/clean-architecture)**: Implementación de arquitectura limpia en 3 capas con Next.js +- **[Testing Strategy](/docs/guides/testing-strategy)**: Estrategia de testing con Vitest y React Testing Library +- **[i18n Patterns](/docs/guides/i18n-patterns)**: Patrones de internacionalización con next-intl ### ✍️ Blog Técnico diff --git a/apps/lab/docusaurus.config.js b/apps/lab/docusaurus.config.js index 5fb0dd0..a27d3d6 100644 --- a/apps/lab/docusaurus.config.js +++ b/apps/lab/docusaurus.config.js @@ -8,6 +8,7 @@ const config = { url: 'https://desenvolupadormaster.vercel.app', baseUrl: '/', + trailingSlash: false, organizationName: 'GMNAPI', projectName: 'devportfolio-lab', @@ -70,7 +71,7 @@ const config = { position: 'left', label: 'Guías', }, - // { to: '/blog', label: 'Blog', position: 'left' }, // Disabled: no blog posts yet + { to: '/blog', label: 'Blog', position: 'left' }, { type: 'localeDropdown', position: 'right', @@ -92,10 +93,10 @@ const config = { label: 'Guías', to: '/docs/intro', }, - // { - // label: 'Blog', - // to: '/blog', - // }, // Disabled: no blog posts yet + { + label: 'Blog', + to: '/blog', + }, ], }, { diff --git a/apps/lab/i18n/en/code.json b/apps/lab/i18n/en/code.json index e3f2492..a3e809f 100644 --- a/apps/lab/i18n/en/code.json +++ b/apps/lab/i18n/en/code.json @@ -1,4 +1,40 @@ { + "homepage.tagline": { + "message": "Technical experiments, skills, and development documentation", + "description": "Homepage hero tagline" + }, + "homepage.button.guides": { + "message": "View Guides", + "description": "Homepage primary CTA button" + }, + "homepage.button.blog": { + "message": "Go to Blog", + "description": "Homepage secondary CTA button" + }, + "homepage.feature.guides.title": { + "message": "Guides", + "description": "Homepage feature card title for Guides" + }, + "homepage.feature.guides.description": { + "message": "Documentation on Clean Architecture, testing, and i18n patterns for Next.js and TypeScript projects.", + "description": "Homepage feature card description for Guides" + }, + "homepage.feature.blog.title": { + "message": "Blog", + "description": "Homepage feature card title for Blog" + }, + "homepage.feature.blog.description": { + "message": "Technical articles, experiments, and case studies on development, SaaS, and DevOps.", + "description": "Homepage feature card description for Blog" + }, + "homepage.feature.portfolio.title": { + "message": "Portfolio", + "description": "Homepage feature card title for Portfolio" + }, + "homepage.feature.portfolio.description": { + "message": "Production projects, skills, and contact. The main CV linked to this Lab.", + "description": "Homepage feature card description for Portfolio" + }, "theme.ErrorPageContent.title": { "message": "This page crashed.", "description": "The title of the fallback page when the page crashed" diff --git a/apps/lab/i18n/en/docusaurus-plugin-content-blog/2025-03-12-bienvenida-al-blog-lab.md b/apps/lab/i18n/en/docusaurus-plugin-content-blog/2025-03-12-bienvenida-al-blog-lab.md new file mode 100644 index 0000000..19c0194 --- /dev/null +++ b/apps/lab/i18n/en/docusaurus-plugin-content-blog/2025-03-12-bienvenida-al-blog-lab.md @@ -0,0 +1,27 @@ +--- +slug: bienvenida-al-blog-lab +title: Welcome to the Lab Blog +authors: [angel] +tags: [typescript, nextjs] +date: 2025-03-12 +--- + +# Welcome to the Lab Blog + +Welcome to the technical blog of the DevPortfolio Lab. This space is designed for sharing experiments, learnings, and development documentation. + +## What will you find here? + +- **Tutorials** on technologies in the current stack (Next.js, TypeScript, Docusaurus) +- **Reflections** on architecture, patterns, and best practices +- **How-tos** and practical guides +- **Experiments** with new tools or approaches + +## The project stack + +The DevPortfolio monorepo combines: + +- **Portfolio**: Next.js 15, React 19, Tailwind, Framer Motion +- **Lab**: Docusaurus 2.4 for documentation and this blog + +Feel free to explore the [Guides](/en/docs/intro) for more structured documentation on architecture, i18n, and testing. diff --git a/apps/lab/i18n/en/docusaurus-plugin-content-blog/options.json b/apps/lab/i18n/en/docusaurus-plugin-content-blog/options.json index 969f5f4..bd4fd69 100644 --- a/apps/lab/i18n/en/docusaurus-plugin-content-blog/options.json +++ b/apps/lab/i18n/en/docusaurus-plugin-content-blog/options.json @@ -8,7 +8,7 @@ "description": "The description for the blog used in SEO" }, "sidebar.title": { - "message": "Artículos recientes", + "message": "Recent articles", "description": "The label for the left sidebar" } } diff --git a/apps/lab/i18n/en/docusaurus-plugin-content-docs/current.json b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current.json index 51dbac3..769eb12 100644 --- a/apps/lab/i18n/en/docusaurus-plugin-content-docs/current.json +++ b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current.json @@ -3,16 +3,12 @@ "message": "Next", "description": "The label for version current" }, - "sidebar.guidesSidebar.category.Tutorial - Basics": { - "message": "Tutorial - Basics", - "description": "The label for category Tutorial - Basics in sidebar guidesSidebar" + "sidebar.guidesSidebar.category.Guías de Arquitectura": { + "message": "Architecture Guides", + "description": "The label for category Guías de Arquitectura in sidebar guidesSidebar" }, - "sidebar.guidesSidebar.category.Tutorial - Basics.link.generated-index.description": { - "message": "5 minutes to learn the most important Docusaurus concepts.", - "description": "The generated-index page description for category Tutorial - Basics in sidebar guidesSidebar" - }, - "sidebar.guidesSidebar.category.Tutorial - Extras": { - "message": "Tutorial - Extras", - "description": "The label for category Tutorial - Extras in sidebar guidesSidebar" + "sidebar.guidesSidebar.category.Guías de Arquitectura.link.generated-index.description": { + "message": "Detailed guides on Clean Architecture, Testing, and i18n in DevPortfolio.", + "description": "The generated-index page description for Architecture Guides" } } diff --git a/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/guides/_category_.json b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/guides/_category_.json new file mode 100644 index 0000000..db9646c --- /dev/null +++ b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/guides/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Architecture Guides", + "position": 2, + "link": { + "type": "generated-index", + "description": "Detailed guides on Clean Architecture, Testing, and i18n in DevPortfolio." + } +} diff --git a/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/guides/clean-architecture.md b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/guides/clean-architecture.md new file mode 100644 index 0000000..f7a5ad8 --- /dev/null +++ b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/guides/clean-architecture.md @@ -0,0 +1,550 @@ +--- +id: clean-architecture +title: Clean Architecture in DevPortfolio +sidebar_label: Clean Architecture +sidebar_position: 1 +description: Comprehensive guide on implementing Clean Architecture with 3 layers in Next.js +keywords: [clean architecture, next.js, typescript, dependency rule] +--- + +# Clean Architecture - DevPortfolio + +## Introduction + +DevPortfolio implements a simplified version of **Clean Architecture** with 3 main layers. This architecture ensures separation of concerns, testability, and maintainability. + +## Layer Structure + +``` +/src +├── /core # 🎯 Domain Layer +├── /features # 📦 Features Layer +└── /shared # 🔧 Shared Layer +``` + +### Dependency Rule + +**CRITICAL**: Dependencies flow inward only: + +``` +features → can import → core + shared +shared → can import → core +core → CANNOT import anything (pure TypeScript) +``` + +**CORRECT Example**: + +```typescript +// ✅ features/hero/HeroSection.tsx +import { validateEmail } from '@/core/use-cases/validateEmail'; +import { Button } from '@/shared/components/ui/Button'; + +// ✅ shared/hooks/useFormValidation.ts +import { validateEmail } from '@/core/use-cases/validateEmail'; + +// ✅ core/use-cases/validateEmail.ts +export function validateEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} +``` + +**INCORRECT Example**: + +```typescript +// ❌ core/entities/Project.ts +import { useState } from 'react'; // NEVER import React in core/ +import { cn } from '@/shared/utils/cn'; // NEVER import shared in core/ + +// ❌ shared/components/ui/Button.tsx +import { Project } from '@/features/projects/types'; // NEVER import features in shared/ +``` + +## Layer 1: Core (Domain) + +**Location**: `/src/core` + +**Responsibility**: Pure business logic, independent of frameworks. + +**Contents**: + +- **Entities** (`/entities`): Domain models with validation +- **Use Cases** (`/use-cases`): Pure business logic functions + +**Rules**: + +- ✅ Pure TypeScript only +- ✅ 100% testable without mocks +- ❌ NO React, Next.js, Framer Motion, etc. imports +- ❌ NO side effects (API calls, localStorage, etc.) + +### Example: Entity + +```typescript +// src/core/entities/Project.ts +export interface ProjectData { + id: string; + title: string; + description: string; + technologies: string[]; + githubUrl?: string; + liveUrl?: string; + category: ProjectCategory; +} + +export type ProjectCategory = 'fullstack' | 'frontend' | 'backend' | 'devops' | 'mobile' | 'data'; + +export class Project { + readonly id: string; + readonly title: string; + readonly description: string; + readonly technologies: string[]; + readonly githubUrl?: string; + readonly liveUrl?: string; + readonly category: ProjectCategory; + + constructor(data: ProjectData) { + this.validate(data); + Object.assign(this, data); + } + + private validate(data: ProjectData): void { + if (!data.id || data.id.trim().length === 0) { + throw new Error('Project ID is required'); + } + + if (!data.title || data.title.trim().length === 0) { + throw new Error('Project title is required'); + } + + if (data.technologies.length === 0) { + throw new Error('At least one technology is required'); + } + + if (data.githubUrl && !this.isValidUrl(data.githubUrl)) { + throw new Error('Invalid GitHub URL'); + } + + if (data.liveUrl && !this.isValidUrl(data.liveUrl)) { + throw new Error('Invalid live URL'); + } + } + + private isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } + } + + hasLiveDemo(): boolean { + return !!this.liveUrl; + } + + hasSourceCode(): boolean { + return !!this.githubUrl; + } +} +``` + +### Example: Use Case + +```typescript +// src/core/use-cases/filterProjectsByCategory.ts +import type { Project } from '@/core/entities/Project'; +import type { ProjectCategory } from '@/core/entities/Project'; + +export function filterProjectsByCategory( + projects: Project[], + category: ProjectCategory | 'all' +): Project[] { + if (category === 'all') { + return projects; + } + + return projects.filter((project) => project.category === category); +} + +// src/core/use-cases/validateContactForm.ts +export interface ContactFormData { + name: string; + email: string; + message: string; +} + +export interface ValidationResult { + isValid: boolean; + errors: Record; +} + +export function validateContactForm(data: ContactFormData): ValidationResult { + const errors: Record = {}; + + if (!data.name || data.name.trim().length === 0) { + errors.name = 'Name is required'; + } else if (data.name.trim().length < 2) { + errors.name = 'Name must be at least 2 characters'; + } + + if (!data.email || data.email.trim().length === 0) { + errors.email = 'Email is required'; + } else if (!isValidEmail(data.email)) { + errors.email = 'Invalid email format'; + } + + if (!data.message || data.message.trim().length === 0) { + errors.message = 'Message is required'; + } else if (data.message.trim().length < 10) { + errors.message = 'Message must be at least 10 characters'; + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; +} + +function isValidEmail(email: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); +} +``` + +## Layer 2: Features + +**Location**: `/src/features` + +**Responsibility**: Implementation of specific portfolio features. Each feature is a vertical slice with components, hooks, types, and constants. + +**Contents**: + +- Feature-specific React components +- Feature-specific custom hooks +- Feature-specific TypeScript types +- Feature-specific constants + +**Rules**: + +- ✅ Can import from `@/core` and `@/shared` +- ✅ Components with 'use client' if they use React hooks +- ✅ Translations with `useTranslations('feature-name')` +- ❌ NO importing from other features (avoid coupling) +- ❌ NO complex business logic (move it to core/) + +### Example: Feature Structure + +``` +/src/features/projects/ +├── ProjectsSection.tsx # Main component +├── ProjectCard.tsx # Card component +├── ProjectFilters.tsx # Filters component +├── types.ts # Feature-specific types +└── constants.ts # Feature-specific constants +``` + +### Example: Feature Component + +```typescript +// src/features/projects/ProjectsSection.tsx +'use client'; + +import { useState, useMemo } from 'react'; +import { useTranslations } from 'next-intl'; +import { motion } from 'framer-motion'; +import { ProjectCard } from './ProjectCard'; +import { ProjectFilters } from './ProjectFilters'; +import type { ProjectCategory } from '@/core/entities/Project'; +import { filterProjectsByCategory } from '@/core/use-cases/filterProjectsByCategory'; +import { PROJECTS } from '@/shared/constants/projects'; + +export function ProjectsSection() { + const t = useTranslations('projects'); + const [selectedCategory, setSelectedCategory] = + useState('all'); + + const filteredProjects = useMemo(() => { + return filterProjectsByCategory(PROJECTS, selectedCategory); + }, [selectedCategory]); + + return ( +
+

{t('title')}

+ + + +
+ {filteredProjects.map((project, index) => ( + + + + ))} +
+
+ ); +} +``` + +## Layer 3: Shared + +**Location**: `/src/shared` + +**Responsibility**: Code reusable across multiple features. + +**Contents**: + +- `/components/ui`: Atomic components (Button, Card, Modal, etc.) +- `/components/layout`: Layout components (Navbar, Footer) +- `/hooks`: Reusable custom hooks +- `/utils`: Utility functions +- `/constants`: Global project constants + +**Rules**: + +- ✅ Can import from `@/core` +- ✅ Generic and reusable UI components +- ✅ No dependencies on specific features +- ❌ NO imports from `/features` +- ❌ NO business logic (move it to core/) + +### Example: Shared UI Component + +```typescript +// src/shared/components/ui/Button.tsx +import { ButtonHTMLAttributes, forwardRef } from 'react'; +import { cn } from '@/shared/utils/cn'; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'outline'; + size?: 'sm' | 'md' | 'lg'; +} + +export const Button = forwardRef( + ({ className, variant = 'primary', size = 'md', ...props }, ref) => { + return ( + + + + ); +} +``` + +**Namespace**: The argument to `useTranslations('hero')` must match the root key in the JSON files. + +### getTranslations (Server Components) + +For server components and pages: + +```typescript +import { getTranslations } from 'next-intl/server'; + +export default async function ProjectsPage() { + const t = await getTranslations('projects'); + + return ( +
+

{t('title')}

+

{t('filters.all')}

+
+ ); +} +``` + +### Dynamic Translations + +For translations with variables: + +```typescript +// messages/en.json +{ + "welcome": { + "greeting": "Hello {name}, you have {count} new messages" + } +} + +// Component +const t = useTranslations('welcome'); +

{t('greeting', { name: 'Ángel', count: 5 })}

+// Output: "Hello Ángel, you have 5 new messages" +``` + +### Plural Translations + +```typescript +// messages/en.json +{ + "projects": { + "count": "{count, plural, =0 {No projects} =1 {1 project} other {# projects}}" + } +} + +// Component +const t = useTranslations('projects'); +

{t('count', { count: 0 })}

// "No projects" +

{t('count', { count: 1 })}

// "1 project" +

{t('count', { count: 5 })}

// "5 projects" +``` + +## Common Patterns + +### Pattern 1: Feature Section with i18n + +```typescript +// src/features/projects/ProjectsSection.tsx +'use client'; + +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import type { ProjectCategory } from '@/core/entities/Project'; + +export function ProjectsSection() { + const t = useTranslations('projects'); + const [selectedCategory, setSelectedCategory] = + useState('all'); + + return ( +
+

{t('title')}

+ +
+ + + +
+ + {/* Render projects... */} +
+ ); +} +``` + +**Corresponding JSON structure**: + +```json +{ + "projects": { + "title": "Projects", + "filters": { + "all": "All", + "fullstack": "Full Stack", + "frontend": "Frontend" + } + } +} +``` + +### Pattern 2: Validation with i18n Error Messages + +```typescript +// src/features/contact/ContactForm.tsx +'use client'; + +import { useTranslations } from 'next-intl'; +import { validateContactForm } from '@/core/use-cases/validateContactForm'; + +export function ContactForm() { + const t = useTranslations('contact'); + const [errors, setErrors] = useState>({}); + + const handleSubmit = (data: ContactFormData) => { + const validation = validateContactForm(data); + + if (!validation.isValid) { + // Map core validation errors to i18n messages + const translatedErrors: Record = {}; + + if (validation.errors.name) { + translatedErrors.name = t('validation.nameRequired'); + } + if (validation.errors.email === 'Email is required') { + translatedErrors.email = t('validation.emailRequired'); + } + if (validation.errors.email === 'Invalid email format') { + translatedErrors.email = t('validation.emailInvalid'); + } + + setErrors(translatedErrors); + return; + } + + // Submit form... + }; + + return ( +
+ + {errors.name &&

{errors.name}

} + + + {errors.email &&

{errors.email}

} + + +
+ ); +} +``` + +**JSON structure**: + +```json +{ + "contact": { + "form": { + "submit": "Send" + }, + "validation": { + "nameRequired": "Name is required", + "emailRequired": "Email is required", + "emailInvalid": "Invalid email" + } + } +} +``` + +### Pattern 3: Shared Component with Optional i18n + +For shared components that can be reused in multiple contexts: + +```typescript +// src/shared/components/ui/Modal.tsx +'use client'; + +import { useTranslations } from 'next-intl'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title?: string; // Allow custom title + children: React.ReactNode; +} + +export function Modal({ isOpen, onClose, title, children }: ModalProps) { + const t = useTranslations('ui'); + + return isOpen ? ( +
+
+

{title || t('modal.defaultTitle')}

+ {children} + +
+
+ ) : null; +} +``` + +**JSON structure**: + +```json +{ + "ui": { + "modal": { + "defaultTitle": "Information", + "close": "Close" + } + } +} +``` + +### Pattern 4: Dynamic Routes with i18n + +For dynamic pages like `/projects/[slug]`: + +```typescript +// app/[locale]/projects/[slug]/page.tsx +import { getTranslations } from 'next-intl/server'; +import { notFound } from 'next/navigation'; +import { PROJECTS } from '@/shared/constants/projects'; + +interface PageProps { + params: { locale: string; slug: string }; +} + +export default async function ProjectDetailPage({ params }: PageProps) { + const t = await getTranslations('projects'); + + const project = PROJECTS.find(p => p.id === params.slug); + + if (!project) { + notFound(); + } + + return ( +
+

{t(`items.${params.slug}.title`)}

+

{t(`items.${params.slug}.description`)}

+ +
+

{t('details.technologies')}

+
    + {project.technologies.map(tech => ( +
  • {tech}
  • + ))} +
+
+
+ ); +} +``` + +**JSON structure for projects**: + +```json +{ + "projects": { + "details": { + "technologies": "Technologies" + }, + "items": { + "devportfolio": { + "title": "DevPortfolio", + "description": "Personal portfolio with Next.js and React" + }, + "ecommerce-platform": { + "title": "E-commerce Platform", + "description": "Scalable e-commerce system" + } + } + } +} +``` + +## Locale Switching + +### Client-Side Locale Switching + +```typescript +// src/shared/components/layout/LocaleSwitcher.tsx +'use client'; + +import { useRouter, usePathname } from 'next/navigation'; +import { useLocale } from 'next-intl'; + +export function LocaleSwitcher() { + const router = useRouter(); + const pathname = usePathname(); + const currentLocale = useLocale(); + + const switchLocale = (newLocale: string) => { + // Remove current locale prefix from pathname + const pathWithoutLocale = pathname.replace(`/${currentLocale}`, ''); + + // Add new locale prefix (except for default locale 'es') + const newPath = newLocale === 'es' + ? pathWithoutLocale || '/' + : `/${newLocale}${pathWithoutLocale || ''}`; + + router.push(newPath); + }; + + return ( +
+ + +
+ ); +} +``` + +### Link Component with Locale + +```typescript +'use client'; + +import { Link as NextIntlLink } from '@/i18n/routing'; + +export function ProjectLink({ slug }: { slug: string }) { + return ( + + View project + + ); +} +``` + +## Testing with i18n + +### Mock next-intl in Tests + +```typescript +// tests/features/hero/HeroSection.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { HeroSection } from '@/features/hero/HeroSection'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => { + const translations: Record = { + 'greeting': 'Hello, I am', + 'name': 'Ángel Hidalgo', + 'tagline': 'Full Stack Developer', + 'cta.projects': 'View Projects', + 'cta.contact': 'Contact', + }; + return translations[key] || key; + }, +})); + +describe('HeroSection', () => { + it('renders translated greeting', () => { + render(); + expect(screen.getByText('Hello, I am')).toBeInTheDocument(); + }); + + it('renders translated CTA buttons', () => { + render(); + expect(screen.getByRole('button', { name: 'View Projects' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Contact' })).toBeInTheDocument(); + }); +}); +``` + +### Testing Multiple Locales + +```typescript +// tests/i18n/messages.test.ts +import { describe, it, expect } from 'vitest'; +import esMessages from '@/messages/es.json'; +import enMessages from '@/messages/en.json'; + +describe('i18n Messages', () => { + it('has same keys in both locale files', () => { + const esKeys = Object.keys(esMessages); + const enKeys = Object.keys(enMessages); + + expect(esKeys).toEqual(enKeys); + }); + + it('has all required sections', () => { + const requiredSections = ['hero', 'about', 'projects', 'contact']; + + requiredSections.forEach((section) => { + expect(esMessages).toHaveProperty(section); + expect(enMessages).toHaveProperty(section); + }); + }); + + it('has matching nested keys for contact form', () => { + const esContactKeys = Object.keys(esMessages.contact.form); + const enContactKeys = Object.keys(enMessages.contact.form); + + expect(esContactKeys).toEqual(enContactKeys); + }); +}); +``` + +## Best Practices + +### 1. Translation Organization + +**DO**: Organize by feature/section + +```json +{ + "hero": { ... }, + "about": { ... }, + "projects": { ... }, + "contact": { ... } +} +``` + +**DON'T**: Organize by content type + +```json +{ + "titles": { "hero": "...", "about": "..." }, + "buttons": { "submit": "...", "cancel": "..." } +} +``` + +### 2. Namespacing + +**DO**: Use specific namespace per feature + +```typescript +const t = useTranslations('projects'); +return

{t('title')}

; +``` + +**DON'T**: Use global namespace + +```typescript +const t = useTranslations(); +return

{t('projects.title')}

; // More verbose +``` + +### 3. Descriptive Keys + +**DO**: Semantic and hierarchical keys + +```json +{ + "contact": { + "form": { + "submit": "Send", + "sending": "Sending..." + }, + "validation": { + "emailRequired": "Email required" + } + } +} +``` + +**DON'T**: Generic or flat keys + +```json +{ + "button1": "Send", + "button2": "Sending...", + "error1": "Email required" +} +``` + +### 4. Keep in Sync + +**CRITICAL**: Always update both files (es.json and en.json) at the same time. + +**Recommended workflow**: + +1. Update `messages/es.json` (source of truth) +2. Update `messages/en.json` with equivalent translations +3. Verify that the keys match +4. Run i18n tests + +### 5. Avoid Hardcoded Strings + +**DO**: Use translations + +```typescript + +``` + +**DON'T**: Hardcode strings + +```typescript + // ❌ Not translatable +``` + +### 6. Consistent Formatting + +**DO**: Consistent format with punctuation + +```json +{ + "validation": { + "nameRequired": "Name is required", + "emailRequired": "Email is required" + } +} +``` + +**DON'T**: Inconsistent format + +```json +{ + "validation": { + "nameRequired": "Name is required.", + "emailRequired": "Email required" // Missing period + } +} +``` + +## Troubleshooting + +### Error: "useTranslations is not a function" + +**Cause**: Missing 'use client' directive in component. + +**Solution**: + +```typescript +'use client'; // ← Add this line + +import { useTranslations } from 'next-intl'; +``` + +### Error: Translation key not found + +**Cause**: Key does not exist in JSON files. + +**Solution**: Verify that the key exists in both `es.json` and `en.json`: + +```json +// messages/en.json +{ + "projects": { + "title": "Projects" // ← Verify it exists + } +} +``` + +### Translations not updating after changes + +**Cause**: Next.js cache. + +**Solution**: + +```bash +rm -rf .next +npm run dev +``` + +## i18n Checklist + +Before committing translation changes: + +- [ ] Both files (`es.json` and `en.json`) are updated +- [ ] Keys match in both files +- [ ] Translations are semantically equivalent +- [ ] No hardcoded strings in components +- [ ] Correct namespace used in `useTranslations()` +- [ ] Components have 'use client' if using `useTranslations` +- [ ] Tests mock `next-intl` correctly +- [ ] Punctuation format is consistent + +## References + +- next-intl Documentation: https://next-intl-docs.vercel.app/ +- Clean Architecture: See `.claude/architecture/clean-architecture.md` +- Testing Strategy: See `.claude/architecture/testing-strategy.md` diff --git a/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/guides/testing-strategy.md b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/guides/testing-strategy.md new file mode 100644 index 0000000..37a8f5e --- /dev/null +++ b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/guides/testing-strategy.md @@ -0,0 +1,582 @@ +--- +id: testing-strategy +title: Testing Strategy in DevPortfolio +sidebar_label: Testing Strategy +sidebar_position: 2 +description: Testing guide with Vitest and React Testing Library to achieve 80%+ coverage +keywords: [testing, vitest, react testing library, coverage, aaa pattern] +--- + +# Testing Strategy - DevPortfolio + +## Testing Goal + +**Coverage target**: 80%+ (lines, functions, branches, statements) + +**Tools**: + +- **Vitest**: Test runner (Jest replacement, faster) +- **React Testing Library**: Component testing +- **@testing-library/user-event**: User interaction simulation +- **jsdom**: DOM environment for tests + +## Configuration + +### Configuration Files + +**vitest.config.ts**: + +```typescript +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: ['node_modules/', 'tests/', '*.config.*', '.next/', 'app/layout.tsx'], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + '@/app': path.resolve(__dirname, './app'), + '@/tests': path.resolve(__dirname, './tests'), + }, + }, +}); +``` + +**tests/setup.ts**: + +```typescript +import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); +``` + +### Testing Scripts + +```bash +npm test # Run tests in watch mode +npm run test:ui # Run tests with Vitest UI +npm run test:run # Run tests once (CI mode) +npm run test:coverage # Generate coverage report +``` + +## What to Test + +### General Rule + +**Test behavior, not implementation**. Tests should verify that the code does what it's supposed to do, not how it does it. + +### Testing Priorities + +1. **Core Layer (90-100% coverage)**: Critical business logic + - Entities: Validation, business methods + - Use Cases: Pure functions, edge cases + +2. **Features Layer (80-90% coverage)**: Components and functionality + - Components: Rendering, user interactions, states + - Custom hooks: State logic, effects + +3. **Shared Layer (80-90% coverage)**: Reusables + - UI Components: Props, variants, accessibility + - Hooks: Behavior, edge cases + - Utils: Utility functions, edge cases + +4. **App Layer (Optional, less than 50% coverage)**: Layout and pages + - Pages: Basic rendering + - Layouts: Structure, providers + +## Testing by Layer + +### 1. Testing Core Layer (Entities and Use Cases) + +**Goal**: 90-100% coverage, simple tests without mocks. + +#### Testing Entities + +```typescript +// tests/core/entities/Project.test.ts +import { describe, it, expect } from 'vitest'; +import { Project, type ProjectData } from '@/core/entities/Project'; + +describe('Project Entity', () => { + describe('Constructor & Validation', () => { + it('creates project with valid data', () => { + // Arrange + const validData: ProjectData = { + id: 'project-1', + title: 'DevPortfolio', + description: 'Personal portfolio', + technologies: ['Next.js', 'React', 'TypeScript'], + category: 'fullstack', + }; + + // Act + const project = new Project(validData); + + // Assert + expect(project.id).toBe('project-1'); + expect(project.title).toBe('DevPortfolio'); + expect(project.technologies).toHaveLength(3); + }); + + it('throws error when ID is missing', () => { + const invalidData = { + id: '', + title: 'Test', + description: 'Test', + technologies: ['React'], + category: 'frontend' as const, + }; + + expect(() => new Project(invalidData)).toThrow('Project ID is required'); + }); + + it('throws error when title is missing', () => { + const invalidData = { + id: 'test-1', + title: '', + description: 'Test', + technologies: ['React'], + category: 'frontend' as const, + }; + + expect(() => new Project(invalidData)).toThrow('Project title is required'); + }); + + it('throws error when technologies array is empty', () => { + const invalidData = { + id: 'test-1', + title: 'Test', + description: 'Test', + technologies: [], + category: 'frontend' as const, + }; + + expect(() => new Project(invalidData)).toThrow('At least one technology is required'); + }); + + it('throws error for invalid GitHub URL', () => { + const invalidData = { + id: 'test-1', + title: 'Test', + description: 'Test', + technologies: ['React'], + category: 'frontend' as const, + githubUrl: 'not-a-url', + }; + + expect(() => new Project(invalidData)).toThrow('Invalid GitHub URL'); + }); + }); + + describe('Business Methods', () => { + it('hasLiveDemo returns true when liveUrl exists', () => { + const project = new Project({ + id: 'test-1', + title: 'Test', + description: 'Test', + technologies: ['React'], + category: 'frontend', + liveUrl: 'https://example.com', + }); + + expect(project.hasLiveDemo()).toBe(true); + }); + + it('hasLiveDemo returns false when liveUrl is missing', () => { + const project = new Project({ + id: 'test-1', + title: 'Test', + description: 'Test', + technologies: ['React'], + category: 'frontend', + }); + + expect(project.hasLiveDemo()).toBe(false); + }); + + it('hasSourceCode returns true when githubUrl exists', () => { + const project = new Project({ + id: 'test-1', + title: 'Test', + description: 'Test', + technologies: ['React'], + category: 'frontend', + githubUrl: 'https://github.com/user/repo', + }); + + expect(project.hasSourceCode()).toBe(true); + }); + }); +}); +``` + +#### Testing Use Cases + +```typescript +// tests/core/use-cases/filterProjectsByCategory.test.ts +import { describe, it, expect } from 'vitest'; +import { filterProjectsByCategory } from '@/core/use-cases/filterProjectsByCategory'; +import { Project } from '@/core/entities/Project'; + +describe('filterProjectsByCategory', () => { + const mockProjects = [ + new Project({ id: '1', title: 'Project 1', description: 'Test', technologies: ['React'], category: 'frontend' }), + new Project({ id: '2', title: 'Project 2', description: 'Test', technologies: ['Node.js'], category: 'backend' }), + new Project({ id: '3', title: 'Project 3', description: 'Test', technologies: ['Next.js'], category: 'fullstack' }), + ]; + + it('returns all projects when category is "all"', () => { + const result = filterProjectsByCategory(mockProjects, 'all'); + expect(result).toHaveLength(3); + }); + + it('filters projects by frontend category', () => { + const result = filterProjectsByCategory(mockProjects, 'frontend'); + expect(result).toHaveLength(1); + expect(result[0].category).toBe('frontend'); + }); + + it('returns empty array when no projects match category', () => { + const result = filterProjectsByCategory(mockProjects, 'mobile'); + expect(result).toHaveLength(0); + }); + + it('handles empty projects array', () => { + const result = filterProjectsByCategory([], 'frontend'); + expect(result).toHaveLength(0); + }); +}); +``` + +### 2. Testing Features Layer (Components) + +**Goal**: 80-90% coverage, testing with React Testing Library. + +#### AAA Pattern (Arrange-Act-Assert) + +All tests must follow the AAA pattern: + +1. **Arrange**: Prepare data and mocks +2. **Act**: Execute the action to test +3. **Assert**: Verify the result + +#### Testing Components + +```typescript +// tests/features/projects/ProjectCard.test.tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProjectCard } from '@/features/projects/ProjectCard'; +import { Project } from '@/core/entities/Project'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +describe('ProjectCard', () => { + it('renders project title and description', () => { + // Arrange + const mockProject = new Project({ + id: 'test-1', + title: 'Test Project', + description: 'This is a test project', + technologies: ['React', 'TypeScript'], + category: 'frontend', + }); + + // Act + render(); + + // Assert + expect(screen.getByText('Test Project')).toBeInTheDocument(); + expect(screen.getByText('This is a test project')).toBeInTheDocument(); + }); + + it('shows live demo link when liveUrl exists', () => { + const mockProject = new Project({ + id: 'test-1', + title: 'Test', + description: 'Test', + technologies: ['React'], + category: 'frontend', + liveUrl: 'https://example.com', + }); + + render(); + + const liveLink = screen.getByRole('link', { name: /live demo/i }); + expect(liveLink).toHaveAttribute('href', 'https://example.com'); + }); + + it('does not show live demo link when liveUrl is missing', () => { + const mockProject = new Project({ + id: 'test-1', + title: 'Test', + description: 'Test', + technologies: ['React'], + category: 'frontend', + }); + + render(); + + expect(screen.queryByRole('link', { name: /live demo/i })).not.toBeInTheDocument(); + }); +}); +``` + +#### Testing User Interactions + +```typescript +// tests/features/contact/ContactForm.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ContactForm } from '@/features/contact/ContactForm'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +describe('ContactForm', () => { + it('submits form with valid data', async () => { + const user = userEvent.setup(); + const mockSubmit = vi.fn(); + render(); + + await user.type(screen.getByLabelText(/name/i), 'John Doe'); + await user.type(screen.getByLabelText(/email/i), 'john@example.com'); + await user.type(screen.getByLabelText(/message/i), 'Hello, this is a test message'); + await user.click(screen.getByRole('button', { name: /send/i })); + + await waitFor(() => { + expect(mockSubmit).toHaveBeenCalledWith({ + name: 'John Doe', + email: 'john@example.com', + message: 'Hello, this is a test message', + }); + }); + }); + + it('shows validation errors for empty fields', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /send/i })); + + expect(await screen.findByText(/name is required/i)).toBeInTheDocument(); + expect(await screen.findByText(/email is required/i)).toBeInTheDocument(); + expect(await screen.findByText(/message is required/i)).toBeInTheDocument(); + }); + + it('disables submit button while submitting', async () => { + const user = userEvent.setup(); + const mockSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 1000))); + render(); + + await user.type(screen.getByLabelText(/name/i), 'John Doe'); + await user.type(screen.getByLabelText(/email/i), 'john@example.com'); + await user.type(screen.getByLabelText(/message/i), 'Test message here'); + + const submitButton = screen.getByRole('button', { name: /send/i }); + await user.click(submitButton); + + expect(submitButton).toBeDisabled(); + }); +}); +``` + +### 3. Testing Shared Layer (UI Components and Hooks) + +#### Testing UI Components + +```typescript +// tests/shared/components/ui/Button.test.tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Button } from '@/shared/components/ui/Button'; + +describe('Button', () => { + it('renders button with text', () => { + render(); + expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument(); + }); + + it('applies primary variant styles by default', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-accent'); + }); + + it('applies secondary variant styles', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('bg-background'); + }); + + it('applies outline variant styles', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('border-2', 'border-accent'); + }); + + it('handles click events', async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + render(); + + await user.click(screen.getByRole('button')); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('can be disabled', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('forwards ref correctly', () => { + const ref = vi.fn(); + render(); + expect(ref).toHaveBeenCalled(); + }); +}); +``` + +#### Testing Custom Hooks + +```typescript +// tests/shared/hooks/useScrollSpy.test.ts +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useScrollSpy } from '@/shared/hooks/useScrollSpy'; + +describe('useScrollSpy', () => { + beforeEach(() => { + // Setup DOM elements for test + const section1 = document.createElement('div'); + section1.id = 'section-1'; + const section2 = document.createElement('div'); + section2.id = 'section-2'; + document.body.appendChild(section1); + document.body.appendChild(section2); + }); + + afterEach(() => { + document.body.replaceChildren(); + }); + + it('returns empty string initially', () => { + const { result } = renderHook(() => useScrollSpy(['section-1', 'section-2'])); + expect(result.current).toBe(''); + }); + + it('updates active section when element intersects', () => { + const { result } = renderHook(() => useScrollSpy(['section-1', 'section-2'])); + + const mockCallback = vi.fn(); + global.IntersectionObserver = vi.fn().mockImplementation((callback) => { + mockCallback.mockImplementation(callback); + return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() }; + }); + + mockCallback([{ isIntersecting: true, target: { id: 'section-1' } }]); + + expect(result.current).toBe('section-1'); + }); +}); +``` + +## Mocking in Tests + +### Mock next-intl + +```typescript +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); +``` + +### Mock framer-motion + +```typescript +vi.mock('framer-motion', () => ({ + motion: { + div: 'div', + button: 'button', + section: 'section', + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => children, +})); +``` + +### Mock next/navigation + +```typescript +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + }), + usePathname: () => '/en/projects', + useSearchParams: () => new URLSearchParams(), +})); +``` + +## Useful Commands + +```bash +# Run specific test file +npm test -- ProjectCard.test.tsx + +# Run tests matching pattern +npm test -- --grep "validates email" + +# Run tests with UI (debugging) +npm run test:ui + +# Generate coverage report +npm run test:coverage + +# Watch mode (default) +npm test +``` + +## Testing Checklist + +Before considering a feature complete: + +- [ ] Core entities have 90%+ coverage +- [ ] Core use cases have 90%+ coverage +- [ ] Main components have 80%+ coverage +- [ ] User interactions are tested +- [ ] Edge cases are covered +- [ ] Validations are tested +- [ ] Tests follow the AAA pattern +- [ ] Mocks are minimal and necessary +- [ ] Tests are readable and maintainable +- [ ] `npm run test:coverage` passes with 80%+ + +## References + +- Testing Library: https://testing-library.com/docs/react-testing-library/intro/ +- Vitest: https://vitest.dev/ +- Clean Architecture Testing: See `.claude/architecture/clean-architecture.md` diff --git a/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/intro.md b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/intro.md new file mode 100644 index 0000000..53b4ffb --- /dev/null +++ b/apps/lab/i18n/en/docusaurus-plugin-content-docs/current/intro.md @@ -0,0 +1,50 @@ +--- +sidebar_position: 1 +id: intro +title: Welcome to the Lab +description: Documentation and technical experimentation hub by Ángel Hidalgo +keywords: [lab, documentation, development, architecture, testing] +--- + +# Welcome to the Lab 🧪 + +This is my technical documentation and experimentation space, where I share guides, architecture patterns, and learnings about software development. + +## What will you find here? + +### 📚 Architecture Guides + +Detailed documentation on the patterns and architecture strategies used in my projects: + +- **[Clean Architecture](/docs/guides/clean-architecture)**: Implementation of clean architecture in 3 layers with Next.js +- **[Testing Strategy](/docs/guides/testing-strategy)**: Testing strategy with Vitest and React Testing Library +- **[i18n Patterns](/docs/guides/i18n-patterns)**: Internationalization patterns with next-intl + +### ✍️ Technical Blog + +Articles on development, Claude Code skills, and real-project case studies. + +## About the Portfolio + +This Lab is linked to my [main portfolio](https://desenvolupadormaster.vercel.app), where you can: + +- View my **[projects](https://desenvolupadormaster.vercel.app/projects)** in production +- Learn about my **[experience](https://desenvolupadormaster.vercel.app#about)** +- **[Contact me](https://desenvolupadormaster.vercel.app#contact)** for collaborations + +## Lab Technologies + +This site is built with: + +- **Docusaurus 2.4.3** - Documentation framework +- **React 17** - UI library +- **Markdown** - Content format +- **TypeScript** - Type safety (optional) + +## Navigation + +Use the left sidebar to explore the **Architecture Guides**. + +--- + +**Questions or suggestions?** Connect with me on [GitHub](https://github.com/GMNAPI) or [LinkedIn](https://linkedin.com/in/angel-hidalgo). diff --git a/apps/lab/i18n/en/docusaurus-theme-classic/footer.json b/apps/lab/i18n/en/docusaurus-theme-classic/footer.json index 62ad511..a1151a4 100644 --- a/apps/lab/i18n/en/docusaurus-theme-classic/footer.json +++ b/apps/lab/i18n/en/docusaurus-theme-classic/footer.json @@ -1,6 +1,6 @@ { "link.title.Contenido": { - "message": "Contenido", + "message": "Content", "description": "The title of the footer links column with title=Contenido in the footer" }, "link.title.Portfolio": { @@ -11,8 +11,12 @@ "message": "Social", "description": "The title of the footer links column with title=Social in the footer" }, + "link.item.label.Inicio": { + "message": "Home", + "description": "The label of footer link linking to portfolio home" + }, "link.item.label.Guías": { - "message": "Guías", + "message": "Guides", "description": "The label of footer link with label=Guías linking to /docs/intro" }, "link.item.label.Blog": { @@ -20,11 +24,11 @@ "description": "The label of footer link with label=Blog linking to /blog" }, "link.item.label.Proyectos": { - "message": "Proyectos", + "message": "Projects", "description": "The label of footer link with label=Proyectos linking to https://angelhidalgo.dev/projects" }, "link.item.label.Contacto": { - "message": "Contacto", + "message": "Contact", "description": "The label of footer link with label=Contacto linking to https://angelhidalgo.dev/#contact" }, "link.item.label.GitHub": { diff --git a/apps/lab/i18n/en/docusaurus-theme-classic/navbar.json b/apps/lab/i18n/en/docusaurus-theme-classic/navbar.json index 08b2615..03285c9 100644 --- a/apps/lab/i18n/en/docusaurus-theme-classic/navbar.json +++ b/apps/lab/i18n/en/docusaurus-theme-classic/navbar.json @@ -8,7 +8,7 @@ "description": "The alt text of navbar logo" }, "item.label.Guías": { - "message": "Guías", + "message": "Guides", "description": "Navbar item with label Guías" }, "item.label.Blog": { diff --git a/apps/lab/src/components/HomepageFeatures/index.tsx b/apps/lab/src/components/HomepageFeatures/index.tsx index 2c3dd93..d46335b 100644 --- a/apps/lab/src/components/HomepageFeatures/index.tsx +++ b/apps/lab/src/components/HomepageFeatures/index.tsx @@ -1,57 +1,66 @@ import React, { type ReactNode } from 'react'; import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import Translate from '@docusaurus/Translate'; import Heading from '@theme/Heading'; import styles from './styles.module.css'; type FeatureItem = { - title: string; + title: ReactNode; Svg: React.ComponentType>; description: ReactNode; + to?: string; + href?: string; }; const FeatureList: FeatureItem[] = [ { - title: 'Easy to Use', + title: Guías, + to: '/docs/intro', Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default, description: ( - <> - Docusaurus was designed from the ground up to be easily installed and - used to get your website up and running quickly. - + + Documentación sobre Clean Architecture, testing y patrones de i18n para + proyectos Next.js y TypeScript. + ), }, { - title: 'Focus on What Matters', + title: Blog, + to: '/blog', Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default, description: ( - <> - Docusaurus lets you focus on your docs, and we'll do the chores. Go - ahead and move your docs into the docs directory. - + + Artículos técnicos, experimentos y casos de estudio sobre desarrollo, + SaaS y DevOps. + ), }, { - title: 'Powered by React', + title: Portfolio, + href: 'https://desenvolupadormaster.vercel.app', Svg: require('@site/static/img/undraw_docusaurus_react.svg').default, description: ( - <> - Extend or customize your website layout by reusing React. Docusaurus can - be extended while reusing the same header and footer. - + + Proyectos en producción, skills y contacto. El CV principal del Lab. + ), }, ]; -function Feature({title, Svg, description}: FeatureItem) { +function Feature({title, to, href, Svg, description}: FeatureItem) { + const linkProps = to ? {to} : {href}; return (
-
- -
-
- {title} -

{description}

-
+ +
+ +
+
+ {title} +

{description}

+
+
); } diff --git a/apps/lab/src/components/HomepageFeatures/styles.module.css b/apps/lab/src/components/HomepageFeatures/styles.module.css index b248eb2..677ffb0 100644 --- a/apps/lab/src/components/HomepageFeatures/styles.module.css +++ b/apps/lab/src/components/HomepageFeatures/styles.module.css @@ -5,6 +5,19 @@ width: 100%; } +.featureLink { + display: block; + color: inherit; + text-decoration: none; + transition: opacity 0.2s ease; +} + +.featureLink:hover { + color: inherit; + text-decoration: none; + opacity: 0.85; +} + .featureSvg { height: 200px; width: 200px; diff --git a/apps/lab/src/pages/index.module.css b/apps/lab/src/pages/index.module.css index 9f71a5d..4c6d72e 100644 --- a/apps/lab/src/pages/index.module.css +++ b/apps/lab/src/pages/index.module.css @@ -20,4 +20,9 @@ display: flex; align-items: center; justify-content: center; + gap: 1rem; +} + +.buttonsColumn { + flex-wrap: wrap; } diff --git a/apps/lab/src/pages/index.tsx b/apps/lab/src/pages/index.tsx index f6cde46..920e677 100644 --- a/apps/lab/src/pages/index.tsx +++ b/apps/lab/src/pages/index.tsx @@ -2,6 +2,7 @@ import React, { type ReactNode } from 'react'; import clsx from 'clsx'; import Link from '@docusaurus/Link'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import Translate from '@docusaurus/Translate'; import Layout from '@theme/Layout'; import HomepageFeatures from '@site/src/components/HomepageFeatures'; import Heading from '@theme/Heading'; @@ -16,12 +17,19 @@ function HomepageHeader() { {siteConfig.title} -

{siteConfig.tagline}

-
+

+ {siteConfig.tagline} +

+
- Docusaurus Tutorial - 5min ⏱️ + Ver Guías + + + Ir al Blog
@@ -33,8 +41,8 @@ export default function Home(): ReactNode { const {siteConfig} = useDocusaurusContext(); return ( + title={siteConfig.title} + description={siteConfig.tagline}>
diff --git a/apps/portfolio/messages/en.json b/apps/portfolio/messages/en.json index e7f7328..256c9f8 100644 --- a/apps/portfolio/messages/en.json +++ b/apps/portfolio/messages/en.json @@ -182,6 +182,10 @@ "experienceLabel": "{years}+ years", "usageLabel": "{percentage}% repo usage", "projectsLabel": "{count} projects", + "aria": { + "openCategory": "Open details for {name}", + "closeModal": "Close modal" + }, "categories": [ { "id": "backend", diff --git a/apps/portfolio/messages/es.json b/apps/portfolio/messages/es.json index cfa0777..73d20dd 100644 --- a/apps/portfolio/messages/es.json +++ b/apps/portfolio/messages/es.json @@ -182,6 +182,10 @@ "experienceLabel": "{years}+ años", "usageLabel": "{percentage}% uso repos", "projectsLabel": "{count} proyectos", + "aria": { + "openCategory": "Abrir detalles de {name}", + "closeModal": "Cerrar modal" + }, "categories": [ { "id": "backend", diff --git a/apps/portfolio/package.json b/apps/portfolio/package.json index 94b86a3..a1c58f8 100644 --- a/apps/portfolio/package.json +++ b/apps/portfolio/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev --port 3002", + "dev:lab": "pnpm --filter lab dev", "build": "next build", "start": "next start", "start:dev": "next start --port 3002", diff --git a/apps/portfolio/src/features/skills/Skills.tsx b/apps/portfolio/src/features/skills/Skills.tsx index 34047a1..3839a8c 100644 --- a/apps/portfolio/src/features/skills/Skills.tsx +++ b/apps/portfolio/src/features/skills/Skills.tsx @@ -1,20 +1,13 @@ 'use client'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { m } from 'framer-motion'; import { useLocale, useTranslations } from 'next-intl'; import type { SkillCategory, SkillSummary } from '@/shared/constants/skills'; import { fadeInUp, staggerContainer } from '@/shared/utils/motion'; - -const CATEGORY_ICONS: Record = { - backend: '🛠️', - frontend: '🎨', - databases: '🗄️', - devops: '⚙️', - 'ai-automation': '🤖', - testing: '✅', -}; +import { SkillCard } from './components/SkillCard'; +import { SkillModal } from './components/SkillModal'; const SEGMENT_COLORS = ['#38bdf8', '#34d399', '#fbbf24', '#f97316', '#a855f7', '#ef4444']; @@ -23,6 +16,8 @@ export function Skills() { const tSkills = useTranslations('skills'); const numberLocale = locale === 'es' ? 'es-ES' : 'en-US'; + const [selectedCategory, setSelectedCategory] = useState(null); + const categories = tSkills.raw('categories') as SkillCategory[]; const summary = tSkills.raw('summary') as SkillSummary; const languages = summary.mainLanguagesShare; @@ -192,96 +187,16 @@ export function Skills() { - {/* Skill categories */} + {/* Skill category cards */} {categories.map((category) => ( - -
-
-

- {CATEGORY_ICONS[category.id]} {category.name} -

-

{category.summary}

-
- - {tSkills('experienceLabel', { years: category.experienceYears })} - -
- -
    - {category.skills.map((skill) => { - const usageLabel = - skill.usagePercentage !== undefined - ? tSkills('usageLabel', { - percentage: percentFormatter.format(skill.usagePercentage), - }) - : null; - - return ( -
  • -
    -

    {skill.name}

    - {usageLabel && ( - - {usageLabel} - - )} -
    -

    {skill.description}

    - {(skill.usagePercentage !== undefined || - skill.experienceYears !== undefined || - skill.experienceProjects !== undefined) && ( -
    - {skill.usagePercentage !== undefined && ( -
    -
    -
    -
    - )} - {skill.experienceYears !== undefined && ( - - {tSkills('experienceLabel', { years: skill.experienceYears })} - - )} - {skill.experienceProjects !== undefined && ( - - {tSkills('projectsLabel', { count: skill.experienceProjects })} - - )} -
    - )} - {skill.keywords && ( -
    - {skill.keywords.map((keyword) => ( - - {keyword} - - ))} -
    - )} -
  • - ); - })} -
-
+ ))}
+ + {/* Detail modal */} + setSelectedCategory(null)} /> ); } diff --git a/apps/portfolio/src/features/skills/components/SkillCard.tsx b/apps/portfolio/src/features/skills/components/SkillCard.tsx new file mode 100644 index 0000000..2194668 --- /dev/null +++ b/apps/portfolio/src/features/skills/components/SkillCard.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { m } from 'framer-motion'; +import { useTranslations } from 'next-intl'; + +import { fadeInUp } from '@/shared/utils/motion'; +import type { SkillCategory } from '@/shared/constants/skills'; + +export const CATEGORY_ICONS: Record = { + backend: '🛠️', + frontend: '🎨', + databases: '🗄️', + devops: '⚙️', + 'ai-automation': '🤖', + testing: '✅', + leadership: '🤝', +}; + +const MAX_VISIBLE_SKILLS = 3; + +interface SkillCardProps { + category: SkillCategory; + onOpen: (category: SkillCategory) => void; +} + +export function SkillCard({ category, onOpen }: SkillCardProps) { + const t = useTranslations('skills'); + + const visibleSkills = category.skills.slice(0, MAX_VISIBLE_SKILLS); + const overflowCount = category.skills.length - MAX_VISIBLE_SKILLS; + + return ( + onOpen(category)} + role="article" + aria-label={t('aria.openCategory', { name: category.name })} + > + {/* Default card content */} +
+ {/* Header: icon + name + experience badge */} +
+

+ {CATEGORY_ICONS[category.id]} {category.name} +

+ + {t('experienceLabel', { years: category.experienceYears })} + +
+ + {/* Summary truncated to 2 lines */} +

+ {category.summary} +

+ + {/* Skill name pills: first 3 + overflow */} +
+ {visibleSkills.map((skill) => ( + + {skill.name} + + ))} + {overflowCount > 0 && ( + + +{overflowCount} + + )} +
+
+ + {/* Hover overlay */} +
+

+ {CATEGORY_ICONS[category.id]} {category.name} +

+
    + {category.skills.map((skill) => ( +
  • +
    + {skill.name} + {skill.usagePercentage !== undefined && ( + + {skill.usagePercentage}% + + )} +
    + {skill.usagePercentage !== undefined && ( +
    +
    + )} +
  • + ))} +
+

Ver detalles →

+
+
+ ); +} diff --git a/apps/portfolio/src/features/skills/components/SkillModal.tsx b/apps/portfolio/src/features/skills/components/SkillModal.tsx new file mode 100644 index 0000000..a0c9139 --- /dev/null +++ b/apps/portfolio/src/features/skills/components/SkillModal.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useMemo } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; + +import { Modal } from '@/shared/components/ui/Modal'; +import type { SkillCategory } from '@/shared/constants/skills'; +import { CATEGORY_ICONS } from './SkillCard'; + +interface SkillModalProps { + category: SkillCategory | null; + onClose: () => void; +} + +export function SkillModal({ category, onClose }: SkillModalProps) { + const t = useTranslations('skills'); + const locale = useLocale(); + const numberLocale = locale === 'es' ? 'es-ES' : 'en-US'; + + const percentFormatter = useMemo( + () => + new Intl.NumberFormat(numberLocale, { + maximumFractionDigits: 1, + minimumFractionDigits: 0, + }), + [numberLocale] + ); + + if (!category) return null; + + return ( + + {/* Header */} +
+
+

+ {CATEGORY_ICONS[category.id]} {category.name} +

+ + {t('experienceLabel', { years: category.experienceYears })} + +
+ +
+ + {/* Summary */} +
+

{category.summary}

+
+ + {/* Skill list */} +
    + {category.skills.map((skill) => { + const usageLabel = + skill.usagePercentage !== undefined + ? t('usageLabel', { + percentage: percentFormatter.format(skill.usagePercentage), + }) + : null; + + return ( +
  • +
    +

    {skill.name}

    + {usageLabel && ( + {usageLabel} + )} +
    + + {skill.usagePercentage !== undefined && ( +
    +
    +
    +
    + )} + +

    {skill.description}

    + + {(skill.experienceYears !== undefined || skill.experienceProjects !== undefined) && ( +
    + {skill.experienceYears !== undefined && ( + {t('experienceLabel', { years: skill.experienceYears })} + )} + {skill.experienceProjects !== undefined && ( + {t('projectsLabel', { count: skill.experienceProjects })} + )} +
    + )} + + {skill.keywords && skill.keywords.length > 0 && ( +
    + {skill.keywords.map((keyword) => ( + + {keyword} + + ))} +
    + )} +
  • + ); + })} +
+
+ ); +} diff --git a/apps/portfolio/src/shared/constants/skills.ts b/apps/portfolio/src/shared/constants/skills.ts index f7729eb..e9cf832 100644 --- a/apps/portfolio/src/shared/constants/skills.ts +++ b/apps/portfolio/src/shared/constants/skills.ts @@ -8,7 +8,7 @@ export interface SkillItem { } export interface SkillCategory { - id: 'backend' | 'frontend' | 'databases' | 'devops' | 'ai-automation' | 'testing'; + id: 'backend' | 'frontend' | 'databases' | 'devops' | 'ai-automation' | 'testing' | 'leadership'; name: string; summary: string; experienceYears: number; diff --git a/apps/portfolio/tests/features/skills/Skills.test.tsx b/apps/portfolio/tests/features/skills/Skills.test.tsx index fec0ec6..d687821 100644 --- a/apps/portfolio/tests/features/skills/Skills.test.tsx +++ b/apps/portfolio/tests/features/skills/Skills.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { NextIntlClientProvider } from 'next-intl'; @@ -6,7 +6,6 @@ import { NextIntlClientProvider } from 'next-intl'; import esMessages from '../../../messages/es.json'; import enMessages from '../../../messages/en.json'; import { Skills } from '@/features/skills/Skills'; -import type { SkillItem } from '@/shared/constants/skills'; function renderSkills(locale: 'es' | 'en' = 'es') { const messages = locale === 'es' ? esMessages : enMessages; @@ -36,150 +35,68 @@ describe('Skills Section', () => { expect(articles.length).toBe(esMessages.skills.categories.length); }); - it('should display skills from constants', () => { + it('should display category names on cards', () => { renderSkills(); esMessages.skills.categories.forEach((category) => { - category.skills.slice(0, 2).forEach((skill) => { - expect( - screen.getAllByText(new RegExp(`^${skill.name}$`, 'i'), { exact: false }).length - ).toBeGreaterThan(0); - }); + expect(screen.getAllByText(new RegExp(category.name, 'i')).length).toBeGreaterThan(0); }); }); - it('should show usage information when available', () => { + it('should display experience badges on cards', () => { renderSkills(); - const usage = screen.getAllByText(/% uso repos/i); - expect(usage.length).toBeGreaterThan(0); + const experienceBadges = screen.getAllByText(/\d+\+ años/i); + expect(experienceBadges.length).toBeGreaterThan(0); }); - describe('Skill Metadata Conditionals', () => { - it('should render usage percentage label when skill has usagePercentage', () => { - renderSkills(); - // Find skills with usagePercentage defined - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillWithUsage = skillsData.find((skill) => skill.usagePercentage !== undefined); - - if (skillWithUsage) { - // Should display the percentage somewhere - const usageTexts = screen.queryAllByText(/% uso repos/i); - expect(usageTexts.length).toBeGreaterThan(0); - } - }); + it('should NOT display skill descriptions in cards (they are in the modal)', () => { + renderSkills(); + // Skill descriptions should not be visible until a card is clicked + const firstCategory = esMessages.skills.categories[0]; + const firstSkillDescription = firstCategory.skills[0].description; + expect(screen.queryByText(firstSkillDescription)).not.toBeInTheDocument(); + }); - it('should not render usage label when skill has no usagePercentage', () => { - renderSkills(); - // All skills in the test data should have usagePercentage - // This tests the conditional logic by verifying proper rendering - const { container } = renderSkills(); - expect(container).toBeTruthy(); - }); + it('should open modal with skill details when a card is clicked', () => { + renderSkills(); + const firstCategory = esMessages.skills.categories[0]; + const articles = screen.getAllByRole('article'); + fireEvent.click(articles[0]); - it('should render experience years when skill has experienceYears', () => { - renderSkills(); - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillWithYears = skillsData.find((skill) => skill.experienceYears !== undefined); - - if (skillWithYears) { - // Should find year labels - const yearLabels = screen.queryAllByText(/años/i); - expect(yearLabels.length).toBeGreaterThan(0); - } - }); + // Modal should now show the first skill's description + const firstSkillDescription = firstCategory.skills[0].description; + expect(screen.getByText(firstSkillDescription)).toBeInTheDocument(); + }); - it('should render experience projects when skill has experienceProjects', () => { - renderSkills(); - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillWithProjects = skillsData.find((skill) => skill.experienceProjects !== undefined); - - if (skillWithProjects) { - // Should find project count labels - const projectLabels = screen.queryAllByText(/proyectos/i); - expect(projectLabels.length).toBeGreaterThan(0); - } - }); + it('should close modal when Escape is pressed', () => { + renderSkills(); + const articles = screen.getAllByRole('article'); + fireEvent.click(articles[0]); - it('should render all metadata when skill has all fields', () => { - renderSkills(); - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const fullSkill = skillsData.find( - (skill) => - skill.usagePercentage !== undefined && - skill.experienceYears !== undefined && - skill.experienceProjects !== undefined - ); - - if (fullSkill) { - // Should display usage, years, and projects - expect(screen.queryAllByText(/% uso repos/i).length).toBeGreaterThan(0); - expect(screen.queryAllByText(/años/i).length).toBeGreaterThan(0); - expect(screen.queryAllByText(/proyectos/i).length).toBeGreaterThan(0); - } - }); + // Modal is open — now close with Escape + fireEvent.keyDown(document, { key: 'Escape' }); - it('should not render metadata section when skill has no metadata fields', () => { - // This test verifies the compound conditional (lines 236-238) - renderSkills(); - const { container } = renderSkills(); - - // Count how many skills have metadata sections - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillsWithMetadata = skillsData.filter( - (skill) => - skill.usagePercentage !== undefined || - skill.experienceYears !== undefined || - skill.experienceProjects !== undefined - ); - - // All skills in the data should have at least some metadata - expect(skillsWithMetadata.length).toBeGreaterThan(0); - expect(container).toBeTruthy(); - }); + const firstCategory = esMessages.skills.categories[0]; + const firstSkillDescription = firstCategory.skills[0].description; + expect(screen.queryByText(firstSkillDescription)).not.toBeInTheDocument(); }); - describe('Keywords', () => { - it('should render keywords when skill has keywords array', () => { - renderSkills(); - const skillsData = esMessages.skills.categories.flatMap((cat) => cat.skills) as SkillItem[]; - const skillWithKeywords = skillsData.find( - (skill) => skill.keywords && skill.keywords.length > 0 - ); - - if (skillWithKeywords) { - // Should render the keyword badges - skillWithKeywords.keywords?.forEach((keyword: string) => { - const keywordElements = screen.queryAllByText(keyword); - expect(keywordElements.length).toBeGreaterThan(0); - }); - } - }); - - it('should not render keywords section when skill has no keywords', () => { - renderSkills(); - // This tests the conditional on line 265 - const { container } = renderSkills(); - expect(container).toBeTruthy(); - }); + it('should show usage information in modal when available', () => { + renderSkills(); + const articles = screen.getAllByRole('article'); + fireEvent.click(articles[0]); + const usage = screen.getAllByText(/% uso repos/i); + expect(usage.length).toBeGreaterThan(0); }); describe('Donut Chart', () => { it('should render donut chart with language distribution', () => { renderSkills(); - // Chart should show percentages const percentages = screen.getAllByText(/% del stack/i); expect(percentages.length).toBeGreaterThan(0); }); - it('should handle empty languages array gracefully', () => { - // This tests the edge case when languages array is empty - renderSkills(); - const { container } = renderSkills(); - expect(container).toBeTruthy(); - }); - it('should calculate total usage correctly', () => { renderSkills(); - // Should display the total repositories count const reposText = screen.getByText(/repos analizados/i); expect(reposText).toBeInTheDocument(); }); @@ -187,15 +104,12 @@ describe('Skills Section', () => { describe('Localization', () => { it('should format percentages according to locale', () => { - // Spanish locale const { container: esContainer } = renderSkills('es'); const esContent = esContainer.textContent || ''; - // English locale const { container: enContainer } = renderSkills('en'); const enContent = enContainer.textContent || ''; - // Both should have percentage content expect(esContent).toMatch(/%/); expect(enContent).toMatch(/%/); }); diff --git a/apps/portfolio/tests/features/skills/components/SkillCard.test.tsx b/apps/portfolio/tests/features/skills/components/SkillCard.test.tsx new file mode 100644 index 0000000..42c9e6c --- /dev/null +++ b/apps/portfolio/tests/features/skills/components/SkillCard.test.tsx @@ -0,0 +1,122 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; + +import esMessages from '../../../../messages/es.json'; +import { SkillCard } from '@/features/skills/components/SkillCard'; +import type { SkillCategory } from '@/shared/constants/skills'; + +const testCategory: SkillCategory = { + id: 'backend', + name: 'Backend', + summary: 'Migraciones ERP a SaaS y APIs REST de alto rendimiento', + experienceYears: 6, + skills: [ + { + name: 'PHP', + description: 'Lenguaje principal en múltiples repositorios', + usagePercentage: 31, + experienceYears: 6, + experienceProjects: 18, + keywords: ['Symfony', 'Laravel', 'OOP'], + }, + { + name: 'Symfony 7', + description: 'Framework para APIs REST', + usagePercentage: 25, + keywords: ['DDD', 'CQRS'], + }, + { + name: 'Node.js', + description: 'Servicios backend asíncronos', + usagePercentage: 15, + }, + { + name: 'API Platform', + description: 'Framework para APIs RESTful y GraphQL', + usagePercentage: 10, + }, + ], +}; + +function renderCard(category = testCategory, onOpen = vi.fn()) { + return render( + + + + ); +} + +describe('SkillCard', () => { + describe('Rendering', () => { + it('renders the category name', () => { + renderCard(); + expect(screen.getAllByText(/Backend/i).length).toBeGreaterThan(0); + }); + + it('renders the experience badge', () => { + renderCard(); + expect(screen.getAllByText(/6\+ años/i).length).toBeGreaterThan(0); + }); + + it('renders the summary truncated', () => { + renderCard(); + expect(screen.getByText(testCategory.summary)).toBeInTheDocument(); + }); + + it('renders max 3 skill name pills', () => { + renderCard(); + // First 3 skill names visible as pills + expect(screen.getAllByText('PHP').length).toBeGreaterThan(0); + expect(screen.getAllByText('Symfony 7').length).toBeGreaterThan(0); + expect(screen.getAllByText('Node.js').length).toBeGreaterThan(0); + }); + + it('shows overflow badge when there are more than 3 skills', () => { + renderCard(); + // 4 skills - 3 visible = 1 more + expect(screen.getByText('+1')).toBeInTheDocument(); + }); + + it('does not show overflow badge when 3 or fewer skills', () => { + const fewSkills: SkillCategory = { + ...testCategory, + skills: testCategory.skills.slice(0, 3), + }; + renderCard(fewSkills); + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); + }); + + it('renders all skills in the hover overlay', () => { + renderCard(); + // All 4 skills should appear (some in default view, all in overlay) + expect(screen.getAllByText('API Platform').length).toBeGreaterThan(0); + }); + }); + + describe('Interaction', () => { + it('calls onOpen with the category when clicked', () => { + const onOpen = vi.fn(); + renderCard(testCategory, onOpen); + const article = screen.getByRole('article'); + fireEvent.click(article); + expect(onOpen).toHaveBeenCalledOnce(); + expect(onOpen).toHaveBeenCalledWith(testCategory); + }); + + it('is a clickable article element', () => { + renderCard(); + const article = screen.getByRole('article'); + expect(article).toHaveClass('cursor-pointer'); + }); + }); + + describe('Accessibility', () => { + it('has an aria-label describing the category', () => { + renderCard(); + const article = screen.getByRole('article'); + expect(article).toHaveAttribute('aria-label'); + expect(article.getAttribute('aria-label')).toContain('Backend'); + }); + }); +}); diff --git a/apps/portfolio/tests/features/skills/components/SkillModal.test.tsx b/apps/portfolio/tests/features/skills/components/SkillModal.test.tsx new file mode 100644 index 0000000..dbccb46 --- /dev/null +++ b/apps/portfolio/tests/features/skills/components/SkillModal.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; + +import esMessages from '../../../../messages/es.json'; +import { SkillModal } from '@/features/skills/components/SkillModal'; +import type { SkillCategory } from '@/shared/constants/skills'; + +const testCategory: SkillCategory = { + id: 'backend', + name: 'Backend', + summary: 'Migraciones ERP a SaaS y APIs REST de alto rendimiento', + experienceYears: 6, + skills: [ + { + name: 'PHP', + description: 'Lenguaje principal en múltiples repositorios', + usagePercentage: 31, + experienceYears: 6, + experienceProjects: 18, + keywords: ['Symfony', 'Laravel', 'OOP'], + }, + { + name: 'Symfony 7', + description: 'Framework para APIs REST', + usagePercentage: 25, + keywords: ['DDD', 'CQRS'], + }, + ], +}; + +function renderModal(category: SkillCategory | null = testCategory, onClose = vi.fn()) { + return render( + + + + ); +} + +describe('SkillModal', () => { + describe('Rendering', () => { + it('renders nothing when category is null', () => { + const { container } = renderModal(null); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders category name when open', () => { + renderModal(); + expect(screen.getAllByText(/Backend/i).length).toBeGreaterThan(0); + }); + + it('renders category summary', () => { + renderModal(); + expect(screen.getByText(testCategory.summary)).toBeInTheDocument(); + }); + + it('renders experience badge', () => { + renderModal(); + expect(screen.getAllByText(/6\+ años/i).length).toBeGreaterThan(0); + }); + + it('renders all skill names', () => { + renderModal(); + expect(screen.getByText('PHP')).toBeInTheDocument(); + expect(screen.getByText('Symfony 7')).toBeInTheDocument(); + }); + + it('renders skill descriptions', () => { + renderModal(); + expect(screen.getByText('Lenguaje principal en múltiples repositorios')).toBeInTheDocument(); + }); + + it('renders skill keywords', () => { + renderModal(); + expect(screen.getByText('Symfony')).toBeInTheDocument(); + expect(screen.getByText('Laravel')).toBeInTheDocument(); + expect(screen.getByText('OOP')).toBeInTheDocument(); + }); + + it('renders usage percentage labels', () => { + renderModal(); + const usageLabels = screen.getAllByText(/% uso repos/i); + expect(usageLabels.length).toBeGreaterThan(0); + }); + + it('renders experience years for skills that have them', () => { + renderModal(); + const yearLabels = screen.queryAllByText(/años/i); + expect(yearLabels.length).toBeGreaterThan(0); + }); + + it('renders project count for skills that have them', () => { + renderModal(); + expect(screen.getByText(/18 proyectos/i)).toBeInTheDocument(); + }); + + it('has role="dialog" and aria-modal', () => { + renderModal(); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + }); + }); + + describe('Close interactions', () => { + it('calls onClose when close button is clicked', () => { + const onClose = vi.fn(); + renderModal(testCategory, onClose); + const closeBtn = screen.getByRole('button', { name: /cerrar modal/i }); + fireEvent.click(closeBtn); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when Escape key is pressed', () => { + const onClose = vi.fn(); + renderModal(testCategory, onClose); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when backdrop is clicked', () => { + const onClose = vi.fn(); + renderModal(testCategory, onClose); + const backdrop = document.querySelector('.fixed.inset-0'); + expect(backdrop).toBeTruthy(); + fireEvent.click(backdrop!); + expect(onClose).toHaveBeenCalledOnce(); + }); + }); +});