Proyecto Angular#

Otra forma de entender un framework es poniendo en práctica los conceptos. Haremos un proycto sencillo con la mayor parte de lo aprendido.

En el curso hemos hecho una aplicación para compartir recetas. Documentaremos algunas partes para este artículo, evitando las tareas repetitivas.

Configuración inicial#

Como en todo proyecto, hay una tarea prévia de configuración. No es necesario explicar todos los pasos o el porqué, ya que está en apartados anteiores, pero vamos a enumerarlos. Algunos pasos, lógicamente, no son necesarios en todos los proyectos:

  • Creación de un repositorio de Git y las ramas, issues, etc.

  • Instalación de Angular CLI y creación del proyecto Angular:

sudo npm install -g @angular/cli [--force]
ng new recipes
cd recipes 
ng serve -o
  • Creación de todos los componentes, interfaces y servicios que se han decidido durante la etapa del análisis:

ng g component components/header
ng g component components/footer
ng g component components/home
ng g component components/login
ng g component recipes/recipe-card
ng g component recipes/recipe-detail
ng g component recipes/recipe-table-row
ng g component recipes/recipe-table
ng g component recipes/recipe-list
...
ng g service recipes/supabase
ng g interface recipes/i-recipe
...

Es buena idea crear todos los componentes que sepamos al principio para tener más claro la estructura y para no tener que reiniciar cada vez.

  • Creación de la estructura básica de la web en app.component.html:

<app-header></app-header>
<router-outlet></router-outlet>
<app-footer></app-footer>
  • Creación de las rutas y testeo manual en app.routes.ts:

export const routes: Routes = [
    {path: 'home', component: HomeComponent},
    {path: 'main', component: RecipeListComponent},
    {path: 'table', component: RecipeTableComponent},
    {path: 'recipes/:id', component: RecipeDetailComponent},
    {path: '**', pathMatch: 'full', redirectTo: 'home'}
];

En app.config.ts:

provideRouter(routes,  withHashLocation()),
  • Instalación de Bootstrap:

npm install bootstrap
npm install --save-dev @types/bootstrap

en angular.json:

"styles": [
  "node_modules/bootstrap/dist/css/bootstrap.min.css",
  "src/styles.scss"
],
"scripts": [
  "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
]
  • Creación de un Navbar de Bootstrap para el menú dentro del componente header. Cada ruta se indicará así:

<a class="nav-link" aria-current="page" [routerLink]="['home']" [routerLinkActive]="['active']">Home</a>
  • Creación del HTML estático de secciones como el footer o home.

Así tendremos la estructura mínima con rutas de la SPA y ya podemos empezar a trabajar en la funcionalidad y la vista de la web.

Los pasos anteriores no detallan la importación de los módulos en los archivos y otros pasos. Todo esto está explicado en apartados anteriores de este libro.

Componentes con datos de ejemplo#

Empezaremos dando contenido a los componentes. Un buen ejemplo puede ser la lista de recetas.

Interfaz IRecipe#

Aquí tenemos un fragmento de la interfaz, que se corresponde con las columnas de la tabla en la base de datos:

