Publicada:

Angular 14 - Ejemplo CRUD con Formularios Reactivos (Reactive Forms)

Tutorial creado con Angular 14.2.12

Otras versiones disponibles:

Este tutorial muestra cómo crear una aplicación CRUD básica de Angular 14 con formularios reactivos que incluye páginas para enumerar, agregar, editar y eliminar registros de una API JSON. Los registros en la aplicación de ejemplo son registros de usuario, pero el mismo patrón CRUD y la misma estructura de código podrían usarse para administrar cualquier tipo de datos, p. productos, servicios, artículos, etc.

API de back-end falso con rutas CRUD

La aplicación de ejemplo se ejecuta con una API de backend falso de forma predeterminada para permitir que se ejecute completamente en el navegador sin una API real, la API falsa contiene rutas para las operaciones CRUD del usuario (Create, Read, Update, Delete) y utiliza el almacenamiento local del navegador para guardar datos. Para deshabilitar el backend falso, solo tiene que eliminar un par de líneas de código del archivo app.module.ts, puede crear su propia API o conectarla con la .NET CRUD API o la Node.js CRUD API disponible.

.

 

Ejemplo de aplicación Angular CRUD

La aplicación de ejemplo incluye una página de inicio básica y una sección de usuarios con funcionalidad CRUD, la página predeterminada en la sección de usuarios muestra una lista de todos los usuarios e incluye botones para agregar, editar y eliminar usuarios. Los botones Agregar y Editar navegan a una página que contiene un formulario reactivo de Angular para crear o actualizar un registro de usuario, y el botón Eliminar ejecuta una función dentro del componente de la lista de usuarios para eliminar un registro de usuario.

Los formularios de agregar y editar se implementan con el mismo componente de agregar/editar que se comporta de manera diferente según el modo en el que se encuentre (modo agregar o modo editar).

Diseñado con Bootstrap 5

La aplicación de inicio de sesión de ejemplo está diseñada con el CSS de Bootstrap 5.2, para obtener más información sobre Bootstrap, consulte https://getbootstrap.com/docs/5.2/getting-started/introduction/.

Código en GitHub

El proyecto de ejemplo está disponible en GitHub en https://github.com/cornflourblue/angular-14-crud-example.

