diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000..5f9de3e --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,167 @@ +# GitHub Actions Workflows + +Este directorio contiene los workflows de CI/CD para el proyecto Base Angular App. + +## 📁 Workflows Disponibles + +### 1. `ci.yml` - Continuous Integration +**Ejecuta:** En cada push o pull request a `main` o `develop` + +**Tareas:** +- ✅ Instala dependencias +- 🔍 Lint del código (si está configurado) +- 🧪 Ejecuta tests +- 🏗️ Construye la aplicación para producción +- 📊 Muestra información del build +- 📦 Genera artifacts (dist + deployment package) +- 💾 Guarda artifacts por 30 días + +**Artifacts generados:** +- `angular-app-dist-{sha}`: Carpeta dist completa +- `deployment-package-{sha}`: ZIP listo para desplegar + +### 2. `deploy.yml` - Prepare Release +**Ejecuta:** +- Manualmente desde GitHub Actions UI +- Automáticamente al crear un tag (v1.0.0, v2.1.0, etc.) + +**Tareas:** +- 🏗️ Build de producción +- 📦 Genera paquetes TAR.GZ y ZIP +- 🔐 Crea checksums SHA256 +- 📋 Incluye nginx.conf +- 🚀 Crea GitHub Release (si es un tag) +- 💾 Guarda artifacts por 90 días + +## 🔐 Configuración (Opcional) + +Los workflows de CI funcionan sin configuración adicional. No necesitas configurar secrets a menos que quieras automatizar despliegues a servicios específicos. + +### Para Auto-Deploy (Opcional) + +Si quieres desplegar automáticamente a servicios cloud, puedes agregar secrets según el servicio: + +**Digital Ocean:** +- `DO_API_TOKEN`: Token de API de Digital Ocean + +**AWS:** +- `AWS_ACCESS_KEY_ID`: Access Key de AWS +- `AWS_SECRET_ACCESS_KEY`: Secret Key de AWS +- `AWS_REGION`: Región (ej: us-east-1) + +**Azure:** +- `AZURE_CREDENTIALS`: JSON con credenciales de service principal + +**Vercel/Netlify:** +- Conecta directamente desde su dashboard (no requiere secrets) + +## 🚀 Cómo Usar + +### Ejecutar Tests Automáticamente +```bash +# Los tests se ejecutan automáticamente en cada push/PR +git push origin develop +``` + +### Desplegar Manualmente +1. Ve a la pestaña "Actions" en GitHub +2. Selecciona "CD - Deploy to Server" +3. Click en "Run workflow" +4. Selecciona la rama y confirma + +### Desplegar Automáticamente +```bash +# Simplemente haz push a main +git push origin main +```1. Crear Pull Request (Desarrollo) +```bash +# Crea una rama feature +git checkout -b feature/nueva-funcionalidad + +# Haz tus cambios y commity un resumen al final +- Recibirás notificaciones por email si algún workflow falla +- Los artifacts se pueden descargar desde la página del workflow + +## 🌐 Opciones de Deployment + +Ver [DEPLOYMENT-CLOUD.md](../../DEPLOYMENT-CLOUD.md) para guías detalladas de deployment en: + +- 🌊 **Digital Ocean** (App Platform o Droplet) +- ☁️ **AWS S3 + CloudFront** +- 🔷 **Azure Static Web Apps** +- ▲ **Vercel** (recomendado para simplicidad) +- 🌐 **Netlify** +- 📄 **GitHub Pages** (gratis para repos públicos) + +## 🔧 Troubleshooting + +### Los tests fallan en GitHub pero pasan localmente +- Verifica que no dependas de configuraciones locales +- Asegúrate de que todos los assets de test estén en el repo +- Revisa las versiones de Node.js (workflow usa 20.x) + +### Build falla +- Verifica que todas las dependencias estén en `package.json` +- Revisa los logs en la pestaña Actions +- Prueba el build localmente: `npm ci && npm run build` + +### No se generan artifacts +- Verifica que el build complete exitosamente +- Los artifacts solo se generan si todos los pasos anteriores pasan +- Artifacts de CI duran 30 días, artifacts de release duran 90 días + +### Error al crear Release +- Solo se crean releases automáticas para tags que empiecen con "v" +- Verifica que tengas permisos de escritura en el repo +- El token `GITHUB_TOKEN` debe tener permiso para crear releases + +## 📝 Notas + +- **Artifacts de CI**: Se mantienen por 30 días +- **Artifacts de Release**: Se mantienen por 90 días +- **GitHub Releases**: Permanentes (hasta que se borren manualmente) +- Los workflows de CI se ejecutan en pull requests sin acceso a secrets (por seguridad) +- El deployment es siempre manual o mediante releases para mejor control +git pull origin main +git tag -a v1.0.0 -m "Release version 1.0.0" +git push origin v1.0.0 +``` + +Esto: +- 🏗️ Construye la aplicación +- 📦 Crea paquetes de deployment +- 🚀 Genera un GitHub Release con todos los assets +- 📋 Incluye instrucciones de deploy para cada plataforma + +### 4. Descargar y Desplegar + +**Opción A: Desde GitHub Release (para tags)** +1. Ve a la sección "Releases" en GitHub +2. Descarga el archivo que necesites: + - `.tar.gz` para Linux/Mac + - `.zip` para Windows +3. Sigue las instrucciones de deployment en [DEPLOYMENT-CLOUD.md](../../DEPLOYMENT-CLOUD.md) + +**Opción B: Desde Artifacts (cualquier commit)** +1. Ve a la pestaña "Actions" en GitHub +2. Selecciona el workflow ejecutado +3. Descarga el artifact `deployment-package-{sha}` +4. Úsalo para desplegar en tu servicio cloud preferido + +### 5. Deploy Manual (sin tag) +```bash +# Ejecuta el workflow manualmente +# Ve a Actions → CD - Prepare Release → Run workflow +# Especifica una versión (ej: v1.1.0-beta) de tu archivo `nginx.conf` +- Ejecuta `sudo nginx -t` en el servidor para verificar + +### Build falla +- Verifica que todas las dependencias estén en `package.json` +- Revisa los logs en la pestaña Actions para más detalles + +## 📝 Notas + +- Los artifacts de build se mantienen por 7 días +- Cada despliegue crea un backup automático con timestamp +- El workflow de CI no despliega, solo valida el código +- El workflow de CD requiere que CI pase exitosamente diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3c3fd8f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,88 @@ +name: CI - Build and Test + +on: + push: + branches: [ main, develop, release/* ] + pull_request: + branches: [ main, develop, release/* ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: TypeScript syntax check + run: npm run typecheck + + - name: Run tests + run: npm test -- --watch=false + + - name: Build application for production + run: npm run build + + - name: Display build output structure + run: | + echo " Build completed successfully!" + echo "Build output structure:" + ls -lah dist/ + if [ -d "dist/base-angular-app/browser" ]; then + echo "Browser bundle size:" + du -sh dist/base-angular-app/browser + fi + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: angular-app-dist-${{ github.sha }} + path: dist/ + retention-days: 30 + compression-level: 9 + + - name: Create deployment package + run: | + cd dist + zip -r ../angular-app-${{ github.sha }}.zip . + cd .. + echo " Package size: $(du -h angular-app-${{ github.sha }}.zip | cut -f1)" + + - name: Upload deployment package + uses: actions/upload-artifact@v4 + with: + name: deployment-package-${{ github.sha }} + path: angular-app-${{ github.sha }}.zip + retention-days: 30 + + - name: Summary + run: | + echo "### Build Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Commit:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "- **Branch:** ${{ github.ref_name }}" >> $GITHUB_STEP_SUMMARY + echo "- **Node Version:** ${{ matrix.node-version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo " **Artifacts generated:**" >> $GITHUB_STEP_SUMMARY + echo "- angular-app-dist-${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "- deployment-package-${{ github.sha }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo " **Ready to deploy to:**" >> $GITHUB_STEP_SUMMARY + echo "- Digital Ocean" >> $GITHUB_STEP_SUMMARY + echo "- AWS S3 + CloudFront" >> $GITHUB_STEP_SUMMARY + echo "- Azure Static Web Apps" >> $GITHUB_STEP_SUMMARY + echo "- Vercel" >> $GITHUB_STEP_SUMMARY + echo "- Netlify" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0a316d8 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,142 @@ +name: CD - Prepare Release + +# Este workflow prepara releases que pueden desplegarse en cualquier servicio cloud +# (Digital Ocean, AWS, Azure, Vercel, Netlify, etc.) + +on: + workflow_dispatch: # Ejecución manual desde GitHub UI + inputs: + version: + description: 'Release version (e.g., v1.0.0)' + required: false + default: 'latest' + push: + tags: + - 'v*' # Se activa cuando creas un tag como v1.0.0 + +jobs: + prepare-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build for production + run: npm run build + + - name: Get version + id: version + run: | + if [ "${{ github.event.inputs.version }}" != "" ]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + elif [ "${{ github.ref_type }}" == "tag" ]; then + echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + else + echo "version=latest" >> $GITHUB_OUTPUT + fi + + - name: Create deployment package + run: | + cd dist + tar -czf ../angular-app-${{ steps.version.outputs.version }}.tar.gz . + zip -r ../angular-app-${{ steps.version.outputs.version }}.zip . + cd .. + echo "📦 Package created:" + ls -lh angular-app-${{ steps.version.outputs.version }}.* + + - name: Generate checksums + run: | + sha256sum angular-app-${{ steps.version.outputs.version }}.tar.gz > checksums.txt + sha256sum angular-app-${{ steps.version.outputs.version }}.zip >> checksums.txt + cat checksums.txt + + - name: Upload release artifacts + uses: actions/upload-artifact@v4 + with: + name: release-${{ steps.version.outputs.version }} + path: | + angular-app-${{ steps.version.outputs.version }}.tar.gz + angular-app-${{ steps.version.outputs.version }}.zip + checksums.txt + nginx.conf + retention-days: 90 + + - name: Create GitHub Release + if: github.ref_type == 'tag' + uses: softprops/action-gh-release@v1 + with: + files: | + angular-app-${{ steps.version.outputs.version }}.tar.gz + angular-app-${{ steps.version.outputs.version }}.zip + checksums.txt + nginx.conf + body: | + ## 🚀 Release ${{ steps.version.outputs.version }} + + ### 📦 Assets + - `angular-app-${{ steps.version.outputs.version }}.tar.gz` - Producción (Linux/Mac) + - `angular-app-${{ steps.version.outputs.version }}.zip` - Producción (Windows/Universal) + - `nginx.conf` - Configuración de Nginx + - `checksums.txt` - SHA256 checksums + + ### 🌐 Deploy Options + + **Digital Ocean App Platform:** + ```bash + doctl apps create --spec .do/app.yaml + ``` + + **Digital Ocean Droplet:** + ```bash + scp angular-app-${{ steps.version.outputs.version }}.tar.gz user@droplet:/tmp/ + ssh user@droplet + cd /var/www/html + tar -xzf /tmp/angular-app-${{ steps.version.outputs.version }}.tar.gz + ``` + + **AWS S3 + CloudFront:** + ```bash + unzip angular-app-${{ steps.version.outputs.version }}.zip + aws s3 sync . s3://your-bucket-name/ --delete + aws cloudfront create-invalidation --distribution-id YOUR_DIST_ID --paths "/*" + ``` + + **Azure Static Web Apps:** + ```bash + az staticwebapp deploy --name your-app-name --source angular-app-${{ steps.version.outputs.version }}.zip + ``` + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + echo "### 🎉 Release Preparado" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Versión:** ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 📦 Archivos Generados" >> $GITHUB_STEP_SUMMARY + echo \`\`\`" >> $GITHUB_STEP_SUMMARY + ls -lh angular-app-* checksums.txt nginx.conf + echo \`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🚀 Próximos Pasos" >> $GITHUB_STEP_SUMMARY + echo "1. Descarga los artifacts desde la pestaña Actions" >> $GITHUB_STEP_SUMMARY + echo "2. Elige tu plataforma de deployment:" >> $GITHUB_STEP_SUMMARY + echo " - Digital Ocean (Droplet o App Platform)" >> $GITHUB_STEP_SUMMARY + echo " - AWS S3 + CloudFront" >> $GITHUB_STEP_SUMMARY + echo " - Azure Static Web Apps" >> $GITHUB_STEP_SUMMARY + echo " - Vercel" >> $GITHUB_STEP_SUMMARY + echo " - Netlify" >> $GITHUB_STEP_SUMMARY + echo "3. Sigue las instrucciones de deployment en el README" >> $GITHUB_STEP_SUMMARY diff --git a/DEPLOYMENT-CLOUD.md b/DEPLOYMENT-CLOUD.md new file mode 100644 index 0000000..03ffa1d --- /dev/null +++ b/DEPLOYMENT-CLOUD.md @@ -0,0 +1,285 @@ +# Guía de Deployment para Servicios Cloud + +Esta guía te muestra cómo desplegar la aplicación Angular en diferentes servicios cloud usando los artifacts generados por GitHub Actions. + +## 📦 Obtener los Artifacts + +Después de cada build exitoso en GitHub Actions: + +1. Ve a la pestaña **Actions** en tu repositorio +2. Selecciona el workflow ejecutado +3. En la sección **Artifacts**, descarga: + - `deployment-package-{sha}` para deploy directo + - O el release completo si creaste un tag + +## 🌊 Digital Ocean + +### Opción 1: Digital Ocean App Platform (Recomendado) + +**App Platform** gestiona automáticamente el hosting, SSL, CDN y escalamiento. + +```bash +# 1. Instalar doctl CLI +# Windows: https://docs.digitalocean.com/reference/doctl/how-to/install/ + +# 2. Autenticarse +doctl auth init + +# 3. Crear app.yaml +cat > .do/app.yaml << EOF +name: base-angular-app +services: +- name: web + github: + repo: tu-usuario/base-angular + branch: main + build_command: npm ci && npm run build + run_command: npx http-server dist/base-angular-app/browser -p 8080 + http_port: 8080 + routes: + - path: / +EOF + +# 4. Desplegar +doctl apps create --spec .do/app.yaml +``` + +**Costo aproximado:** $5-12/mes + +### Opción 2: Digital Ocean Droplet + +Para usar un droplet con Nginx: + +```bash +# 1. Crear droplet ($4-6/mes) +doctl compute droplet create base-angular \ + --image ubuntu-24-04-x64 \ + --size s-1vcpu-1gb \ + --region nyc1 + +# 2. Obtener IP +doctl compute droplet list + +# 3. Copiar artifact al droplet +scp angular-app.zip root@YOUR_DROPLET_IP:/tmp/ + +# 4. SSH y configurar +ssh root@YOUR_DROPLET_IP + +# Instalar Nginx +apt update && apt install -y nginx unzip + +# Extraer app +mkdir -p /var/www/base-angular-app +cd /var/www/base-angular-app +unzip /tmp/angular-app.zip + +# Configurar Nginx (usa el nginx.conf del artifact) +nano /etc/nginx/sites-available/base-angular-app +ln -s /etc/nginx/sites-available/base-angular-app /etc/nginx/sites-enabled/ +nginx -t && systemctl reload nginx + +# 5. Opcional: Configurar SSL con Let's Encrypt +apt install -y certbot python3-certbot-nginx +certbot --nginx -d tudominio.com +``` + +## ☁️ AWS S3 + CloudFront + +Perfecto para aplicaciones Angular (SPA hosting estático). + +```bash +# 1. Instalar AWS CLI +# Windows: https://aws.amazon.com/cli/ + +# 2. Configurar credenciales +aws configure + +# 3. Crear bucket S3 +aws s3 mb s3://base-angular-app + +# 4. Habilitar hosting estático +aws s3 website s3://base-angular-app \ + --index-document index.html \ + --error-document index.html + +# 5. Desplegar +unzip angular-app.zip -d dist +aws s3 sync dist/base-angular-app/browser s3://base-angular-app --delete + +# 6. Crear distribución CloudFront (CDN) +aws cloudfront create-distribution \ + --origin-domain-name base-angular-app.s3.amazonaws.com \ + --default-root-object index.html + +# 7. Invalidar caché después de cada deploy +aws cloudfront create-invalidation \ + --distribution-id YOUR_DISTRIBUTION_ID \ + --paths "/*" +``` + +**Costo aproximado:** $1-5/mes (tráfico incluido en free tier primer año) + +## 🔷 Azure Static Web Apps + +```bash +# 1. Instalar Azure CLI +# Windows: https://aka.ms/installazurecliwindows + +# 2. Login +az login + +# 3. Crear Static Web App +az staticwebapp create \ + --name base-angular-app \ + --resource-group myResourceGroup \ + --location "East US 2" + +# 4. Desplegar +unzip angular-app.zip -d dist +az staticwebapp deploy \ + --name base-angular-app \ + --source-path dist/base-angular-app/browser + +# O configurar deployment directo desde GitHub +az staticwebapp create \ + --name base-angular-app \ + --resource-group myResourceGroup \ + --source https://github.com/tu-usuario/base-angular \ + --branch main \ + --app-location "/" \ + --output-location "dist/base-angular-app/browser" +``` + +**Costo:** Free tier generoso (100GB bandwidth/mes) + +## ▲ Vercel + +La opción más simple para Angular: + +```bash +# 1. Instalar Vercel CLI +npm install -g vercel + +# 2. Login +vercel login + +# 3. Desplegar (desde el directorio del proyecto) +vercel --prod + +# O conectar directamente con GitHub (auto-deploy en cada push) +# Ve a https://vercel.com/new e importa tu repositorio +``` + +**Configuración automática:** Vercel detecta Angular y configura todo automáticamente. + +**Costo:** Free tier muy generoso + +## 🌐 Netlify + +```bash +# 1. Instalar Netlify CLI +npm install -g netlify-cli + +# 2. Login +netlify login + +# 3. Desplegar +unzip angular-app.zip +netlify deploy --prod --dir=dist/base-angular-app/browser + +# O configurar auto-deploy desde GitHub +# Ve a https://app.netlify.com/start e importa tu repositorio +``` + +**Costo:** Free tier generoso (100GB bandwidth/mes) + +## 🚀 GitHub Pages (Gratis) + +Para proyectos públicos: + +```bash +# 1. Instalar angular-cli-ghpages +npm install -g angular-cli-ghpages + +# 2. Build con base-href correcto +ng build --base-href "https://tu-usuario.github.io/base-angular/" + +# 3. Desplegar +npx angular-cli-ghpages --dir=dist/base-angular-app/browser +``` + +O configurar GitHub Actions workflow: + +```yaml +# .github/workflows/gh-pages.yml +name: Deploy to GitHub Pages + +on: + push: + branches: [ main ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + - run: npm ci + - run: npm run build + - uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./dist/base-angular-app/browser +``` + +## 📊 Comparación de Servicios + +| Servicio | Costo/mes | SSL Gratis | CDN | Facilidad | Mejor para | +|----------|-----------|------------|-----|-----------|------------| +| **Vercel** | Free-$20 | ✅ | ✅ | ⭐⭐⭐⭐⭐ | Deploy rápido | +| **Netlify** | Free-$19 | ✅ | ✅ | ⭐⭐⭐⭐⭐ | JAMstack apps | +| **GitHub Pages** | Free | ✅ | ✅ | ⭐⭐⭐⭐ | Proyectos públicos | +| **AWS S3+CF** | $1-5 | ✅ | ✅ | ⭐⭐⭐ | Control total | +| **Azure SWA** | Free-$9 | ✅ | ✅ | ⭐⭐⭐⭐ | Ecosistema Azure | +| **DO App Platform** | $5-12 | ✅ | ✅ | ⭐⭐⭐⭐ | Simplicidad | +| **DO Droplet** | $4-6 | Con certbot | ❌ | ⭐⭐ | Máximo control | + +## 🔄 Workflow Recomendado + +```mermaid +graph LR + A[Crear PR] --> B[Tests automáticos] + B --> C[Merge a main] + C --> D[Build + Artifacts] + D --> E[Desplegar a staging] + E --> F[Crear tag v1.x.x] + F --> G[GitHub Release] + G --> H[Deploy a producción] +``` + +## 💡 Tips + +1. **Staging + Producción:** Usa branches diferentes (develop → staging, main → prod) +2. **Environment Variables:** Usa Angular environments para diferentes configs +3. **Monitoring:** Agrega Google Analytics o Sentry +4. **Performance:** Habilita compresión gzip/brotli en tu servidor +5. **Cache:** Configura headers de cache para assets estáticos + +## 🆘 Troubleshooting + +### Error: "Cannot GET /ruta" +- Configura routing del servidor para SPA (todas las rutas → index.html) +- Vercel/Netlify: Lo hacen automático +- Nginx: Usa el `nginx.conf` incluido en los artifacts +- S3: Configura error document = index.html + +### Error: "Failed to load resource" +- Verifica el `base-href` en tu build +- Revisa CORS si usas APIs externas + +### Assets no cargan +- Revisa rutas relativas vs absolutas +- Verifica que el build incluyó todos los assets diff --git a/package.json b/package.json index 56b98c6..243229a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test" + "test": "ng test", + "typecheck": "tsc --noEmit -p tsconfig.app.json" }, "prettier": { "printWidth": 100, diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index ad28f24..026d983 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -27,10 +27,4 @@ export const routes: Routes = [ path: 'products/:id', loadComponent: () => import('./features/products/components/product-detail.component').then(m => m.ProductDetailComponent) } - // Agregar aquí más rutas de features - // Ejemplo de lazy loading: - // { - // path: 'users', - // loadComponent: () => import('./features/users/users.component').then(m => m.UsersComponent) - // } ]; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 8b6a367..5553f7a 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,10 +1,16 @@ import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { Title } from '@angular/platform-browser'; import { App } from './app'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], + providers: [ + provideRouter([]), + Title + ] }).compileComponents(); }); @@ -14,10 +20,25 @@ describe('App', () => { expect(app).toBeTruthy(); }); - it('should render title', async () => { + it('should have a title property', () => { const fixture = TestBed.createComponent(App); - await fixture.whenStable(); + const app = fixture.componentInstance; + expect(app.title).toBeDefined(); + expect(typeof app.title).toBe('string'); + }); + + it('should set document title on init', () => { + const fixture = TestBed.createComponent(App); + const titleService = TestBed.inject(Title); + const app = fixture.componentInstance; + + expect(titleService.getTitle()).toBe(app.title); + }); + + it('should render router-outlet', () => { + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, base-angular-app'); + expect(compiled.querySelector('router-outlet')).toBeTruthy(); }); }); diff --git a/src/app/features/products/components/product-detail.component.html b/src/app/features/products/components/product-detail.component.html index 806b972..370d6e4 100644 --- a/src/app/features/products/components/product-detail.component.html +++ b/src/app/features/products/components/product-detail.component.html @@ -1,83 +1,89 @@
- -
-
-