export interface IRecipe {
    idMeal:                      null | string;
    strMeal:                     null | string;
    strDrinkAlternate:           null | string;
    strCategory:                 null | string;
    strArea:                     null | string;
    strInstructions:             null | string;
    strMealThumb:                null | string;
    strTags:                     null | string;
    strYoutube:                  null | string;
    strIngredient1:              null | string;
    strIngredient2:              null | string;
    ...

Lista de recetas#

El componente RecipesListComponent debe tener algo que mostrar. Al principo es posible hacer un mockde los datos creando un array de recetas que respeta la interfaz creada anteriormente:

import { IRecipe } from "../i-recipe"

export const recipes: IRecipe[] = [
    {
        "idMeal": "52878",
        "strMeal": "Beef and Oyster pie",
        "strDrinkAlternate": null,
        "strCategory": "Beef",

Este mock será descartado cuando tengamos los datos de la base de datos, pero puede servir mientras tanto para crear los componentes.

El primer componente será RecipesListComponent. Aquí un extracto del código relevante:

import {recipes} from "./recipes_exemples"

@Component({
  selector: 'app-recipes-list',
  imports: [CommonModule, RecipeCardComponent],
  templateUrl: './recipes-list.component.html',
  styleUrl: './recipes-list.component.css'
})
export class RecipesListComponent implements OnInit, OnDestroy {

  public recipes: IRecipe[] = recipes;

En el HTML podemos hacer un @for para ir creando los <app-recipe-card> para cada receta:

@for (recipe of recipes; track $index) {
<div class="col">
    <app-recipe-card [recipe]="recipe"></app-recipe-card>
</div>
}

@Input con las tarjetas#

Como se ve, en cada <app-recipe-card> se añade [recipe]="recipe" para pasar ese objeto por @Input al componente hijo. Este lo recibe así:

 @Input({ required: true,  }) recipe!: IRecipe;

Le hemos llamado tarjetas porque las podemos hacer con un card de Bootstrap:

 <div class="card" style="width: 18rem;">
        <img src={{recipe.strMealThumb}} class="card-img-top" alt={{recipe.strMeal}}>
        <div class="card-body">
          <h5 class="card-title">{{recipe ? recipe.strMeal : "Sin titulo"}}</h5>
          <p>{{recipe.strInstructions}}</p>
          <a [routerLink]="['/recipes',recipe.idMeal]"  class="btn btn-primary">Detail</a>
        </div>
      </div>

@Output de las tarjetas a la vista#

Vamos a poner algún tipo de retroacción a las tarjetas para demostrar el funcionamiento de @Output y lo dejaremos listo para cuando podamos guardarla de forma persistente en la base de datos.

La tabla de recetas#

En el caso de la tabla hemos hecho el componente RecipeTableComponent que contendrá los componentes RecipeTableRowComponent. El funcionamiento es igual que con las card, pero el problema es que <table> debe tener siempre <tr> inmediatamente dentro. Por eso no podemos crear una etiqueta para el componente RecipeTableRowComponent. Para solucionarlo usaremos un selector por atributo:

@Component({
  selector: '[app-recipe-table-row]',
  imports: [],
  templateUrl: './recipe-table-row.component.html',
  styleUrl: './recipe-table-row.component.css'
})

Y lo añadiremos como atributo de los tr:

@for (recipe of recipes; track $index) {
<tr app-recipe-table-row [recipe]="recipe"></tr>
}

Servicios#

Necesitamos servicios para gestionar la comunicación con Supabase de los distintos componentes.

Configurar Supabase#

En este caso vamos a usar el SDK de Supabase por simplificar. Este SDK es genérico y no totalmente enfocado a la metodología Angular de usar el HTTPClient y Observables. Por eso vamos a adaptar las peticiones a Observables.

Como se ve en el apartado de Supabase y Angular, hay que crear el environment:

ng generate environments 

Y añadir los datos de conexión con Supabase:

export const environment = {
  production: false,
  supabaseUrl: 'YOUR_SUPABASE_URL',
  supabaseKey: 'YOUR_SUPABASE_KEY',
};

Luego en el servicio haremos las peticiones:

@Injectable({
  providedIn: 'root'
})
export class SupabaseService {

  private supabase: SupabaseClient;

  constructor(private http: HttpClient) { 
    this.supabase = createClient(environment.supabaseUrl, environment.supabaseKey);
  }

// Función genérica para Supabase
  async getData(table: string): Promise<any[]> {
    const { data, error } = await this.supabase.from(table).select('*');
    if (error) {
      console.error('Error fetching data:', error);
      throw error;
    }
    return data;
  }

// Adaptador de las promesas a Observables
  getDataObservable(table: string): Observable<any[]> {
    return from(this.getData(table));
  }

// Función que hace la petición a una tabla en concreto
  getMeals(): Observable<IRecipe[]>{
    return this.getDataObservable('meals');
  }

Convertir Supabase SDK en Observables#

Con las promesas resultantes haremos un Observable:

// Adaptador de las promesas a Observables
  getDataObservable(table: string): Observable<any[]> {
    return from(this.getData(table));
  }

// Función que hace la petición a una tabla en concreto
  getMeals(): Observable<IRecipe[]>{
    return this.getDataObservable('meals');
  }

Mostrar los datos en la lista de recetas#

Puesto que getMeals retorna una Observable de listas de recetas, en el componente RecipesListComponent nos suscribimos y quitamos las recetas de ejemplo:

export class RecipesListComponent implements OnInit, OnDestroy {

  constructor(private supabaseService: SupabaseService){}

  public recipes: IRecipe[] = [];

  ngOnInit(): void {

    this.supabaseService.getMeals().subscribe({
      next: meals => {
       this.recipes = meals;
      },
      error: err => console.log(err),
      complete: ()=> console.log('Received')
    })
  }

Rutas con parámetros#

Antes ya hemos configurado el router para tener rutas con parámetros a una receta en particular:

    {path: 'recipes/:id', component: RecipeDetailComponent},

Crear e invocar rutas con parámetros#

Ahora hay que saber llamar a esas recetas. En el componente RecipeCardComponent hemos puesto un botón con este enlace:

<a [routerLink]="['/recipes',recipe.idMeal]" class="btn btn-primary">Detail</a>

Aceptar los parámetros en las rutas#

La manera de aceptar los parámetros en las rutas puede ser mediante withComponentInputBinding. En app.config.ts:

 provideRouter(routes,  withHashLocation(), withComponentInputBinding()),

Y luego en el componente RecipeDetailComponent las aceptamos como @Input:

@Input('id') recipeID?: string;

Obtener y mostrar la receta#

Una vez hecho esto, hay que pedir la receta, así que necesitamos:

Función en SupabaseService para obtener una receta#

En este caso lo que hemos hecho es modificar las funciones para hacerlas más genéricas y añadir la posibilidad de pedir más de un dato:

async getData(table: string, search?: Object, ids?: string[], idField?: string): Promise<any[]> {
    let query = this.supabase.from(table).select('*');
    if (search) {
      query = query?.match(search);
    }
    if (ids) {
      console.log(idField);

      query = query?.in(idField ? idField : 'id', ids);
    }
    const { data, error } = await query
    if (error) {
      console.error('Error fetching data:', error);
      throw error;
    }
    return data;
  }

getDataObservable<T>(table: string, search?: Object, ids?: string[], idField?: string): Observable<T[]> {
    return from(this.getData(table, search, ids, idField));
  }

getIngredients(ids: (string | null)[]): Observable<Ingredient>{
    return this.getDataObservable<Ingredient>('ingredients', undefined, ids.filter(id => id !== null) as string[], 'idIngredient')
    .pipe(
      mergeMap(ingredients => from(ingredients)),
      mergeMap(async ingredient => {
            const { data, error } = await this.supabase
              .storage
              .from('recipes')
              .download(`${ingredient.strStorageimg}?rand=${Math.random()}`);
            if (data) {
              ingredient.blobimg = URL.createObjectURL(data);
            }
            return ingredient;
          })
        
      )
  }

El operador mergeMap se utiliza en este caso para procesar de manera asíncrona cada ingrediente y emitir cada uno tan pronto como se haya completado el procesamiento.

El primer mergeMap Toma el array de ingredientes emitido por getDataObservable y se lo pasa al from. Cuando el código de dentro emita Observables los aplanará en un único Observable. El from descompone el array para ir trabajando con todos por separado. Genera un Observable que emite cada uno de los valores. El segundo mergeMap convierte las promesas de la descarga de cada imagen en un Observable. Al final se retornan los ingredientes con la imagen dentro de un flujo en el que cada ingrediente va saliendo conforme se completa.

Obtener el Observable en ngOnInit#

ngOnInit(): void {
    this.supabaseService.getMeals(this.recipeID).subscribe({
      next: meals => {
       this.recipe = meals[0];
      this.supabaseService.getIngredients(this.recipe?.idIngredients).subscribe({
        next: ingredients => {
          this.ingredients.push(ingredients);
        }
      });
      },
      error: err => console.log(err),
      complete: ()=> console.log('Received')
    })
  }

Así se van incorporando los ingredientes cuando van llegando.

Mostrar la receta en la plantilla#

<div class="container">
  <div class="row">
    <div class="col-md-6">
      <h2>{{recipe?.strMeal}}</h2>
      <h3>Instructions</h3>
      <p>{{recipe?.strInstructions}}</p>
      <h3>Ingredients</h3>
      <div class="row row-cols-1 row-cols-md-3 g-4">
        @for (ingredient of ingredients; track $index) { @if (ingredient) {
        <div class="col">
          <app-ingredient [ingredient]="ingredient"></app-ingredient>
        </div>
        } }
      </div>
    </div>
    <div class="col-md-6">
      <img
        src="{{recipe?.strMealThumb}}"
        alt="{{recipe?.strMeal}}"
        class="img-fluid"
      />
    </div>
  </div>
</div>

Autenticación#

Puesto que el SDK tiene su propia manera de autenticar y mantener la sesión, no vamos a necesitar guardar el token en LocalStorage ni otros métodos manuales. No obstante, hay que crear un Guard para evitar las rutas no permitidas. Tampoco hace falta el Interceptor, ya que todas las peticiones se hacen con el SDK y ya tienen un mecanismo equivalente.

Funciones de Register, Login, Logout#

Usaremos las funciones de la librería, pero transformandolas en un Observable con from. Luego mapeamos el resultado para atender al error o retornar los datos. Al lanzar el error dentro del map, el Observable emite su función de error, que el Observer puede recoger.

 login(email: string, password: string) {
   const loginResult = from(this.supabase.auth.signInWithPassword({
    email,
    password
  })).pipe(
    map(({ data, error }) => {
        if (error) {
          throw error; 
        }
        return data;
      }),
    tap(()=>this.isLogged())
  );
  return loginResult;
  }

Aquí el Observer:

  sendLogin(){
    this.supaService.login(this.email,this.password).subscribe(
      {next: logindata => console.log(logindata),
        complete: ()=> console.log("complete"),
        error: error =>  this.error = error
       }
    )
  }

Componentes para el acceso y registro#

Creamos el componente con ng g component components/login

Puesto que el formulario de login es muy simple, lo podemos hacer con un formulario de plantilla. Haremos las dos variables email y password, importaremos FormsModule y CommonModule para que funcione la vinculación bidireccional y las clases y podemos usar una plantilla de Bootstrap como esta:

<div class="container">
    <h1>Login</h1>
  <form #loginForm="ngForm" >
    <div class="mb-3">
      <label for="exampleInputEmail1" class="form-label">Email address</label>
      <input
        type="email"  
        name="email"
        [(ngModel)]="email"
        pattern=".*@.*"
        class="form-control"
        required
        id="exampleInputEmail1"
        aria-describedby="emailHelp"
        #emailInput="ngModel"
        [ngClass]="{
          'is-valid': emailInput.touched && emailInput.valid,
          'is-invalid': emailInput.touched && !emailInput.valid
        }"
      />
      @if ( emailInput.touched && !emailInput.valid) {
        <div
        class="alert alert-danger">
        Email requerit en arroba</div>
      }
      <div id="emailHelp" class="form-text">
        We'll never share your email with anyone else.
      </div>
    </div>
    <div class="mb-3">
      <label for="exampleInputPassword1" class="form-label">Password</label>
      <input type="password" class="form-control" id="exampleInputPassword1" 
      name="password"
      [(ngModel)]="password"
      #passwordInput="ngModel"
      required
      [ngClass]="{
        'is-valid': passwordInput.touched && passwordInput.valid,
        'is-invalid': passwordInput.touched && !passwordInput.valid
      }"
      />
    </div>
    <div class="mb-3 form-check">
      <input type="checkbox" class="form-check-input" id="exampleCheck1" />
      <label class="form-check-label" for="exampleCheck1">Check me out</label>
    </div>
    @if(loginForm.valid){
        <button type="submit" class="btn btn-primary">Submit</button>
    }

  </form>
</div>

Usamos los validadores de HTML5 por defecto y no hace falta crear una directiva nueva.

Para el formulario de registro usaremos un formulario reactivo por practicar la validación avanzada. El HTML quedará un poco simplificado y delegaremos el control y la validación al .ts.

Tan solo hay que poner formGroup y formControlName Delegaremos el valor ngClass a getters en el .ts:

<div class="container">
    <h1>Register</h1>
  <form [formGroup]="registerForm" >
    <div class="mb-3">
      <label for="exampleInputEmail1" class="form-label">Email address</label>
      <input
        type="email"
        name="email"
        formControlName="email"
        class="form-control"
        id="exampleInputEmail1"
        aria-describedby="emailHelp"
        [ngClass]="emailNotValid ? 'is-invalid': 'is-valid'"
      />
      @if(emailNotValid){
        <div
        class="alert alert-danger">
        Email requerit en arroba</div>
      }
      <div id="emailHelp" class="form-text">
        We'll never share your email with anyone else.
      </div>
    </div>
    <div class="mb-3" formGroupName="passwords" [ngClass]="crossPasswordsNotValid ? 'bg-warning' : 'bg-success'">
      <label for="exampleInputPassword1" class="form-label">Password</label>
      <input type="password2" class="form-control" id="exampleInputPassword1" 
      name="password"
      formControlName="password"
      [ngClass]="password1NotValid"
      />
      <label for="exampleInputPassword1" class="form-label">Repeat Password</label>
      <input type="password2" class="form-control" id="exampleInputPassword2" 
      name="password2"
      formControlName="password2"
      [ngClass]="password2NotValid"
      />
    </div>
    <div class="mb-3 form-check">
      <input type="checkbox" class="form-check-input" id="exampleCheck1" />
      <label class="form-check-label" for="exampleCheck1">Check me out</label>
    </div>
        <button type="submit" class="btn btn-primary" >Login</button>
      <div
      class="alert alert-danger">
     {{error}}</div>
    
  </form>
</div>

En el .ts construimos el formGroup:

  registerForm: FormGroup;
  constructor(private supaService: SupabaseService, private formBuilder: FormBuilder) {
    this.registerForm = this.formBuilder.group({
      email: ['', [Validators.required, Validators.pattern('.*@.*')]],
      passwords: this.formBuilder.group({
        password: ['', [Validators.required, Validators.pattern('.*[0-9].*'), this.passwordValidator(8)]],
        password2: ['', [Validators.required, Validators.pattern('.*[0-9].*'), this.passwordValidator(8)]],
      }, {
        validators:
          this.passwordCrossValidator
      })
    }
    );
  }

Y luego podemos hacer los getters, como, por ejemplo el del email:

  get emailNotValid() {
    return this.registerForm.get('email')?.invalid && this.registerForm.get('email')?.touched
  }

Un ejemplo de crossValidator:

  passwordCrossValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
    const ps = control.get('password');
    const ps2 = control.get('password2');
    console.log(ps?.value, ps2?.value);
    return ps && ps2 && ps.value === ps2.value ? null : { passwordCrossValidator: true };
  };

Estado de la sesión#

El estado de sesión se gestiona con un Subject, ya que queremos que los componentes se suscriban a los cambios en el estado. Lo definimos y modificamos así:

  loggedSubject = new BehaviorSubject(false);

  async isLogged(){
      const { data: { user } } = await this.supabase.auth.getUser()
      if(user){
        this.loggedSubject.next(true);
      }
      else
      this.loggedSubject.next(false);
  }

Y si nos fijamos, podemos ver que en la función de login, hemos puesto un tap que la llama para actualizar el valor del Subject.

Luego, el menú se suscribe a ese Subject:

  ngOnInit(): void {
    this.logged =  this.supaService.loggedSubject.getValue();
    this.supaService.loggedSubject.subscribe(logged => this.logged = logged);
    this.supaService.isLogged();
  }

El proceso es, obtener el último valor, suscribirse para obtener los valores que vendrán luego y luego llamar a la función para forzar a comprobarlo de nuevo. Esto provocará un next en el subject, lo que llegará a la suscripción.

Ocultar menús#

En la plantilla podemos usar ese valor al que nos hemos suscrito:

    @if(logged){
          <li class="nav-item">
            <a class="nav-link" aria-current="page" [routerLink]="['table']"  [routerLinkActive]="['active']"
            >Table</a>
          </li>
        }

Guards#

Formularios#

En el apartado de autenticación hemos usado un formulario de plantilla y en este vamos a usar uno Reactivo.

Hacemos un formulario para crear recetas o editar recetas existentes. El formulario será capaz de crear y de editar.

Configuración previa para Crear recetas Podemos crear un menú que nos envíe al formulario de creación:

  <li class="nav-item">
            <a class="nav-link"  [routerLink]="['create_recipe']"  [routerLinkActive]="['active']">Create</a>
  </li>

Además, necesitamos una nueva ruta:

    {path: 'create_recipe', component: CreateRecipeComponent},

Configuración previa para Editar recetas Podemos crear un botón en el componente que muestra el detalle de la receta para acceder al formulario de edición:

      <a [routerLink]="['/edit_recipe',recipe?.idMeal]"  class="btn btn-brand-orange">Edit</a>

Y la ruta correspondiente:

    {path: 'edit_recipe/:id', component: CreateRecipeComponent},

Esta ruta será atendida por un @Input en el componente:

  @Input('id') recipeID?: string;

Componente y plantilla Creamos el componente y añadimos un formulario al HTML, aquí un extracto:

<div class="container">
  <form [formGroup]="mealForm">
    <div class="mb-3">
      <label for="strMeal" class="form-label">Nom</label>
      <input
        type="text"
        class="form-control"
        id="strMeal"
        aria-describedby="strMeal"
        name="strMeal"
        formControlName="strMeal"
        [ngClass]="{
          'is-valid': strMealValid,
          'is-invalid': !strMealValid
        }"
      />
    </div>
    <div class="mb-3">
      <label for="strInstructions" class="form-label">Instruccions</label>
      <textarea
        class="form-control"
        id="strInstructions"
        name="strInstructions"
        formControlName="strInstructions"
      ></textarea>
    </div>
    <button type="submit" class="btn btn-primary">Crear</button>
  </form>
</div>

Este formulario es reactivo, así que crearemos el formGroup por código:

  mealForm: FormGroup;

  constructor(
    private supaService: SupabaseService,
    private formBuilder: FormBuilder
  ) {
    this.mealForm = this.formBuilder.group({
      strMeal: ['', [Validators.required]],
      strInstructions: ['', [Validators.required]],
      ingredients: this.formBuilder.array([]),
    });
  }

Validadores#

Puesto que estamos usando validadores que ya están en Validators, lo mejor es hacer un getter para saber si un campo es válido:

  get strMealValid() {
    return (
      this.mealForm.get('strMeal')?.valid &&
      this.mealForm.get('strMeal')?.touched
    );
  }

Si quisieramos hacer nuestos propios validadores podriamos hacer una función. Hay un ejemplo en la parte del registro.

Formulario dinámico#

Los ingredientes se pueden añadir o quitar del formulario, así que los añadiremos en un arrayForm, como se ve en el formBuilder.

Además, necesitamos funciones para obtener el array y para modificarlo:

  getIngredientControl(): FormControl {
    const control = this.formBuilder.control('');
    control.setValidators(Validators.required);
    return control;
  }

  generateIngredientControl(id: string): FormControl {
    const control = this.formBuilder.control(id);
    control.setValidators(Validators.required);
    return control;
  }

  get IngredientsArray(): FormArray {
    return <FormArray>this.mealForm.get('ingredients');
  }

  addIngredient() {
    (<FormArray>this.mealForm.get('ingredients')).push(
      this.getIngredientControl()
    );
  }
  delIngredient(i: number) {   
    (<FormArray>this.mealForm.get('ingredients')).removeAt(i);
  }

Cargar datos en el formulario#

En caso de que llegue un id por la ruta, debemos descargar los datos en el ngOnInit:

  ngOnInit(): void {
    if (this.recipeID) {
      // falta demanar tots els ingredients (id, nom)
      this.supaService.getMeals(this.recipeID).subscribe({
        next: (meals) => {
          this.mealForm.reset(meals[0]);
          meals[0].idIngredients.forEach(i=>{
            if(i){
              (<FormArray>this.mealForm.get('ingredients')).push(
                this.generateIngredientControl(i)
             )
            }
          })
        },
        error: (err) => console.log(err),
        complete: () => console.log('Received'),
      });
    }
  }

En este caso, es especialmente interesante porque rellenamos también el arrayForm

Aplicación en tiempo real#

Las conexiones al servidor se suelen hacer unidireccionalmente y una por una. El protocolo HTTP está pensado para hacer peticiones get post… desde el cliente. Pero si queremos que el servidor informe al cliente o que la comunicación no se corte cuando se acaba la transferencia, debemos usar Websockets.

Un WebSocket es un protocolo de comunicación que permite establecer una conexión bidireccional y persistente entre un cliente y un servidor. A diferencia de HTTP, que sigue un modelo de petición/respuesta, los WebSockets permiten que tanto el cliente como el servidor envíen y reciban datos en tiempo real.

Supabase Realtime#

La manera de implementar Websockets en supabase es con su SDK y el servicio Realtime.

Como se explica en el artículo de Supabase hay 3 tipos de comunicación con Realtime. A nosotros nos interesa la opción de Postgres Changes para ir enviando y consultando los mensajes con persistencia en la base de datos.

Para ello, iremos a Database > Publications para dar de alta la tabla que queremos conectar con websockets.

Websockets con SDK a Observables#

El SDK simplifica mucho la conexión al websocket. La idea es crear un Channel que escuche los cambios en una tabla de la base de datos. Esos cambios pueden filtrarse por tipo de evento o por filtros como el un select. Este canal tiene la función subscribe(), que no es igual que la de los Observables. Esta función activa el Websocket y ejecuta la función que indiquemos en la función on().

En nuestro código lo que vamos a hacer es crear un Subject y proporcionarle nuevos datos con next cuando el Websocket nos lo genere con la función de Callback que recibe el payload:

 getsharedRecipesChannel(recipe: number){
    const sharedRecipesSubject: Subject<any> = new Subject<any>();
    const channel = this.supabase
    .channel('shared_recipes_events_change')
    .on(
      'postgres_changes',
      {
        event: '*',
        schema: 'public',
        table: 'shared_recipes_events',
        filter: `shared_recipe=eq.${recipe}`,
      },
      (payload:any) => sharedRecipesSubject.next(payload) // Función de Callback
    )
    .subscribe(); // Este no es un observable
    return sharedRecipesSubject
  }

Interfaz#

Para mostrar esto nos suscribimos al Subject y generamos los datos conforme lleguen.

      this.supabasewebsocketService.getsharedRecipesChannel(Number(this.recipe?.id)).pipe(
        startWith(null) // Para que tenga un valor inicial si fuera necesario
      ).subscribe(
        (events: any) => {
          events && this.recipe!.shared_recipes_events.push(events.new);
        }
      );

Despliegue#

En el caso de este proyecto se ha decidido desplegar mediante integración continua desde Github Actions a un VPS en Azure. Este es el yml del action en el que ser instalan las dependencias necesarias, se ejecuta en build y se sube mediante scp al servidor:

name: Build and Deploy Angular App

on:
  push:
    branches:
      - master
    paths:
      - 09-angular/2425/myrecipes/** # Ejecuta solo cuando se modifique algo en esta carpeta

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:

    - name: Checkout code
      uses: actions/checkout@v3


    - name: Set up Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '20'

   
    - name: Install dependencies
      working-directory: 09-angular/2425/myrecipes
      run: npm install

    
    - name: Build Angular App
      working-directory: 09-angular/2425/myrecipes
      run: npm run build

    
    - name: Copy build to remote server
      env:
        REMOTE_USER: ${{ secrets.REMOTE_USER }}
        REMOTE_HOST: ${{ secrets.REMOTE_HOST }}
        REMOTE_PASSWORD: ${{ secrets.REMOTE_PASSWORD }}
      run: |
        sudo apt-get update
        sudo apt-get install -y sshpass
        mkdir -p ~/.ssh
        ssh-keyscan -H ${{ secrets.REMOTE_HOST }} >> ~/.ssh/known_hosts
        echo "Added ${{ secrets.REMOTE_HOST }} to known_hosts."
        sshpass -p "${REMOTE_PASSWORD}" scp -r 09-angular/2425/myrecipes/dist/myrecipes/browser/* ${REMOTE_USER}@${REMOTE_HOST}:/var/www/html

Extra: Estilos personalizados de Bootstrap#

Supongamos que estamos usando Bootstrap y queremos unos colores personalizados para toda la aplicación. Puesto que funciona con Sass, podemos modificar el valor de variables y maps.

Quitaremos la referencia a Bootstrap de angular.json y la añadiremos al styles.scss:

// Definir colores personalizados

@use "sass:map";
$primary: #25408f;
$secondary: #8f5325;
$success: #3e8d63;
$info: #13101c;
$warning: #945707;
$danger: #d62518;
$light: #061625;
$dark: #343a40;


$theme-colors: (
  primary: $primary,
  secondary: $secondary,
  success: $success,
  info: $info,
  warning: $warning,
  danger: $danger,
  light: $light,
  dark: $dark,
);



// definir colores customizados
$custom-colors: (
  "brand-blue": #2EC4B6,
  "brand-orange": #FF9F1C,
  "brand-orange-light": #FFBF69,
  "brand-blue-light": #CBF3F0

);

// Combina las paletas
$theme-colors: map.merge($theme-colors, $custom-colors);


// importar finalmente Bootstrap para todo lo demás.
@import "../node_modules/bootstrap/scss/bootstrap" 

Luego podemos usar esos colores personalizados en otras partes, como en el navbar:

<nav class="navbar navbar-expand-lg bg-brand-blue">

Los colores customizados los hemos obtenido de esta paleta: https://coolors.co/palette/ff9f1c-ffbf69-ffffff-cbf3f0-2ec4b6