Aquí está en acción: (Ver en StackBlitz en https://stackblitz.com/edit/angular-14-crud-example)


Ejecute el ejemplo de Angular CRUD localmente

  1. Instalar Node.js y npm desde https://nodejs.org.
  2. Descargue o clone el código fuente del proyecto Angular desde https://github.com/cornflourblue/angular-14-crud-example
  3. Instale todos los paquetes npm necesarios ejecutando npm install o npm i desde la línea de comando en la carpeta raíz del proyecto (donde se encuentra el package.json).
  4. Inicie la aplicación ejecutando npm start desde la línea de comando en la carpeta raíz del proyecto, esto compilará la aplicación Angular y la iniciará automáticamente en el navegador en la URL http://localhost:4200.

NOTA: También puede iniciar la aplicación CRUD con el comando Angular CLI ng serve --open. Para hacer esto, primero instale Angular CLI globalmente en su sistema con el comando npm install -g @angular/cli.

Para obtener más información sobre cómo configurar su entorno de desarrollo Angular local, consulte Angular - Setup Development Environment.


Conecte la aplicación Angular CRUD con una API de .NET

Para obtener detalles completos sobre la API de .NET de ejemplo, consulte el tutorial .NET 6.0 - Ejemplo y tutorial de API CRUD. Pero para ponerse en marcha rápidamente, solo siga los pasos a continuación.

  1. Instale el SDK de .NET desde https://dotnet.microsoft.com/download.
  2. Descargue o clone el código fuente del proyecto desde https://github.com/cornflourblue/dotnet-6-crud-api
  3. Inicie la API ejecutando dotnet run desde la línea de comando en la carpeta raíz del proyecto (donde se encuentra el archivo WebApi.csproj), debería ver el mensaje Ahora escuchando: http://localhost:4000.
  4. De vuelta en la aplicación Angular, elimine o comente la línea debajo del comentario // provider used to create fake backend ubicado en /src/app/app.module.ts, luego inicie la aplicación Angular y ahora debería estar conectado con la API de .NET.


Conecte la aplicación Angular CRUD con Node.js + MS SQL Server API

Para obtener detalles completos sobre el ejemplo Node.js + API MSSQL, consulte el tutorial Node.js + MS SQL Server - Ejemplo y tutorial de API CRUD. Pero para ponerse en marcha rápidamente, solo siga los pasos a continuación.

  1. Instale MS SQL Server: necesitará acceso a la instancia de SQL Server en ejecución para que la API se conecte, puede ser remota (por ejemplo, Azure, AWS, etc.) o en su máquina local. La edición Express de SQL Server está disponible de forma gratuita en https://www.microsoft.com/sql-server/sql-server-downloads. También puede ejecutarlo en un contenedor Docker, las imágenes oficiales de Docker para SQL Server están disponibles en https://hub.docker.com/_/microsoft-mssql-server.
  2. Descargue o clone el código fuente del proyecto desde https://github.com/cornflourblue/node-mssql-crud-api
  3. Instale todos los paquetes npm necesarios ejecutando npm install o npm i desde la línea de comando en la carpeta raíz del proyecto (donde se encuentra el package.json).
  4. Actualice las credenciales de la base de datos en /config.json para conectarse a su instancia de MS SQL Server y asegúrese de que el servidor MSSQL se está ejecutando.
  5. Inicie la API ejecutando npm start (o npm run dev para comenzar con nodemon) desde la línea de comando en la carpeta raíz del proyecto, debería ver el mensaje Servidor escuchando en el puerto 4000.
  6. De vuelta en la aplicación Angular, elimine o comente la línea debajo del comentario // provider used to create fake backend ubicado en /src/app/app.module.ts, luego inicie la aplicación Angular y ahora debería estar conectado con la API de Node + MSSQL.


Estructura del proyecto Angular 14

La CLI de Angular se usó para generar la estructura del proyecto base con el comando ng new <nombre del proyecto>, la CLI también se usa para construir y servir la aplicación. Para obtener más información sobre la CLI de Angular, consulte https://angular.io/cli.

La aplicación y la estructura del código del tutorial siguen principalmente las recomendaciones de mejores prácticas de la guía oficial de estilo angular, con algunos de mis propios ajustes aquí y allá.

Estructura de carpetas

Cada función tiene su propia carpeta (home y users), otros códigos compartidos/comunes como components, services, models, helpers, etc., se colocan en carpetas con el prefijo _ para diferenciarlos fácilmente de las funciones y agruparlos en la parte superior de la estructura de carpetas.

Archivos de barril

Los archivos index.ts en algunas carpetas son archivos de barril que agrupan los módulos exportados de esa carpeta para que puedan importarse usando solo la ruta de la carpeta en lugar de la ruta completa del módulo, y para habilitar importar varios módulos en una sola importación (por ejemplo, import { UserService, AlertService } from '@app/_services').

Módulos de funciones de Angular (Angular Feature Modules)

La función users es un módulo de funciones autónomo que administra su propio diseño, rutas y componentes, y está conectado a la aplicación principal de Angular CRUD dentro de la módulo de enrutamiento de la aplicación con carga diferida (lazy loading).

Alias de ruta de TypeScript

Los alias de ruta @app y @environments se han configurado en tsconfig.json que se asignan a la directorios /src/app y /src/environments. Esto permite que las importaciones sean relativas a las carpetas de aplicaciones y entornos al anteponer rutas de importación con alias en lugar de tener que usar rutas relativas largas (por ejemplo, import MyComponent from '../../../MyComponent').

Estos son los archivos principales del proyecto que contienen la lógica de la aplicación CRUD. Omití algunos archivos generados por el comando Angular CLI ng new que no cambié.

 

Plantilla de componente de alerta

Ruta: /src/app/_components/alert.component.html

La plantilla del componente de alerta contiene el html para mostrar mensajes de alerta en la parte superior de la página, presenta una notificación para cada alerta en la matriz alerts del componente de alerta a continuación.

<div *ngFor="let alert of alerts" class="alert alert-dismissible mt-4 container" [ngClass]="cssClass(alert)">
    <span [innerHTML]="alert.message"></span>
    <button class="btn-close" (click)="removeAlert(alert)"></button>
</div>
 

Componente de alerta

Ruta: /src/app/_components/alert.component.ts

El componente de alerta controla la adición y eliminación de alertas en la interfaz de usuario, mantiene una serie de alertas que se representan mediante la plantilla del componente.

En ngOnInit() el componente recibe nuevas alertas del servicio de alertas al suscribirse al método alertService.onAlert(), cuando se recibe una nueva alerta se agrega a la matriz alerts para su visualización. Las alertas se borran cuando se recibe una alerta con un mensaje vacío del servicio de alertas. El método ngOnInit() también se suscribe a router.events para borrar automáticamente las alertas en el cambio de ruta.

El método ngOnDestroy() cancela la suscripción del servicio de alerta y del enrutador cuando se destruye el componente para evitar pérdidas de memoria por suscripciones huérfanas.

El método removeAlert() elimina el objeto alert especificado de la matriz, lo que permite cerrar alertas individuales en la interfaz de usuario.

El método cssClass() devuelve una clase de alerta Bootstrap correspondiente para cada uno de los tipos de alerta, si está usando algo que no sea Bootstrap, puede cambiar las clases CSS devueltas para adaptarse a su aplicación.

import { Component, OnInit, OnDestroy, Input } from '@angular/core';
import { Router, NavigationStart } from '@angular/router';
import { Subscription } from 'rxjs';

import { Alert, AlertType } from '@app/_models';
import { AlertService } from '@app/_services';

@Component({ selector: 'alert', templateUrl: 'alert.component.html' })
export class AlertComponent implements OnInit, OnDestroy {
    @Input() id = 'default-alert';
    @Input() fade = true;

    alerts: Alert[] = [];
    alertSubscription!: Subscription;
    routeSubscription!: Subscription;

    constructor(private router: Router, private alertService: AlertService) { }

    ngOnInit() {
        // subscribe to new alert notifications
        this.alertSubscription = this.alertService.onAlert(this.id)
            .subscribe(alert => {
                // clear alerts when an empty alert is received
                if (!alert.message) {
                    // filter out alerts without 'keepAfterRouteChange' flag
                    this.alerts = this.alerts.filter(x => x.keepAfterRouteChange);

                    // reset 'keepAfterRouteChange' flag on the rest
                    this.alerts.forEach(x => x.keepAfterRouteChange = false);
                    return;
                }

                // add alert to array
                this.alerts.push(alert);

                // auto close alert if required
                if (alert.autoClose) {
                    setTimeout(() => this.removeAlert(alert), 3000);
                }
           });

        // clear alerts on location change
        this.routeSubscription = this.router.events.subscribe(event => {
            if (event instanceof NavigationStart) {
                this.alertService.clear(this.id);
            }
        });
    }

    ngOnDestroy() {
        // unsubscribe to avoid memory leaks
        this.alertSubscription.unsubscribe();
        this.routeSubscription.unsubscribe();
    }

    removeAlert(alert: Alert) {
        // check if already removed to prevent error on auto close
        if (!this.alerts.includes(alert)) return;

        // fade out alert if this.fade === true
        const timeout = this.fade ? 250 : 0;
        alert.fade = this.fade;

        setTimeout(() => {
            // filter alert out of array
            this.alerts = this.alerts.filter(x => x !== alert);
        }, timeout);
    }

    cssClass(alert: Alert) {
        if (alert?.type === undefined) return;

        const alertTypeClass = {
            [AlertType.Success]: 'alert-success',
            [AlertType.Error]: 'alert-danger',
            [AlertType.Info]: 'alert-info',
            [AlertType.Warning]: 'alert-warning'
        }

        const classes = [alertTypeClass[alert.type]];

        if (alert.fade) classes.push('fade');

        // return space separated class string
        return classes.join(' ');
    }
}
 

Interceptor de errores

Ruta: /src/app/_helpers/error.interceptor.ts

El interceptor de errores intercepta las respuestas http de la API para verificar si hubo algún error, si hubo, el mensaje de error se muestra con el servicio de alerta, se registra en la consola y se vuelve a lanzar al objeto que realiza la llamada para permitir que se agregue allí una lógica adicional de manejo de errores si es necesario.

Se implementa usando la interfaz HttpInterceptor de Angular incluida en HttpClientModule, al implementar la interfaz HttpInterceptor puede crear un interceptor personalizado para capturar todas las respuestas de error de el servidor en una sola ubicación.

Los interceptores HTTP se agregan a la canalización de solicitudes en la sección de proveedores del archivo app.module.ts.

import { Injectable } from '@angular/core';
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

import { AlertService } from '@app/_services';

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
    constructor(private alertService: AlertService) {}

    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        return next.handle(request).pipe(catchError(err => {
            const error = err.error?.message || err.statusText;
            this.alertService.error(error);
            console.error(err);
            return throwError(() => error);
        }))
    }
}
 