Cargando producto...

-
+ + @if (loading$ | async) { + + } -
-

❌ Error al cargar el producto

-

{{ error.message || 'Error desconocido' }}

- -
+ @if (error$ | async; as error) { + + + } - -
-
-
-

{{ product.name }}

- {{ product.category }} -
-
- - - -
-
+ + @if (product$ | async; as product) { +
+
+
+

{{ product.name }}

+ {{ product.category }} +
+
+ + + +
+
-
-
-

Información General

-
-
- - {{ product.id }} -
-
- - {{ product.name }} -
-
- - {{ product.category }} +
+
+

General Information

+
+
+ + {{ product.id }} +
+
+ + {{ product.name }} +
+
+ + {{ product.category }} +
-
-
+ -
-

Descripción

-

{{ product.description || 'Sin descripción' }}

-
+
+

Description

+

{{ product.description || 'No description' }}

+
-
-

Pricing & Inventario

-
-
- - ${{ product.price | number:'1.2-2' }} -
-
- - - {{ product.stock }} unidades - +
+

Pricing & Inventory

+
+
+ + ${{ product.price | number:'1.2-2' }} +
+
+ + + {{ product.stock }} units + +
-
-
+ +
-
+ } - -
-

❌ Producto no encontrado

-

El producto que buscas no existe o ha sido eliminado.