API de back-end falsa

Ruta: /src/app/_helpers/fake-backend.ts

Para ejecutar y probar el ejemplo CRUD de Angular 14 sin una API de back-end real, la aplicación utiliza un back-end falso que intercepta las solicitudes HTTP de la aplicación Angular y las devuelve "falsas" respuestas. Esto lo hace una clase que implementa la interfaz HttpInterceptor de Angular, para obtener más información sobre los interceptores HTTP de Angular, consulte https://angular.io/api/common/http/HttpInterceptor o este artículo.

El backend falso contiene una función handleRoute que verifica si la solicitud coincide con una de las rutas falsas en la declaración de cambio, en este momento esto incluye solicitudes para manejar las operaciones CRUD del usuario. Las solicitudes que coinciden son interceptadas y manejadas por una de las siguientes // route functions, las solicitudes que no coinciden se envían al backend real llamando a next.handle(request);. Debajo de las funciones de ruta hay // helper functions para devolver diferentes tipos de respuesta y realizar tareas pequeñas.

import { Injectable } from '@angular/core';
import { HttpRequest, HttpResponse, HttpHandler, HttpEvent, HttpInterceptor, HTTP_INTERCEPTORS } from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { delay, materialize, dematerialize } from 'rxjs/operators';

import { Role } from '@app/_models';

// array in local storage for registered users
const usersKey = 'angular-14-crud-example-users';
const usersJSON = localStorage.getItem(usersKey);
let users: any[] = usersJSON ? JSON.parse(usersJSON) : [{
    id: 1,
    title: 'Mr',
    firstName: 'Rick',
    lastName: 'Sanchez',
    email: '[email protected]',
    role: Role.Admin,
    password: 'snuffles'
}];

@Injectable()
export class FakeBackendInterceptor implements HttpInterceptor {
    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
        const { url, method, headers, body } = request;

        return handleRoute();

        function handleRoute() {
            switch (true) {
                case url.endsWith('/users') && method === 'GET':
                    return getUsers();
                case url.match(/\/users\/\d+$/) && method === 'GET':
                    return getUserById();
                case url.endsWith('/users') && method === 'POST':
                    return createUser();
                case url.match(/\/users\/\d+$/) && method === 'PUT':
                    return updateUser();
                case url.match(/\/users\/\d+$/) && method === 'DELETE':
                    return deleteUser();
                default:
                    // pass through any requests not handled above
                    return next.handle(request);
            }    
        }

        // route functions

        function getUsers() {
            return ok(users.map(x => basicDetails(x)));
        }

        function getUserById() {
            const user = users.find(x => x.id === idFromUrl());
            return ok(basicDetails(user));
        }

        function createUser() {
            const user = body;

            if (users.find(x => x.email === user.email)) {
                return error(`User with the email ${user.email} already exists`);
            }

            // assign user id and a few other properties then save
            user.id = newUserId();
            delete user.confirmPassword;
            users.push(user);
            localStorage.setItem(usersKey, JSON.stringify(users));

            return ok();
        }

        function updateUser() {
            let params = body;
            let user = users.find(x => x.id === idFromUrl());

            if (params.email !== user.email && users.find(x => x.email === params.email)) {
                return error(`User with the email ${params.email} already exists`);
            }

            // only update password if entered
            if (!params.password) {
                delete params.password;
            }

            // update and save user
            Object.assign(user, params);
            localStorage.setItem(usersKey, JSON.stringify(users));

            return ok();
        }

        function deleteUser() {
            users = users.filter(x => x.id !== idFromUrl());
            localStorage.setItem(usersKey, JSON.stringify(users));
            return ok();
        }

        // helper functions

        function ok(body?: any) {
            return of(new HttpResponse({ status: 200, body }))
                .pipe(delay(500)); // delay observable to simulate server api call
        }

        function error(message: any) {
            return throwError(() => ({ error: { message } }))
                .pipe(materialize(), delay(500), dematerialize()); // call materialize and dematerialize to ensure delay even if an error is thrown (https://github.com/Reactive-Extensions/RxJS/issues/648);
        }

        function basicDetails(user: any) {
            const { id, title, firstName, lastName, email, role } = user;
            return { id, title, firstName, lastName, email, role };
        }

        function idFromUrl() {
            const urlParts = url.split('/');
            return parseInt(urlParts[urlParts.length - 1]);
        }

        function newUserId() {
            return users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
        }
    }
}

export const fakeBackendProvider = {
    // use fake backend in place of Http service for backend-less development
    provide: HTTP_INTERCEPTORS,
    useClass: FakeBackendInterceptor,
    multi: true
};
 

Validador MustMatch

Ruta: /src/app/_helpers/must-match.validator.ts

El validador personalizado MustMatch se usa en el ejemplo para validar que ambos campos de contraseña coincidan en el componente de agregar/editar de usuarios. Pero se puede usar para validar que cualquier par de campos coincida (por ejemplo, los campos "correo electrónico" y "confirmación de correo electrónico").

Funciona de forma ligeramente diferente a un validador típico, por lo general, un validador espera un parámetro de control de entrada, mientras que este espera un grupo de formulario porque está validando dos entradas en lugar de una.

Además, en lugar de devolver un error que se adjuntaría al grupo de formulario principal, el validador llama a matchingControl.setErrors() para adjuntar el error al campo de confirmación de contraseña. Pensé que esto tenía más sentido porque el error de validación se muestra debajo del campo confirmPassword en la plantilla.

import { AbstractControl } from '@angular/forms';

// custom validator to check that two fields match
export function MustMatch(controlName: string, matchingControlName: string) {
    return (group: AbstractControl) => {
        const control = group.get(controlName);
        const matchingControl = group.get(matchingControlName);

        if (!control || !matchingControl) {
            return null;
        }

        // return if another validator has already found an error on the matchingControl
        if (matchingControl.errors && !matchingControl.errors.mustMatch) {
            return null;
        }

        // set error on matchingControl if validation fails
        if (control.value !== matchingControl.value) {
            matchingControl.setErrors({ mustMatch: true });
        } else {
            matchingControl.setErrors(null);
        }
        return null;
    }
}
 

Modelos de alerta

Ruta: /src/app/_models/alert.ts

El archivo del modelo de alerta contiene los modelos Alert, AlertType y AlertOptions.

  • Alert defines the properties of each alert object.
  • AlertType is an enumeration containing the types of alerts.
  • AlertOptions defines the options available when sending an alert to the alert service.
export class Alert {
    id?: string;
    type?: AlertType;
    message?: string;
    autoClose?: boolean;
    keepAfterRouteChange?: boolean;
    fade?: boolean;

    constructor(init?: Partial<Alert>) {
        Object.assign(this, init);
    }
}

export enum AlertType {
    Success,
    Error,
    Info,
    Warning
}

export class AlertOptions {
    id?: string;
    autoClose?: boolean;
    keepAfterRouteChange?: boolean;
}
 

Enumeración de roles

Ruta: /src/app/_models/role.ts

La enumeración de roles define los roles que son compatibles con el ejemplo de Angular CRUD.

export enum Role {
    User = 'User',
    Admin = 'Admin'
}
 

Modelo de usuario

Ruta: /src/app/_models/user.ts

El modelo de usuario es una clase pequeña que representa las propiedades de un usuario en la aplicación Angular CRUD. Lo utiliza el servicio de usuario para devolver objetos de usuario fuertemente tipados desde la API.

import { Role } from './role';

export class User {
    id?: string;
    title?: string;
    firstName?: string;
    lastName?: string;
    email?: string;
    role?: Role;
}
 

Alert Service

Ruta: /src/app/_services/alert.service.ts

El servicio de alerta actúa como puente entre cualquier componente del ejemplo de Angular CRUD y el componente de alerta que muestra los mensajes de alerta en la interfaz de usuario. Contiene métodos para enviar, borrar y suscribirse a mensajes de alerta.

Puede activar notificaciones de alerta desde cualquier componente o servicio llamando a uno de los métodos convenientes para mostrar los diferentes tipos de alertas: success(), error(), info() y warn().

Parámetros del método de alerta

Cada método de alerta toma los parámetros (message: string, options?: AlertOptions):

  • El primer parámetro es una cadena (string) para el mensaje de alerta que puede ser texto sin formato o HTML.
  • El segundo parámetro es un objeto AlertOptions opcional que admite las siguientes propiedades (todas son opcionales):
    • id: el id del componente <alert> que mostrará la notificación de alerta. El valor predeterminado es "default-alert".
    • autoClose: si es true, la alerta se cerrará automáticamente después de tres segundos. El valor predeterminado es false.
    • keepAfterRouteChange: si es true, la alerta seguirá mostrándose después de un cambio de ruta, lo que es útil para mostrar una alerta después de una redirección automática (por ejemplo, después de completar un formulario). El valor predeterminado es false.
import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { filter } from 'rxjs/operators';

import { Alert, AlertType, AlertOptions } from '@app/_models';

@Injectable({ providedIn: 'root' })
export class AlertService {
    private subject = new Subject<Alert>();
    private defaultId = 'default-alert';

    // enable subscribing to alerts observable
    onAlert(id = this.defaultId): Observable<Alert> {
        return this.subject.asObservable().pipe(filter(x => x && x.id === id));
    }

    // convenience methods
    success(message: string, options?: AlertOptions) {
        this.alert(new Alert({ ...options, type: AlertType.Success, message }));
    }

    error(message: string, options?: AlertOptions) {
        this.alert(new Alert({ ...options, type: AlertType.Error, message }));
    }

    info(message: string, options?: AlertOptions) {
        this.alert(new Alert({ ...options, type: AlertType.Info, message }));
    }

    warn(message: string, options?: AlertOptions) {
        this.alert(new Alert({ ...options, type: AlertType.Warning, message }));
    }

    // main alert method    
    alert(alert: Alert) {
        alert.id = alert.id || this.defaultId;
        this.subject.next(alert);
    }

    // clear alerts
    clear(id = this.defaultId) {
        this.subject.next(new Alert({ id }));
    }
}
 

Servicio de Usuario

Ruta: /src/app/_services/user.service.ts

El servicio de usuario maneja la comunicación entre la aplicación Angular 14 CRUD y la API de back-end, contiene métodos CRUD estándar para administrar usuarios que realizan solicitudes HTTP correspondientes al punto final /users de la API con Angular Cliente Http.

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

import { environment } from '@environments/environment';
import { User } from '@app/_models';

const baseUrl = `${environment.apiUrl}/users`;

@Injectable({ providedIn: 'root' })
export class UserService {
    constructor(private http: HttpClient) { }

    getAll() {
        return this.http.get<User[]>(baseUrl);
    }

    getById(id: string) {
        return this.http.get<User>(`${baseUrl}/${id}`);
    }

    create(params: any) {
        return this.http.post(baseUrl, params);
    }

    update(id: string, params: any) {
        return this.http.put(`${baseUrl}/${id}`, params);
    }