- -
+ + @if (!(loading$ | async) && !(product$ | async) && !(error$ | async)) { +
+

❌ Product not found

+

The product you are looking for does not exist or has been deleted.

+ +
+ }
diff --git a/src/app/features/products/components/product-detail.component.spec.ts b/src/app/features/products/components/product-detail.component.spec.ts new file mode 100644 index 0000000..eab0fba --- /dev/null +++ b/src/app/features/products/components/product-detail.component.spec.ts @@ -0,0 +1,192 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, ActivatedRoute } from '@angular/router'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { ProductDetailComponent } from './product-detail.component'; +import * as ProductsActions from '../store/products.actions'; +import * as ProductsSelectors from '../store/products.selectors'; +import { Product } from '../models'; + +describe('ProductDetailComponent', () => { + let component: ProductDetailComponent; + let store: MockStore; + let router: Router; + let activatedRoute: ActivatedRoute; + + const mockProduct: Product = { + id: '123', + name: 'Test Product', + description: 'Test Description', + price: 99.99, + stock: 50, + category: 'Electronics' + }; + + const initialState = { + products: { + entities: {}, + ids: [], + loading: false, + error: null, + selectedProduct: null + } + }; + + beforeEach(async () => { + const activatedRouteStub = { + snapshot: { + paramMap: { + get: (key: string) => key === 'id' ? '123' : null + } + } + }; + + await TestBed.configureTestingModule({ + imports: [ProductDetailComponent], + providers: [ + provideMockStore({ initialState }), + { + provide: Router, + useValue: { + navigate: vi.fn() + } + }, + { + provide: ActivatedRoute, + useValue: activatedRouteStub + } + ] + }).compileComponents(); + + store = TestBed.inject(MockStore); + router = TestBed.inject(Router); + activatedRoute = TestBed.inject(ActivatedRoute); + + store.overrideSelector(ProductsSelectors.selectSelectedProduct, mockProduct); + store.overrideSelector(ProductsSelectors.selectProductsLoading, false); + store.overrideSelector(ProductsSelectors.selectProductsError, null); + + const fixture = TestBed.createComponent(ProductDetailComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch loadProduct action on init', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + + component.ngOnInit(); + + expect(dispatchSpy).toHaveBeenCalledWith( + ProductsActions.loadProduct({ id: '123' }) + ); + }); + + it('should not dispatch loadProduct if no id in route', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + activatedRoute.snapshot.paramMap.get = () => null; + + component.ngOnInit(); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('should select product from store', async () => { + const product = await new Promise(resolve => { + component.product$.subscribe(product => resolve(product)); + }); + expect(product).toEqual(mockProduct); + }); + + it('should select loading state from store', async () => { + const loading = await new Promise(resolve => { + component.loading$.subscribe(loading => resolve(loading)); + }); + expect(loading).toBe(false); + }); + + it('should select error state from store', async () => { + const error = await new Promise(resolve => { + component.error$.subscribe(error => resolve(error)); + }); + expect(error).toBeNull(); + }); + + it('should navigate to edit page', () => { + component.onEdit('123'); + + expect(router.navigate).toHaveBeenCalledWith(['/products/edit', '123']); + }); + + it('should navigate back to products list', () => { + component.onBack(); + + expect(router.navigate).toHaveBeenCalledWith(['/products']); + }); + + it('should dispatch deleteProduct and navigate back when confirmed', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + component.onDelete('123', 'Test Product'); + + expect(dispatchSpy).toHaveBeenCalledWith( + ProductsActions.deleteProduct({ id: '123' }) + ); + expect(router.navigate).toHaveBeenCalledWith(['/products']); + }); + + it('should not delete product when cancelled', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + vi.spyOn(window, 'confirm').mockReturnValue(false); + + component.onDelete('123', 'Test Product'); + + expect(dispatchSpy).not.toHaveBeenCalledWith( + ProductsActions.deleteProduct({ id: '123' }) + ); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('should show confirmation dialog with product name', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + + component.onDelete('123', 'Test Product'); + + expect(confirmSpy).toHaveBeenCalledWith( + 'Are you sure you want to delete the product "Test Product"?' + ); + }); + + it('should handle null product gracefully', async () => { + store.overrideSelector(ProductsSelectors.selectSelectedProduct, null); + store.refreshState(); + + const product = await new Promise(resolve => { + component.product$.subscribe(product => resolve(product)); + }); + expect(product).toBeNull(); + }); + + it('should handle loading state', async () => { + store.overrideSelector(ProductsSelectors.selectProductsLoading, true); + store.refreshState(); + + const loading = await new Promise(resolve => { + component.loading$.subscribe(loading => resolve(loading)); + }); + expect(loading).toBe(true); + }); + + it('should handle error state', async () => { + const error = { message: 'Product not found' }; + store.overrideSelector(ProductsSelectors.selectProductsError, error); + store.refreshState(); + + const err = await new Promise(resolve => { + component.error$.subscribe(err => resolve(err)); + }); + expect(err).toEqual(error); + }); +}); diff --git a/src/app/features/products/components/product-detail.component.ts b/src/app/features/products/components/product-detail.component.ts index 6aa1946..9e6220c 100644 --- a/src/app/features/products/components/product-detail.component.ts +++ b/src/app/features/products/components/product-detail.component.ts @@ -6,18 +6,21 @@ import { Observable } from 'rxjs'; import * as ProductsActions from '../store/products.actions'; import * as ProductsSelectors from '../store/products.selectors'; import { Product } from '../models'; +import { LoadingComponent } from '../../../shared/components/loading.component'; +import { ErrorComponent } from '../../../shared/components/error.component'; /** - * Componente para ver detalles de un producto (solo lectura) + * Product Detail Component */ @Component({ selector: 'app-product-detail', standalone: true, - imports: [CommonModule, RouterModule], + imports: [CommonModule, RouterModule, LoadingComponent, ErrorComponent], templateUrl: './product-detail.component.html', styleUrl: './product-detail.component.css' }) export class ProductDetailComponent implements OnInit { + // Injected services private store = inject(Store); private route = inject(ActivatedRoute); private router = inject(Router); @@ -34,6 +37,7 @@ export class ProductDetailComponent implements OnInit { ngOnInit(): void { const id = this.route.snapshot.paramMap.get('id'); + console.log('ProductDetailComponent initialized with id:', id); if (id) { this.store.dispatch(ProductsActions.loadProduct({ id })); } @@ -43,12 +47,20 @@ export class ProductDetailComponent implements OnInit { this.router.navigate(['/products/edit', id]); } + /** + * Go back to the products list + */ onBack(): void { this.router.navigate(['/products']); } + /** + * Delete the product after confirmation + * @param id The ID of the product to delete + * @param name The name of the product to delete + */ onDelete(id: string, name: string): void { - if (confirm(`¿Estás seguro de eliminar el producto "${name}"?`)) { + if (confirm(`Are you sure you want to delete the product "${name}"?`)) { this.store.dispatch(ProductsActions.deleteProduct({ id })); this.router.navigate(['/products']); } diff --git a/src/app/features/products/components/product-form.component.html b/src/app/features/products/components/product-form.component.html index 11ffff1..ae73dc8 100644 --- a/src/app/features/products/components/product-form.component.html +++ b/src/app/features/products/components/product-form.component.html @@ -1,112 +1,126 @@
-

{{ isEditMode ? 'Editar Producto' : 'Nuevo Producto' }}

- +

{{ isEditMode ? 'Edit Product' : 'New Product' }}

+
- -
-

❌ {{ error.message || 'Error al procesar la solicitud' }}

-
+ + @if (error$ | async; as error) { + + + }
- - + +
- - Product Name * + - - {{ getFieldError('name') }} - + placeholder="E.g.: HP Laptop"> + @if (isFieldInvalid('name')) { + + {{ getFieldError('name') }} + + }
- +
- - - - {{ getFieldError('description') }} - + placeholder="Describe the product..."> + @if (isFieldInvalid('description')) { + + {{ getFieldError('description') }} + + }
- +
- - Price * + - - {{ getFieldError('price') }} - + @if (isFieldInvalid('price')) { + + {{ getFieldError('price') }} + + }
- - - {{ getFieldError('stock') }} - + @if (isFieldInvalid('stock')) { + + {{ getFieldError('stock') }} + + }
- +
- - Category * + - - {{ getFieldError('category') }} - + placeholder="E.g.: Electronics"> + @if (isFieldInvalid('category')) { + + {{ getFieldError('category') }} + + }
- +
- -
-
+
+

* Required fields

+
- -
-

* Campos obligatorios

-
+
diff --git a/src/app/features/products/components/product-form.component.spec.ts b/src/app/features/products/components/product-form.component.spec.ts new file mode 100644 index 0000000..2823711 --- /dev/null +++ b/src/app/features/products/components/product-form.component.spec.ts @@ -0,0 +1,300 @@ +import { TestBed } from '@angular/core/testing'; +import { Router, ActivatedRoute } from '@angular/router'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { of } from 'rxjs'; +import { ProductFormComponent } from './product-form.component'; +import * as ProductsActions from '../store/products.actions'; +import * as ProductsSelectors from '../store/products.selectors'; +import { Product } from '../models'; + +describe('ProductFormComponent', () => { + let component: ProductFormComponent; + let store: MockStore; + let router: Router; + let activatedRoute: ActivatedRoute; + + const mockProduct: Product = { + id: '1', + name: 'Test Product', + description: 'Test Description', + price: 100, + stock: 10, + category: 'Test Category' + }; + + const initialState = { + products: { + entities: {}, + ids: [], + loading: false, + error: null, + selectedProduct: null + } + }; + + beforeEach(async () => { + const activatedRouteStub = { + paramMap: of({ get: () => null }), + snapshot: { paramMap: { get: () => null } } + }; + + await TestBed.configureTestingModule({ + imports: [ProductFormComponent], + providers: [ + provideMockStore({ initialState }), + { + provide: Router, + useValue: { + navigate: vi.fn() + } + }, + { + provide: ActivatedRoute, + useValue: activatedRouteStub + } + ] + }).compileComponents(); + + store = TestBed.inject(MockStore); + router = TestBed.inject(Router); + activatedRoute = TestBed.inject(ActivatedRoute); + + store.overrideSelector(ProductsSelectors.selectProductsActionLoading, false); + store.overrideSelector(ProductsSelectors.selectProductsError, null); + store.overrideSelector(ProductsSelectors.selectSelectedProduct, null); + + const fixture = TestBed.createComponent(ProductFormComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize form with empty values', () => { + component.ngOnInit(); + + expect(component.productForm).toBeDefined(); + expect(component.productForm.get('name')?.value).toBe(''); + expect(component.productForm.get('description')?.value).toBe(''); + expect(component.productForm.get('price')?.value).toBe(0); + expect(component.productForm.get('stock')?.value).toBe(0); + expect(component.productForm.get('category')?.value).toBe(''); + }); + + it('should be in create mode by default', () => { + component.ngOnInit(); + + expect(component.isEditMode).toBe(false); + expect(component.productId).toBeNull(); + }); + + it('should validate required fields', () => { + component.ngOnInit(); + + const nameControl = component.productForm.get('name'); + const descriptionControl = component.productForm.get('description'); + + nameControl?.setValue(''); + descriptionControl?.setValue(''); + + expect(nameControl?.hasError('required')).toBe(true); + expect(descriptionControl?.hasError('required')).toBe(true); + }); + + it('should validate name minlength', () => { + component.ngOnInit(); + + const nameControl = component.productForm.get('name'); + nameControl?.setValue('ab'); + + expect(nameControl?.hasError('minlength')).toBe(true); + }); + + it('should validate price minimum value', () => { + component.ngOnInit(); + + const priceControl = component.productForm.get('price'); + priceControl?.setValue(0); + + expect(priceControl?.hasError('min')).toBe(true); + }); + + it('should validate stock minimum value', () => { + component.ngOnInit(); + + const stockControl = component.productForm.get('stock'); + stockControl?.setValue(-1); + + expect(stockControl?.hasError('min')).toBe(true); + }); + + it('should accept valid form values', () => { + component.ngOnInit(); + + component.productForm.patchValue({ + name: 'Valid Product Name', + description: 'This is a valid description with enough characters', + price: 99.99, + stock: 50, + category: 'Electronics' + }); + + expect(component.productForm.valid).toBe(true); + }); + + it('should dispatch createProduct action on submit for new product', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + component.ngOnInit(); + + const formValue = { + name: 'New Product', + description: 'New product description with enough text', + price: 50, + stock: 100, + category: 'Category' + }; + + component.productForm.patchValue(formValue); + component.onSubmit(); + + expect(dispatchSpy).toHaveBeenCalledWith( + ProductsActions.createProduct({ product: formValue }) + ); + }); + + it('should not submit invalid form', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + component.ngOnInit(); + + component.productForm.patchValue({ + name: 'ab', // Too short + description: 'short', // Too short + price: 0, // Below minimum + stock: -1, // Below minimum + category: '' + }); + + component.onSubmit(); + + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + + it('should navigate back to products list on cancel', () => { + component.onCancel(); + + expect(router.navigate).toHaveBeenCalledWith(['/products']); + }); + + it('should detect invalid field', () => { + component.ngOnInit(); + + const nameControl = component.productForm.get('name'); + nameControl?.setValue(''); + nameControl?.markAsTouched(); + + expect(component.isFieldInvalid('name')).toBe(true); + }); + + it('should return correct error message for required field', () => { + component.ngOnInit(); + + const nameControl = component.productForm.get('name'); + nameControl?.setValue(''); + nameControl?.markAsTouched(); + + expect(component.getFieldError('name')).toBe('Este campo es requerido'); + }); + + it('should return correct error message for minlength', () => { + component.ngOnInit(); + + const nameControl = component.productForm.get('name'); + nameControl?.setValue('ab'); + nameControl?.markAsTouched(); + + expect(component.getFieldError('name')).toContain('Mínimo'); + }); + + it('should return correct error message for min value', () => { + component.ngOnInit(); + + const priceControl = component.productForm.get('price'); + priceControl?.setValue(0); + priceControl?.markAsTouched(); + + expect(component.getFieldError('price')).toContain('El valor mínimo es'); + }); + + it('should load product in edit mode', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + store.overrideSelector(ProductsSelectors.selectSelectedProduct, mockProduct); + + // Create new mock with mutable paramMap + const mockActivatedRoute = { + paramMap: of({ + get: (key: string) => key === 'id' ? '1' : null + } as any), + snapshot: { paramMap: { get: () => null } } + }; + Object.assign(activatedRoute, mockActivatedRoute); + + component.ngOnInit(); + + expect(dispatchSpy).toHaveBeenCalledWith( + ProductsActions.loadProduct({ id: '1' }) + ); + expect(component.isEditMode).toBe(true); + expect(component.productId).toBe('1'); + }); + + it('should patch form values in edit mode', async () => { + store.overrideSelector(ProductsSelectors.selectSelectedProduct, mockProduct); + + const mockActivatedRoute = { + paramMap: of({ + get: (key: string) => key === 'id' ? '1' : null + } as any), + snapshot: { paramMap: { get: () => null } } + }; + Object.assign(activatedRoute, mockActivatedRoute); + + component.ngOnInit(); + + await new Promise(resolve => setTimeout(resolve, 100)); + + expect(component.productForm.get('name')?.value).toBe('Test Product'); + expect(component.productForm.get('price')?.value).toBe(100); + }); + + it('should dispatch updateProduct action in edit mode', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + + component.ngOnInit(); + + // Set component in edit mode + component.isEditMode = true; + component.productId = '1'; + + const updatedProduct = { + name: 'Updated Product', + description: 'Updated description with enough characters', + price: 150, + stock: 20, + category: 'Updated Category' + }; + + component.productForm.patchValue(updatedProduct); + component.onSubmit(); + + expect(dispatchSpy).toHaveBeenCalledWith( + ProductsActions.updateProduct({ + id: '1', + product: { + id: '1', + ...updatedProduct + } + }) + ); + }); +}); diff --git a/src/app/features/products/components/product-form.component.ts b/src/app/features/products/components/product-form.component.ts index c979e97..8388c29 100644 --- a/src/app/features/products/components/product-form.component.ts +++ b/src/app/features/products/components/product-form.component.ts @@ -7,6 +7,7 @@ import { Observable } from 'rxjs'; import * as ProductsActions from '../store/products.actions'; import * as ProductsSelectors from '../store/products.selectors'; import { Product } from '../models'; +import { ErrorComponent } from '../../../shared/components/error.component'; /** * Componente para crear o editar un producto @@ -14,7 +15,7 @@ import { Product } from '../models'; @Component({ selector: 'app-product-form', standalone: true, - imports: [CommonModule, ReactiveFormsModule], + imports: [CommonModule, ReactiveFormsModule, ErrorComponent], templateUrl: './product-form.component.html', styleUrl: './product-form.component.css' }) @@ -37,7 +38,7 @@ export class ProductFormComponent implements OnInit { ngOnInit(): void { this.initForm(); - + // Verificar si estamos en modo edición this.route.paramMap.subscribe(params => { this.productId = params.get('id'); @@ -61,7 +62,7 @@ export class ProductFormComponent implements OnInit { private loadProduct(id: string): void { this.store.dispatch(ProductsActions.loadProduct({ id })); - + this.store.select(ProductsSelectors.selectSelectedProduct).subscribe(product => { if (product) { this.productForm.patchValue({ diff --git a/src/app/features/products/products.component.html b/src/app/features/products/products.component.html index 2caeed5..2e5f9af 100644 --- a/src/app/features/products/products.component.html +++ b/src/app/features/products/products.component.html @@ -1,86 +1,95 @@
-

Gestión de Productos

+

Product management

- -
- - -
+ + @if (categories$ | async; as categories) { +
+ + @for (category of categories; track category) { + + } +
+ } -
-
-

Cargando productos...

-
+ @if (loading$ | async) { + + } -
-

❌ Error al cargar productos

-

{{ error.message || 'Error desconocido' }}

- -
+ @if (error$ | async; as error) { + + + } -
-
-

📦 No hay productos disponibles

- -
+ @if (products$ | async; as products) { +
+ @if (products.length === 0 && !(loading$ | async)) { +
+

📦 No products available

+ +
+ } -
-
-

{{ product.name }}

- {{ product.category }} -
- -
-

{{ product.description }}

- -
-
- Precio: - ${{ product.price | number:'1.2-2' }} + @for (product of products; track trackByProductId($index, product)) { +
+
+

{{ product.name }}

+ {{ product.category }}
-
- Stock: - - {{ product.stock }} unidades - + +
+

{{ product.description }}

+ +
+
+ Price: + ${{ product.price | number:'1.2-2' }} +
+
+ Stock: + + {{ product.stock }} units + +
+
-
-
-
- - - -
+
+ + + +
+
+ }
-
+ }
diff --git a/src/app/features/products/products.component.spec.ts b/src/app/features/products/products.component.spec.ts new file mode 100644 index 0000000..3565921 --- /dev/null +++ b/src/app/features/products/products.component.spec.ts @@ -0,0 +1,169 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { ProductsComponent } from './products.component'; +import * as ProductsActions from './store/products.actions'; +import * as ProductsSelectors from './store/products.selectors'; +import { Product } from './models'; + +describe('ProductsComponent', () => { + let component: ProductsComponent; + let store: MockStore; + let router: Router; + + const mockProducts: Product[] = [ + { + id: '1', + name: 'Product 1', + description: 'Description 1', + price: 100, + stock: 10, + category: 'Category A' + }, + { + id: '2', + name: 'Product 2', + description: 'Description 2', + price: 200, + stock: 20, + category: 'Category B' + } + ]; + + const initialState = { + products: { + entities: {}, + ids: [], + loading: false, + error: null + } + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductsComponent], + providers: [ + provideMockStore({ initialState }), + { + provide: Router, + useValue: { + navigate: vi.fn() + } + } + ] + }).compileComponents(); + + store = TestBed.inject(MockStore); + router = TestBed.inject(Router); + + store.overrideSelector(ProductsSelectors.selectAllProducts, mockProducts); + store.overrideSelector(ProductsSelectors.selectProductsLoading, false); + store.overrideSelector(ProductsSelectors.selectProductsError, null); + store.overrideSelector(ProductsSelectors.selectCategories, ['Category A', 'Category B']); + + const fixture = TestBed.createComponent(ProductsComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch loadProducts on init', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + + component.ngOnInit(); + + expect(dispatchSpy).toHaveBeenCalledWith(ProductsActions.loadProducts()); + }); + + it('should select products from store', async () => { + const products = await new Promise(resolve => { + component.products$.subscribe(products => resolve(products)); + }); + expect(products).toEqual(mockProducts); + }); + + it('should select loading state from store', async () => { + const loading = await new Promise(resolve => { + component.loading$.subscribe(loading => resolve(loading)); + }); + expect(loading).toBe(false); + }); + + it('should select categories from store', async () => { + const categories = await new Promise(resolve => { + component.categories$.subscribe(categories => resolve(categories)); + }); + expect(categories).toEqual(['Category A', 'Category B']); + }); + + it('should navigate to new product form', () => { + component.onCreateProduct(); + + expect(router.navigate).toHaveBeenCalledWith(['/products/new']); + }); + + it('should navigate to edit product form', () => { + const productId = '123'; + + component.onEditProduct(productId); + + expect(router.navigate).toHaveBeenCalledWith(['/products/edit', productId]); + }); + + it('should navigate to product detail', () => { + const productId = '123'; + + component.onViewProduct(productId); + + expect(router.navigate).toHaveBeenCalledWith(['/products', productId]); + }); + + it('should dispatch deleteProduct action when confirmed', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + component.onDeleteProduct('1', 'Product 1'); + + expect(dispatchSpy).toHaveBeenCalledWith( + ProductsActions.deleteProduct({ id: '1' }) + ); + }); + + it('should not dispatch deleteProduct action when cancelled', () => { + const dispatchSpy = vi.spyOn(store, 'dispatch'); + vi.spyOn(window, 'confirm').mockReturnValue(false); + + component.onDeleteProduct('1', 'Product 1'); + + expect(dispatchSpy).not.toHaveBeenCalledWith( + ProductsActions.deleteProduct({ id: '1' }) + ); + }); + + it('should filter products by category', () => { + const filteredProducts = [mockProducts[0]]; + store.overrideSelector( + ProductsSelectors.selectProductsByCategory('Category A'), + filteredProducts + ); + + component.filterByCategory('Category A'); + + expect(component.selectedCategory).toBe('Category A'); + }); + + it('should show all products when category is null', () => { + component.filterByCategory(null); + + expect(component.selectedCategory).toBeNull(); + }); + + it('should track products by id', () => { + const product = mockProducts[0]; + const trackId = component.trackByProductId(0, product); + + expect(trackId).toBe('1'); + }); +}); diff --git a/src/app/features/products/products.component.ts b/src/app/features/products/products.component.ts index 16eec63..edfb5ce 100644 --- a/src/app/features/products/products.component.ts +++ b/src/app/features/products/products.component.ts @@ -6,15 +6,17 @@ import { Observable } from 'rxjs'; import * as ProductsActions from './store/products.actions'; import * as ProductsSelectors from './store/products.selectors'; import { Product } from './models'; +import { LoadingComponent } from '../../shared/components/loading.component'; +import { ErrorComponent } from '../../shared/components/error.component'; /** - * Componente principal del feature Products - * Lista todos los productos + * Main component of the Products feature + * Lists all products and allows navigation to create, edit, and view details */ @Component({ selector: 'app-products', standalone: true, - imports: [CommonModule, RouterModule], + imports: [CommonModule, RouterModule, LoadingComponent, ErrorComponent], templateUrl: './products.component.html', styleUrl: './products.component.css' }) @@ -49,7 +51,7 @@ export class ProductsComponent implements OnInit { } onDeleteProduct(id: string, name: string): void { - if (confirm(`¿Estás seguro de eliminar el producto "${name}"?`)) { + if (confirm(`Are you sure you want to delete the product "${name}"?`)) { this.store.dispatch(ProductsActions.deleteProduct({ id })); } } @@ -60,7 +62,7 @@ export class ProductsComponent implements OnInit { filterByCategory(category: string | null): void { this.selectedCategory = category; - + if (category) { this.products$ = this.store.select(ProductsSelectors.selectProductsByCategory(category)); } else { diff --git a/src/app/features/products/services/products.service.ts b/src/app/features/products/services/products.service.ts index 64fcd02..e53126c 100644 --- a/src/app/features/products/services/products.service.ts +++ b/src/app/features/products/services/products.service.ts @@ -42,21 +42,32 @@ export class ProductsService { * Obtener un producto por ID */ getProductById(id: string): Observable { - return this.http.get(`${this.apiUrl}/${id}`); + return this.http.get(`${this.apiUrl}/${id}`).pipe( + map(response => { + if (!response) { + throw new Error(`Product with id ${id} not found`); + } + return response.data ? response.data : response; + }) + ); } /** * Crear un nuevo producto */ createProduct(product: CreateProductCommand): Observable { - return this.http.post(this.apiUrl, product); + return this.http.post(this.apiUrl, product).pipe( + map(response => response.data ? response.data : response) + ); } /** * Actualizar un producto existente */ updateProduct(id: string, product: UpdateProductCommand): Observable { - return this.http.put(`${this.apiUrl}/${id}`, product); + return this.http.put(`${this.apiUrl}/${id}`, product).pipe( + map(response => response.data ? response.data : response) + ); } /** diff --git a/src/app/features/products/store/products.actions.ts b/src/app/features/products/store/products.actions.ts index e6d274c..aa1a039 100644 --- a/src/app/features/products/store/products.actions.ts +++ b/src/app/features/products/store/products.actions.ts @@ -17,7 +17,7 @@ export const loadProductsFailure = createAction( ); /** - * Acciones para cargar un producto específico + * Actions to load a single product */ export const loadProduct = createAction( '[Product Detail Page] Load Product', @@ -35,7 +35,7 @@ export const loadProductFailure = createAction( ); /** - * Acciones para crear producto + * Actions to create a product */ export const createProduct = createAction( '[Product Form] Create Product', @@ -53,7 +53,7 @@ export const createProductFailure = createAction( ); /** - * Acciones para actualizar producto + * Actions to update a product */ export const updateProduct = createAction( '[Product Form] Update Product', @@ -71,7 +71,7 @@ export const updateProductFailure = createAction( ); /** - * Acciones para eliminar producto + * Actions to delete a product */ export const deleteProduct = createAction( '[Product List] Delete Product', @@ -89,7 +89,7 @@ export const deleteProductFailure = createAction( ); /** - * Acción para limpiar el producto seleccionado + * Action to clear the selected product */ export const clearSelectedProduct = createAction( '[Product Detail] Clear Selected Product' diff --git a/src/app/features/products/store/products.effects.ts b/src/app/features/products/store/products.effects.ts index fa93c7c..6161665 100644 --- a/src/app/features/products/store/products.effects.ts +++ b/src/app/features/products/store/products.effects.ts @@ -16,7 +16,7 @@ export class ProductsEffects { private router = inject(Router); /** - * Effect para cargar todos los productos + * Effect to load all products */ loadProducts$ = createEffect(() => this.actions$.pipe( @@ -31,13 +31,15 @@ export class ProductsEffects { ); /** - * Effect para cargar un producto específico + * Effect to load a single product */ loadProduct$ = createEffect(() => this.actions$.pipe( ofType(ProductsActions.loadProduct), switchMap(({ id }) => this.productsService.getProductById(id).pipe( + tap(product => console.log('Fetched product:', product)), + tap(() => console.log('Loaded product with id:', id)), map(product => ProductsActions.loadProductSuccess({ product })), catchError(error => of(ProductsActions.loadProductFailure({ error }))) ) @@ -46,7 +48,7 @@ export class ProductsEffects { ); /** - * Effect para crear un producto + * Effect to create a product */ createProduct$ = createEffect(() => this.actions$.pipe( @@ -61,7 +63,7 @@ export class ProductsEffects { ); /** - * Effect para redirigir después de crear + * Effect to redirect after creating */ createProductSuccess$ = createEffect(() => this.actions$.pipe( @@ -72,7 +74,7 @@ export class ProductsEffects { ); /** - * Effect para actualizar un producto + * Effect to update a product */ updateProduct$ = createEffect(() => this.actions$.pipe( @@ -87,7 +89,7 @@ export class ProductsEffects { ); /** - * Effect para redirigir después de actualizar + * Effect to redirect after updating */ updateProductSuccess$ = createEffect(() => this.actions$.pipe( @@ -98,7 +100,7 @@ export class ProductsEffects { ); /** - * Effect para eliminar un producto + * Effect to delete a product */ deleteProduct$ = createEffect(() => this.actions$.pipe( diff --git a/src/app/shared/components/error.component.ts b/src/app/shared/components/error.component.ts new file mode 100644 index 0000000..6c2c576 --- /dev/null +++ b/src/app/shared/components/error.component.ts @@ -0,0 +1,73 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +/** + * Reusable error display component + * Shows error message with optional action button + */ +@Component({ + selector: 'app-error', + standalone: true, + imports: [CommonModule], + template: ` +
+
+

{{ title }}

+

{{ message }}

+ +
+ `, + styles: [` + .error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; + min-height: 200px; + } + + .error-icon { + font-size: 3rem; + margin-bottom: 1rem; + } + + h3 { + color: #e74c3c; + margin-bottom: 0.5rem; + } + + p { + color: #666; + margin-bottom: 1.5rem; + max-width: 500px; + } + + .btn-secondary { + padding: 0.5rem 1rem; + background-color: #6c757d; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.95rem; + transition: background-color 0.2s; + } + + .btn-secondary:hover { + background-color: #5a6268; + } + `] +}) +export class ErrorComponent { + @Input() title = 'Error'; + @Input() message = 'An error occurred'; + @Input() actionLabel?: string; + @Output() action = new EventEmitter(); +} diff --git a/src/app/shared/components/loading.component.ts b/src/app/shared/components/loading.component.ts new file mode 100644 index 0000000..646545e --- /dev/null +++ b/src/app/shared/components/loading.component.ts @@ -0,0 +1,51 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +/** + * Reusable loading spinner component + * Shows a spinner with optional message + */ +@Component({ + selector: 'app-loading', + standalone: true, + imports: [CommonModule], + template: ` +
+
+

{{ message }}

+
+ `, + styles: [` + .loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + min-height: 200px; + } + + .spinner { + width: 50px; + height: 50px; + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + p { + margin-top: 1rem; + color: #666; + font-size: 0.95rem; + } + `] +}) +export class LoadingComponent { + @Input() message = 'Loading...'; +}