    delete(id: string) {
        return this.http.delete(`${baseUrl}/${id}`);
    }
}
 

Plantilla de componente de inicio

Ruta: /src/app/home/home.component.html

La plantilla del componente de inicio contiene sintaxis de plantilla html y angular para mostrar un mensaje de bienvenida simple y un enlace a la sección de usuarios.

<div class="p-4">
    <div class="container">
        <h1>Angular 14 - CRUD Example with Reactive Forms</h1>
        <p>An example app showing how to list, add, edit and delete user records with Angular 14.</p>
        <p>👉 <a routerLink="/users">Manage Users</a></p>
    </div>
</div>
 

Componente de inicio

Ruta: /src/app/home/home.component.ts

El componente de inicio es el componente predeterminado del ejemplo CRUD, está vinculado a la plantilla de inicio con la propiedad templateUrl del decorador @Component de Angular.

import { Component } from '@angular/core';

@Component({ templateUrl: 'home.component.html' })
export class HomeComponent { }
 

Plantilla de componente de agregar/editar usuarios

Ruta: /src/app/users/add-edit.component.html

La plantilla de componente de agregar/editar usuarios contiene un formulario dinámico que permite agregar y editar usuarios. El formulario está en modo editar cuando hay una propiedad id de usuario en la ruta actual; de lo contrario, está en modo agregar.

En el modo de edición, el formulario se completa previamente con los detalles del usuario obtenidos de la API y el campo de contraseña es opcional. El comportamiento dinámico se implementa en el componente de adición/edición de usuarios.

<h1>{{title}}</h1>
<form *ngIf="!loading" [formGroup]="form" (ngSubmit)="onSubmit()">
    <div class="row">
        <div class="col mb-3">
            <label class="form-label">Title</label>
            <select formControlName="title" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.title.errors }">
                <option value=""></option>
                <option value="Mr">Mr</option>
                <option value="Mrs">Mrs</option>
                <option value="Miss">Miss</option>
                <option value="Ms">Ms</option>
            </select>
            <div *ngIf="submitted && f.title.errors" class="invalid-feedback">
                <div *ngIf="f.title.errors.required">Title is required</div>
            </div>
        </div>
        <div class="col-3 mb-3">
            <label class="form-label">First Name</label>
            <input type="text" formControlName="firstName" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.firstName.errors }" />
            <div *ngIf="submitted && f.firstName.errors" class="invalid-feedback">
                <div *ngIf="f.firstName.errors.required">First Name is required</div>
            </div>
        </div>
        <div class="col-5 mb-3">
            <label class="form-label">Last Name</label>
            <input type="text" formControlName="lastName" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.lastName.errors }" />
            <div *ngIf="submitted && f.lastName.errors" class="invalid-feedback">
                <div *ngIf="f.lastName.errors.required">Last Name is required</div>
            </div>
        </div>
    </div>
    <div class="row">
        <div class="col-7 mb-3">
            <label class="form-label">Email</label>
            <input type="text" formControlName="email" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.email.errors }" />
            <div *ngIf="submitted && f.email.errors" class="invalid-feedback">
                <div *ngIf="f.email.errors.required">Email is required</div>
                <div *ngIf="f.email.errors.email">Email must be a valid email address</div>
            </div>
        </div>
        <div class="col mb-3">
            <label class="form-label">Role</label>
            <select formControlName="role" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.role.errors }">
                <option value=""></option>
                <option value="User">User</option>
                <option value="Admin">Admin</option>
            </select>
            <div *ngIf="submitted && f.role.errors" class="invalid-feedback">
                <div *ngIf="f.role.errors.required">Role is required</div>
            </div>
        </div>
    </div>
    <div *ngIf="id">
        <h3 class="pt-3">Change Password</h3>
        <p>Leave blank to keep the same password</p>
    </div>
    <div class="row">
        <div class="col mb-3">
            <label class="form-label">Password</label>
            <input type="password" formControlName="password" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.password.errors }" />
            <div *ngIf="submitted && f.password.errors" class="invalid-feedback">
                <div *ngIf="f.password.errors.required">Password is required</div>
                <div *ngIf="f.password.errors.minlength">Password must be at least 6 characters</div>
            </div>
        </div>
        <div class="col mb-3">
            <label class="form-label">Confirm Password</label>
            <input type="password" formControlName="confirmPassword" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.confirmPassword.errors }" />
            <div *ngIf="submitted && f.confirmPassword.errors" class="invalid-feedback">
                <div *ngIf="f.confirmPassword.errors.required">Confirm Password is required</div>
                <div *ngIf="f.confirmPassword.errors.mustMatch">Passwords must match</div>
            </div>
        </div>
    </div>
    <div class="form-group">
        <button [disabled]="loading" class="btn btn-primary">
            <span *ngIf="submitting" class="spinner-border spinner-border-sm me-1"></span>
            Save
        </button>
        <a routerLink="/users" class="btn btn-link">Cancel</a>
    </div>
</form>
<div *ngIf="loading" class="text-center m-5">
    <span class="spinner-border spinner-border-lg align-center"></span>
</div>
 

Componente de adición/edición de usuarios

Ruta: /src/app/users/add-edit.component.ts

El componente de agregar/editar usuarios se usa tanto para agregar como para editar usuarios en la aplicación angular CRUD, el componente está en modo editar cuando hay un parámetro de ruta id de usuario, de lo contrario, está en modo agregar.

En el modo agregar, el campo de contraseña es obligatorio y los campos del formulario están vacíos de forma predeterminada. En el modo editar, el campo de contraseña es opcional y el formulario se completa previamente con los detalles del usuario especificado, que se obtienen de la API con el servicio de usuario. Para obtener más información sobre la validación con formularios reactivos, consulte Angular 14 - Ejemplo de Validación de Formularios Reactivos (Reactive Forms).

Al enviar, un usuario se crea o se actualiza llamando al servicio de usuario y, en caso de éxito, se le redirige a la página de la lista de usuarios con un mensaje de éxito.

import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { first } from 'rxjs/operators';

import { UserService, AlertService } from '@app/_services';
import { MustMatch } from '@app/_helpers';

@Component({ templateUrl: 'add-edit.component.html' })
export class AddEditComponent implements OnInit {
    form!: FormGroup;
    id?: string;
    title!: string;
    loading = false;
    submitting = false;
    submitted = false;

    constructor(
        private formBuilder: FormBuilder,
        private route: ActivatedRoute,
        private router: Router,
        private userService: UserService,
        private alertService: AlertService
    ) {}

    ngOnInit() {
        this.id = this.route.snapshot.params['id'];
        
        this.form = this.formBuilder.group({
            title: ['', Validators.required],
            firstName: ['', Validators.required],
            lastName: ['', Validators.required],
            email: ['', [Validators.required, Validators.email]],
            role: ['', Validators.required],
            // password and confirm password only required in add mode
            password: ['', [Validators.minLength(6), ...(!this.id ? [Validators.required] : [])]],
            confirmPassword: ['', [...(!this.id ? [Validators.required] : [])]]
        }, {
            validators: MustMatch('password', 'confirmPassword')
        });

        this.title = 'Add User';
        if (this.id) {
            // edit mode
            this.title = 'Edit User';
            this.loading = true;
            this.userService.getById(this.id)
                .pipe(first())
                .subscribe(x => {
                    this.form.patchValue(x);
                    this.loading = false;
                });
        }
    }

    // convenience getter for easy access to form fields
    get f() { return this.form.controls; }

    onSubmit() {
        this.submitted = true;

        // reset alerts on submit
        this.alertService.clear();

        // stop here if form is invalid
        if (this.form.invalid) {
            return;
        }

        this.submitting = true;
        this.saveUser()
            .pipe(first())
            .subscribe({
                next: () => {
                    this.alertService.success('User saved', { keepAfterRouteChange: true });
                    this.router.navigateByUrl('/users');
                },
                error: error => {
                    this.alertService.error(error);
                    this.submitting = false;
                }
            })
    }

    private saveUser() {
        // create or update user based on id param
        return this.id
            ? this.userService.update(this.id!, this.form.value)
            : this.userService.create(this.form.value);
    }
}
 

Plantilla de componente de diseño de usuarios

Ruta: /src/app/users/layout.component.html

La plantilla del componente de diseño de usuarios es la plantilla raíz de la sección de usuarios del ejemplo CRUD, contiene el HTML externo para todas las páginas /users y un <router-outlet> para renderizar el componente actualmente enrutado.

<div class="p-4">
    <div class="container">
        <router-outlet></router-outlet>
    </div>
</div>
 

Componente de diseño de usuarios

Ruta: /src/app/users/layout.component.ts

El componente de diseño de usuarios es el componente raíz de la sección de usuarios de la aplicación Angular CRUD, vincula el componente a la plantilla de diseño de usuarios con la propiedad templateUrl del decorador @Component de Angular.

import { Component } from '@angular/core';

@Component({ templateUrl: 'layout.component.html' })
export class LayoutComponent { }
 

Plantilla de componente de lista de usuarios

Ruta: /src/app/users/list.component.html

La plantilla del componente de lista de usuarios muestra una lista de todos los usuarios y contiene botones para agregar, editar y eliminar usuarios en el ejemplo de Angular CRUD.

<h1>Users</h1>
<a routerLink="add" class="btn btn-sm btn-success mb-2">Add User</a>
<table class="table table-striped">
    <thead>
        <tr>
            <th style="width: 30%">Name</th>
            <th style="width: 30%">Email</th>
            <th style="width: 30%">Role</th>
            <th style="width: 10%"></th>
        </tr>
    </thead>
    <tbody>
        <tr *ngFor="let user of users">
            <td>{{user.title}} {{user.firstName}} {{user.lastName}}</td>
            <td>{{user.email}}</td>
            <td>{{user.role}}</td>
            <td style="white-space: nowrap">
                <a routerLink="edit/{{user.id}}" class="btn btn-sm btn-primary me-1">Edit</a>
                <button (click)="deleteUser(user.id)" class="btn btn-sm btn-danger btn-delete-user" [disabled]="user.isDeleting">
                    <span *ngIf="user.isDeleting" class="spinner-border spinner-border-sm"></span>
                    <span *ngIf="!user.isDeleting">Delete</span>
                </button>
            </td>
        </tr>
        <tr *ngIf="!users">
            <td colspan="4" class="text-center">
                <span class="spinner-border spinner-border-lg align-center"></span>
            </td>
        </tr>
    </tbody>
</table>
 

Componente de lista de usuarios

Ruta: /src/app/users/list.component.ts

El componente de lista de usuarios obtiene todos los usuarios del servicio de usuario en el método de ciclo de vida angular ngOnInit() y los pone a disposición a la plantilla de lista de usuarios a través de la propiedad users.

El método deleteUser() primero establece la propiedad user.isDeleting = true para que la plantilla muestre una rueda giratoria en el botón de eliminar, luego llama a this.userService.delete() para eliminar el usuario y elimina el usuario eliminado de la matriz users para eliminarlo de la interfaz de usuario.

import { Component, OnInit } from '@angular/core';
import { first } from 'rxjs/operators';

import { UserService } from '@app/_services';

@Component({ templateUrl: 'list.component.html' })
export class ListComponent implements OnInit {
    users?: any[];

    constructor(private userService: UserService) {}

    ngOnInit() {
        this.userService.getAll()
            .pipe(first())
            .subscribe(users => this.users = users);
    }

    deleteUser(id: string) {
        const user = this.users!.find(x => x.id === id);
        user.isDeleting = true;
        this.userService.delete(id)
            .pipe(first())
            .subscribe(() => this.users = this.users!.filter(x => x.id !== id));
    }
}
 

Módulo de enrutamiento de usuarios

Ruta: /src/app/users/users-routing.module.ts

El módulo de enrutamiento de usuarios define las rutas para el módulo de funciones de usuarios de la aplicación CRUD de ejemplo. Incluye rutas para enumerar, agregar y editar usuarios, y una ruta principal para el componente de diseño que contiene el código de diseño común para la sección de usuarios.

Las rutas de agregar y editar son diferentes, pero ambas cargan el mismo componente (AddEditComponent) que modifica su comportamiento en función de la ruta.

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { LayoutComponent } from './layout.component';
import { ListComponent } from './list.component';
import { AddEditComponent } from './add-edit.component';

const routes: Routes = [
    {
        path: '', component: LayoutComponent,
        children: [
            { path: '', component: ListComponent },
            { path: 'add', component: AddEditComponent },
            { path: 'edit/:id', component: AddEditComponent }
        ]
    }
];

@NgModule({
    imports: [RouterModule.forChild(routes)],
    exports: [RouterModule]
})
export class UsersRoutingModule { }
 

Módulo de usuarios

Ruta: /src/app/users/users.module.ts

El módulo de usuarios define el módulo de características para la sección de usuarios de la aplicación tutorial Angular CRUD junto con metadatos sobre el módulo. Las imports especifican qué otros módulos angulares requiere este módulo, y las declarations indican qué componentes pertenecen a este módulo. Para obtener más información sobre los módulos angulares, consulte https://angular.io/docs/ts/latest/guide/ngmodule.html.

El módulo de usuarios está conectado a la aplicación principal dentro del módulo de enrutamiento de la aplicación con carga diferida (lazy loading).

import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';

import { UsersRoutingModule } from './users-routing.module';
import { LayoutComponent } from './layout.component';
import { ListComponent } from './list.component';
import { AddEditComponent } from './add-edit.component';

@NgModule({
    imports: [
        CommonModule,
        ReactiveFormsModule,
        UsersRoutingModule
    ],
    declarations: [
        LayoutComponent,
        ListComponent,
        AddEditComponent
    ]
})
export class UsersModule { }
 

Módulo de enrutamiento de la aplicación

Ruta: /src/app/app-routing.module.ts

El módulo de enrutamiento de la aplicación define las rutas de nivel superior para la aplicación angular CRUD y genera un módulo de enrutamiento raíz al pasar la matriz de routes al RouterModule.forRoot() método. El módulo se importa al módulo de la aplicación principal a continuación.

La ruta de inicio asigna la ruta raíz de la aplicación al componente de inicio y la ruta de los usuarios carga diferida el módulo de usuarios y lo asigna a /users.

Para obtener más información sobre el enrutamiento de Angular, consulte https://angular.io/guide/router.

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

import { HomeComponent } from './home';

const usersModule = () => import('./users/users.module').then(x => x.UsersModule);

const routes: Routes = [
    { path: '', component: HomeComponent },
    { path: 'users', loadChildren: usersModule },

    // otherwise redirect to home
    { path: '**', redirectTo: '' }
];

@NgModule({
    imports: [RouterModule.forRoot(routes)],
    exports: [RouterModule]
})
export class AppRoutingModule { }
 

Plantilla de componente de aplicación

Ruta: /src/app/app.component.html

La plantilla del componente de la aplicación es la plantilla del componente raíz de la aplicación CRUD, contiene la barra de navegación principal, un componente de alerta global y un componente router-outlet para mostrar el contenido de cada vista según la ruta/camino actual.

<!-- nav -->
<nav class="navbar navbar-expand navbar-dark bg-dark px-3">
    <div class="navbar-nav">
        <a class="nav-item nav-link" routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">Home</a>
        <a class="nav-item nav-link" routerLink="/users" routerLinkActive="active">Users</a>
    </div>
</nav>

<!-- main app container -->
<div class="app-container bg-light">
    <alert></alert>
    <router-outlet></router-outlet>
</div>
 

Componente de la aplicación

Ruta: /src/app/app.component.ts

El componente de la aplicación es el componente raíz del ejemplo CRUD, define la etiqueta raíz de la aplicación como <app-root></app-root> con la propiedad selector del decorador @Component() y está vinculada a la plantilla de componente de aplicación con la propiedad templateUrl.

import { Component } from '@angular/core';

@Component({ selector: 'app-root', templateUrl: 'app.component.html' })
export class AppComponent { }
 

Módulo de la aplicación

Ruta: /src/app/app.module.ts

El módulo de la aplicación define el módulo Angular raíz de la aplicación CRUD junto con los metadatos sobre el módulo. Para obtener más información sobre los módulos angulares, consulte https://angular.io/docs/ts/latest/guide/ngmodule.html.

Aquí es donde se agrega el proveedor de backend falso a la aplicación; para cambiar a un backend real, simplemente elimine el fakeBackendProvider que se encuentra debajo del comentario // provider used to create fake backend.

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';

// used to create fake backend
import { fakeBackendProvider } from './_helpers';

import { AppRoutingModule } from './app-routing.module';
import { ErrorInterceptor } from './_helpers';
import { AppComponent } from './app.component';
import { AlertComponent } from './_components';
import { HomeComponent } from './home';

@NgModule({
    imports: [
        BrowserModule,
        ReactiveFormsModule,
        HttpClientModule,
        AppRoutingModule
    ],
    declarations: [
        AppComponent,
        AlertComponent,
        HomeComponent
    ],
    providers: [
        { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
        
        // provider used to create fake backend
        fakeBackendProvider
    ],
    bootstrap: [AppComponent]
})
export class AppModule { };
 

Configuración del entorno de producción

Ruta: /src/environments/environment.prod.ts

La configuración del entorno de producción contiene variables necesarias para ejecutar la aplicación en producción. Esto le permite crear la aplicación con una configuración diferente para cada entorno diferente (por ejemplo, producción y desarrollo) sin actualizar el código de la aplicación.

Cuando compila la aplicación para producción con el comando ng build --configuration production, la salida environment.ts se reemplaza por environment.prod.ts.

export const environment = {
    production: true,
    apiUrl: 'http://localhost:4000'
};
 

Configuración del entorno de desarrollo

Ruta: /src/environments/environment.ts

La configuración del entorno de desarrollo contiene variables necesarias para ejecutar la aplicación en desarrollo.

Se accede a la configuración del entorno importando el objeto del entorno en cualquier servicio Angular del componente con la línea importar {entorno} desde '@environments/environment' y acceder a las propiedades en el objeto environment, consulte el servicio de usuario para ver un ejemplo.

export const environment = {
    production: false,
    apiUrl: 'http://localhost:4000'
};
 

Archivo index.html principal

Ruta: /src/index.html

El archivo index.html principal es la página inicial cargada por el navegador que inicia todo. La CLI de Angular (con Webpack bajo el capó) agrupa todos los archivos javascript compilados y los inyecta en el cuerpo de la página index.html para que el navegador pueda cargar y ejecutar los scripts.

<!DOCTYPE html>
<html>
<head>
    <base href="/" />
    <title>Angular 14 - CRUD Example with Reactive Forms</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- bootstrap css -->
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <app-root></app-root>
</body>
</html>
 

Archivo main.js de Angular

Ruta: /src/main.ts

El archivo main.js es el punto de entrada utilizado por angular para iniciar y arrancar la aplicación.

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
    enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
    .catch(err => console.error(err));
 

Polirellenos

Ruta: /src/polyfills.ts

Algunas funciones utilizadas por Angular 14 aún no son compatibles de forma nativa con todos los principales navegadores, los polyfills se utilizan para agregar compatibilidad con funciones cuando sea necesario para que su aplicación Angular funcione en todos los principales navegadores.

Este archivo es generado por Angular CLI al crear un nuevo proyecto con el comando ng new, he excluido los comentarios en el archivo por razones de brevedad.

import 'zone.js';  // Included with Angular CLI.
 

Estilos LESS/CSS globales

Ruta: /src/styles.less

El archivo de estilos globales contiene estilos LESS/CSS que se aplican globalmente en toda la aplicación CRUD.

/* You can add global styles to this file, and also import other style files */
.app-container {
    min-height: 320px;
    overflow: hidden;
}

.btn-delete-user {
    width: 40px;
    text-align: center;
    box-sizing: content-box;
}
 

npm package.json

Ruta: /package.json

El archivo package.json contiene información de configuración del proyecto, incluidas las dependencias del paquete que se instalan cuando ejecuta npm install y scripts que se ejecutan cuando ejecuta npm start o npm run build etc. La documentación completa está disponible en https://docs.npmjs.com/files/package.json.

{
    "name": "angular-14-example",
    "version": "0.0.0",
    "scripts": {
        "ng": "ng",
        "start": "ng serve --open",
        "build": "ng build",
        "watch": "ng build --watch --configuration development",
        "test": "ng test"
    },
    "private": true,
    "dependencies": {
        "@angular/animations": "^14.2.0",
        "@angular/common": "^14.2.0",
        "@angular/compiler": "^14.2.0",
        "@angular/core": "^14.2.0",
        "@angular/forms": "^14.2.0",
        "@angular/platform-browser": "^14.2.0",
        "@angular/platform-browser-dynamic": "^14.2.0",
        "@angular/router": "^14.2.0",
        "rxjs": "~7.5.0",
        "tslib": "^2.3.0",
        "zone.js": "~0.11.4"
    },
    "devDependencies": {
        "@angular-devkit/build-angular": "^14.2.8",
        "@angular/cli": "~14.2.8",
        "@angular/compiler-cli": "^14.2.0",
        "@types/jasmine": "~4.0.0",
        "jasmine-core": "~4.3.0",
        "karma": "~6.4.0",
        "karma-chrome-launcher": "~3.1.0",
        "karma-coverage": "~2.2.0",
        "karma-jasmine": "~5.1.0",
        "karma-jasmine-html-reporter": "~2.0.0",
        "typescript": "~4.7.2"
    }
}
 

TypeScript tsconfig.json

Ruta: /tsconfig.json

El archivo tsconfig.json contiene la configuración básica del compilador de TypeScript para todos los proyectos en el espacio de trabajo de Angular, configura cómo se compilará/transpilará el código de TypeScript en JavaScript que el navegador pueda entender. Para obtener más información, consulte https://angular.io/config/tsconfig.

La mayor parte del archivo no ha cambiado desde que Angular CLI lo generó, solo se agregó la propiedad paths para asignar @app y @environments alias a los directorios /src/app y /src/environments. Esto permite que las importaciones sean relativas a las carpetas de aplicaciones y entornos al anteponer rutas de importación con alias en lugar de tener que usar rutas relativas largas (por ejemplo, import MyComponent from '@app/MyComponent' en su lugar de importar MyComponent from '../../../MyComponent').

/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
    "compileOnSave": false,
    "compilerOptions": {
        "baseUrl": "./",
        "outDir": "./dist/out-tsc",
        "allowSyntheticDefaultImports": true,
        "forceConsistentCasingInFileNames": true,
        "strict": true,
        "noImplicitOverride": true,
        "noPropertyAccessFromIndexSignature": false,
        "noImplicitReturns": true,
        "noFallthroughCasesInSwitch": true,
        "sourceMap": true,
        "declaration": false,
        "downlevelIteration": true,
        "experimentalDecorators": true,
        "moduleResolution": "node",
        "importHelpers": true,
        "target": "es2020",
        "module": "es2020",
        "lib": [
            "es2020",
            "dom"
        ],
        "paths": {
            "@app/*": ["src/app/*"],
            "@environments/*": ["src/environments/*"]
        }
    },
    "angularCompilerOptions": {
        "enableI18nLegacyMessageIdFormat": false,
        "strictInjectionParameters": true,
        "strictInputAccessModifiers": true,
        "strictTemplates": true
    }
}

 


Suscríbete o Sígueme para actualizaciones

Suscríbete a mi canal de YouTube o sígueme en Twitter, Facebook o GitHub para recibir notificaciones cuando publique contenido nuevo.

Aparte de la codificación...

Actualmente estoy intentando viajar por Australia en motocicleta con mi esposa Tina en un par de Royal Enfield Himalayan. Puedes seguir nuestras aventuras en YouTube, Instagram y Facebook.


¿Necesita Ayuda Angular 14?

Buscar fiverr para encontrar ayuda rápidamente de desarrolladores Angular 14 experimentados.