Modelos, servicios, dependency injection, formas y data binding con Angular + Material.
Demo: GLTrack 2da parte. Una aplicación para monitorear niveles de glucosa para pacientes de diabetes.
Introducción
Hola de nuevo, hace tiempo que no escribía, estuve ocupado trabajando en un proyecto con Angular y ASP.NET Core, aprendí mucho y planeo compartir algunas cosas en próximos posts.
En este post continuaremos trabajando en GLTrack, nuestra aplicación para registrar y monitorear un registro personal de niveles de azúcar en la sangre, esas mediciones que deben tomarse regularmente los pacientes de diabetes.
En el post anterior, habíamos creado una aplicación con tres componentes básicos: navegación, dashboard y registro. Eso ya hace 1 mes y medio, así que si necesitas refrescar la memoria aquí está el link. "Schematics, Componentes y Ruteo con Angular + Material".
Hoy continuaremos trabajando en el componente de registro, este componente va a permitir que el usuario registre sus niveles de glucosa y que pueda revisar y eliminar registros si es necesario.
NOTA: Esta es la parte 2 de 4 de la serie de posts "GLTrack"
- GLTrack parte 1: "Schematics, Componentes y Ruteo con Angular + Material".
- GLTrack parte 2: "Modelos, servicios, dependency injection, formas y data binding con Angular + Material".
- GLTrack parte 3: "Reutiliza componentes. Interacción entre componentes usando @Input() y EventEmmiter con Angular 8".
- GLTrack parte 4: "Gráficas con ng2-charts (Chart.js) y Angular 8. Ordena, filtra, mapea y reduce arreglos de objetos.".
Paso 1: Diseñar un modelo de datos
Como primer paso debemos diseñar un modelo que defina los datos que vamos a guardar. Para esto necesitamos crear una clase o interfaz que contenga las propiedades que necesitamos. En el archivo models.ts crearemos la siguiente interfaz:
export interface MedicionGlucosa {
Id: string;
Timestamp: Date;
Nivel: number;
Comida: string;
AntesDespues: string;
Fecha: Date;
}
Paso 2: Crear una forma de captura
Comenzaremos creando un objeto nuevo para capturar una nueva medición agregando esta línea en el componente registro.component.ts:
nuevoRegistro = {} as MedicionGlucosa;
Antes de continuar con la interfaz gráfica, necesitamos importar algunos módulos de material para poder usar componentes como select y datepicker de angular material. Importa y exporta los siguientes módulos en material.module.ts:
FormsModule,
MatSelectModule,
MatDatepickerModule,
MatNativeDateModule
Una vez importados los módulos necesarios podemos agregar los controles para el formulario en registro.component.html:
<h1>Niveles de glucosa</h1>
<mat-card>
<mat-card-title>Nuevo registro</mat-card-title>
<div class="row">
<mat-form-field class="row-item grow">
<input matInput placeholder="Nivel de glucosa" [(ngModel)]="nuevoRegistro.Nivel" required>
</mat-form-field>
<mat-form-field class="row-item grow">
<mat-label>Comida</mat-label>
<mat-select [(ngModel)]="nuevoRegistro.Comida" required>
<mat-option value="Desayuno">Desayuno</mat-option>
<mat-option value="Comida">Comida</mat-option>
<mat-option value="Cena">Cena</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="row-item grow">
<mat-label>Antes o después de alimentos</mat-label>
<mat-select [(ngModel)]="nuevoRegistro.AntesDespues">
<mat-option value="Antes">Antes</mat-option>
<mat-option value="Despues">Después</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="row-item grow">
<input matInput type="datetime-local" placeholder="Fecha" [(ngModel)]="nuevoRegistro.Fecha">
</mat-form-field>
<button class="row-item" mat-button [disabled]="IsInvalid()" (click)="Guardar()">
<mat-icon>save</mat-icon> Guardar
</button>
<button class="row-item" mat-button (click)="LimpiarForma()">
<mat-icon>delete</mat-icon> Cancelar
</button>
</div>
</mat-card>
Como puedes observar, los controles como input y mat-select tienen un atributo [(ngModel)], en este atributo estamos especificando una variable o propiedad de un objeto con el que va a estar ligado el control. Por ejemplo, el control “Nivel de glucosa” estará ligado a la propiedad “Nivel” del objeto “nuevoRegistro” que acabamos de crear en registro.component.ts.
Observa que el atributo ngModel está envuelto en corchetes “[]” y paréntesis “()”, esto indica que la sincronización del valor es en dos sentidos, entrada “[]” y salida “()”. De esta manera, el valor del control en HTML queda sincronizado con el valor del objeto en nuestro componente.
También podemos ver que los botones del formulario tienen los atributos [disabled] y (click). Esto indica que solamente nos interesa escribir el valor de “[disabled]” con el resultado de la función “IsInvalid()” y que solamente estaremos escuchando el evento “(click)” de los botones.
El botón “Guardar”
En este caso, el valor de “[disabled]” va a depender del resultado de la función “IsInvalid()”. Agrega esta función en registro.component.ts:
Esta función regresará un valor booleano. Verdadero si alguna de las propiedades del nuevo registro es undefined y falso si todas las propiedades tienen un valor asignado. De esta manera nos aseguramos de que el usuario pueda guardar un registro solamente si ha llenado todos los campos del formulario.
IsInvalid() {
return this.nuevoRegistro.Nivel === undefined
|| this.nuevoRegistro.Comida === undefined
|| this.nuevoRegistro.AntesDespues === undefined
|| this.nuevoRegistro.Comida === undefined
|| this.nuevoRegistro.Fecha === undefined;
}
En el botón “Guardar”, indicamos que el evento “(click)” va a ejecutar la función “Guardar()”. Esta función debe encargarse de tomar los datos capturados y enviarlos a una base de datos, un archivo o alguna manera de almacenamiento de datos. En este caso estaremos utilizando “localStorage”, un espacio de almacenamiento del navegador. Agrega las siguientes funciones en registro.component.ts:
Guardar() {
this.nuevoRegistro.Id = this.NewGuid();
const mediciones = this.GetAll();
mediciones.push(this.nuevoRegistro);
localStorage.setItem('registros', JSON.stringify(mediciones));
}
private NewGuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0,
v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
La función “Guardar()” se está encargando de convertir el nuevo registro en una cadena de caracteres con formato JSON y guardarla en localStorage, mientras que utiliza la función “NewGuid()” para generar un GUID que es usado como llave o identificador único para el nuevo registro.
Esto podría funcionar, sin embargo, la única responsabilidad de este componente debería ser presentar datos en la pantalla y encargarse de las interacciones del usuario con la interfaz gráfica.
Para solucionar esto y cumplir con el 1er principio SOLID – Single responsibility – necesitamos crear un servicio de angular, que se va a encargar del almacenamiento de datos únicamente, sin depender de la interfaz gráfica, de validaciones ni del estado de ningún componente.
En la consola ejecuta el siguiente comando para crear el servicio:
ng generate service services/mediciones
Esto creará un nuevo servicio en app/services/mediciones.service.ts.
El servicio debe encargarse de inicializar nuestros datos en localStorage si es que no existen, por ejemplo, si es la primera vez que ejecutamos la aplicación o si hemos borrado los datos del navegador. Para esto, debemos agregar esta validación al constructor del servicio.
De esta manera cuando el servicio es instanciado en la aplicación, el constructor verifica si existen datos en el navegador y de no ser así inicializa con un arreglo vacío.
Para encargarse de la lectura y escritura de datos, vamos a agregar 3 funciones al servicio: GetAll() para recuperar la lista completa de registros, Create() para guardar un nuevo registro y Delete() para eliminar un registro en especial.
Esta función recupera los datos de localStorage como un string y lo convierte en un arreglo de objetos de
tipo
MedicionGlucosa.
Después, agregaremos la función Create(). Esta función recibe como parámetro un objeto que debemos guardar. Para esto, el primer paso es generar una nueva llave única para identificarlo en la lista de objetos. Después, obtenemos la lista de objetos guardados y agregamos el nuevo registro para guardar la lista actualizada.
Observa que la creación de un GUID se encuentra en una función separada “NewGuid()” esta función tiene el modificador “private”, esto quiere decir que esta función solo es accesible dentro del servicio, a diferencia de las demás funciones que son visibles desde otros servicios, módulos o componentes.
Por último, agregaremos la función “Delete()” que se encarga de eliminar registros de la lista. Esta función recibe como parámetro un string que debe contener el ID del objeto que será eliminado.
Después de este paso el servicio mediciones.service.ts debe contener lo siguiente:
import { Injectable } from '@angular/core';
import { MedicionGlucosa } from '../models/models';
@Injectable({
providedIn: 'root'
})
export class MedicionesService {
storageKey = 'mediciones';
constructor() {
if (localStorage.getItem(this.storageKey) === null) {
localStorage.setItem(this.storageKey, JSON.stringify([] as MedicionGlucosa[]));
}
}
GetAll() {
const medicionesString = localStorage.getItem(this.storageKey);
return JSON.parse(medicionesString) as MedicionGlucosa[];
}
Create(mc: MedicionGlucosa) {
mc.Id = this.NewGuid();
const mediciones = this.GetAll();
mediciones.push(mc);
localStorage.setItem(this.storageKey, JSON.stringify(mediciones));
}
Delete(id: string) {
const mediciones = this.GetAll();
const i = mediciones.findIndex(f => f.Id === id);
if (i > -1) {
mediciones.splice(i, 1);
localStorage.setItem(this.storageKey, JSON.stringify(mediciones));
}
}
private NewGuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0,
v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
}
Ahora podemos regresar al componente de registro y actualizarlo para dejar que el servicio se encargue de leer y escribir los datos.
Para poder utilizar el servicio que acabamos de crear desde registro.component.ts necesitamos utilizar una técnica conocida como Dependency Injection. De esta manera, no necesitamos crear una instancia del servicio, sino que Angular se encargará de crearla e inyectarla en todos los componentes que la necesiten. Para utilizar Dependency Injection, solamente agrega un parámetro al constructor del componente en registro.component.ts:
import { MedicionesService } from 'src/app/services/mediciones.service';
…
constructor(private mediciones: MedicionesService) {
}
Esto es suficiente para que una instancia del servicio “MedicionesService” sea accesible desde el componente.
Ahora podemos actualizar la función “Guardar()” en registro.component.ts para que solo se encargue de actualizar la interfaz gráfica y le deje el trabajo de escribir los datos al servicio inyectado:
Guardar() {
if (!this.IsInvalid()) {
this.mediciones.Create(this.nuevoRegistro);
this.LimpiarForma();
this.registros = this.mediciones.GetAll();
this.dataSource.data = this.registros;
}
}
LimpiarForma() {
this.nuevoRegistro = {} as MedicionGlucosa;
}
Además de actualizar la función “Guardar()” hemos creado una función “LimpiarForma()” que se encarga de limpiar los controles del formulario reinicializando el objeto “nuevoRegistro”. Recuerda que los controles del formulario están ligados a este objeto utilizando la directiva “ngModel”, por eso no es necesario manipular el HTML manualmente.
Esta función (“LimpiarForma()”) también es utilizada por el botón “Cancelar” del formulario.
Paso 3: Crear tabla de registros
A continuación, necesitamos crear un componente que muestre una tabla con los datos guardados por el usuario.
Agrega el siguiente código HTML al archivo registro.component.html:
<mat-card>
<mat-card-title>Mis registros</mat-card-title>
<div class="row">
<mat-form-field class="row-item grow">
<input matInput (keyup)="applyFilter($event.target.value)" placeholder="Buscar">
</mat-form-field>
</div>
<table mat-table [dataSource]="dataSource" matSort>
<ng-container matColumnDef="fecha">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Fecha </th>
<td mat-cell *matCellDef="let row"> {{row.Fecha|date:'medium'}} </td>
</ng-container>
<ng-container matColumnDef="nivel">
<th mat-header-cell *matHeaderCellDef mat-sort-header> ID </th>
<td mat-cell *matCellDef="let row"> {{row.Nivel}} </td>
</ng-container>
<ng-container matColumnDef="comida">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Comida </th>
<td mat-cell *matCellDef="let row"> {{row.Comida}} </td>
</ng-container>
<ng-container matColumnDef="antesDespues">
<th mat-header-cell *matHeaderCellDef mat-sort-header> Antes/Después </th>
<td mat-cell *matCellDef="let row"> {{row.AntesDespues}} </td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef> </th>
<td mat-cell *matCellDef="let row">
<button mat-icon-button (click)="Eliminar(row.Id)">
<mat-icon>delete</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<mat-paginator [pageSizeOptions]="[5, 10, 25, 100]"></mat-paginator>
</mat-card>
Además de definir aspectos estéticos del componente, estamos insertando un campo de texto para filtrar los contenidos de la tabla, también especifica las columnas disponibles y el dato que mostraremos en cada una de ellas. Por último, estamos insertando un paginador para ayudar al usuario a navegar entre sus registros.
Ahora necesitamos conectar los datos en registro.component.ts a la tabla. Para esto comenzaremos creando una variable que guardará la lista de registros en el componente.
export class RegistroComponent implements OnInit {
registros = [] as MedicionGlucosa[];
nuevoRegistro = {} as MedicionGlucosa;
…
}
También debemos agregar algunas variables para el funcionamiento de la tabla:
displayedColumns: string[] = ['fecha', 'nivel', 'comida', 'antesDespues', 'actions'];
dataSource: MatTableDataSource<MedicionGlucosa>;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
La variable “displayedColumns” es un arreglo de strings que indica el nombre de las columnas y el orden en el que serán mostradas en la tabla, estos valores deben coincidir con el nombre que hemos indicado en el archivo registro.component.html.
Ahora tenemos una tabla lista para mostrar datos, pero necesitamos recuperar los datos existentes para llenar la tabla cuando se carga el componente. Para esto necesitamos utilizar el servicio mediciones.service.ts en la función “ngOnInit()” de nuestro componente. Agrega las siguientes líneas en el archivo registro.component.ts:
ngOnInit() {
this.registros = this.mediciones.GetAll();
this.dataSource = new MatTableDataSource(this.registros);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
De esta manera, cuando se inicializa registro.component.ts recuperamos los datos almacenados e inicializamos la tabla para mostrar los datos.
Lo único que falta en la tabla es la habilidad de eliminar un registro. Para esto utilizaremos el botón en la última columna.
Esta columna muestra un botón para eliminar registros. Con el atributo (click) estaremos ejecutando la función “Eliminar()” cuando el usuario dispare el evento “click”. Agrega la función “Eliminar()” en registro.component.ts:
Eliminar(id: string) {
this.mediciones.Delete(id);
this.registros = this.mediciones.GetAll();
this.dataSource.data = this.registros;
}
Esta función recibe como parámetro el id del registro a eliminar y utiliza el servicio mediciones.service.ts para llevar a cabo la operación. Después de eliminar el registro, vuelve a utilizar el servicio para obtener los datos actualizados y actualiza los datos de la tabla.
Después de este paso, registro.component.ts queda así:
import { Component, OnInit, ViewChild } from '@angular/core';
import { MatTableDataSource, MatPaginator, MatSort } from '@angular/material';
import { MedicionGlucosa } from 'src/app/models/models';
import { MedicionesService } from 'src/app/services/mediciones.service';
@Component({
selector: 'app-registro',
templateUrl: './registro.component.html',
styleUrls: ['./registro.component.css']
})
export class RegistroComponent implements OnInit {
registros = [] as MedicionGlucosa[];
nuevoRegistro = {} as MedicionGlucosa;
displayedColumns: string[] = ['fecha', 'nivel', 'comida', 'antesDespues', 'actions'];
dataSource: MatTableDataSource<MedicionGlucosa>;
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
constructor(private mediciones: MedicionesService) {
}
ngOnInit() {
this.registros = this.mediciones.GetAll();
this.dataSource = new MatTableDataSource(this.registros);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
Guardar() {
if (!this.IsInvalid()) {
this.mediciones.Create(this.nuevoRegistro);
this.LimpiarForma();
this.registros = this.mediciones.GetAll();
this.dataSource.data = this.registros;
}
}
Eliminar(id: string) {
this.mediciones.Delete(id);
this.registros = this.mediciones.GetAll();
this.dataSource.data = this.registros;
}
LimpiarForma() {
this.nuevoRegistro = {} as MedicionGlucosa;
}
IsInvalid() {
return this.nuevoRegistro.Nivel === undefined
|| this.nuevoRegistro.Comida === undefined
|| this.nuevoRegistro.AntesDespues === undefined
|| this.nuevoRegistro.Comida === undefined
|| this.nuevoRegistro.Fecha === undefined;
}
applyFilter(filterValue: string) {
this.dataSource.filter = filterValue.trim().toLowerCase();
if (this.dataSource.paginator) {
this.dataSource.paginator.firstPage();
}
}
}
Conclusión
En este post continuamos construyendo GLTrack, una aplicación para registro de mediciones de glucosa en la sangre para pacientes de diabetes.
Para construir el componente de registro diseñamos un modelo de datos y creamos un formulario de captura utilizando Template-Driven forms de Angular y data-binding con la directiva ngModel.
También creamos un servicio y lo implementamos con Dependency Injection. Por último, creamos una tabla con filtros, sorting y paginación utilizando el componente MatTable de Angular Material.
En el siguiente post, continuaremos construyendo un Dashboard como página de inicio de la aplicación, donde mostraremos datos relevantes para el usuario acerca de sus mediciones de glucosa implementando valores de entrada y salida para interactuar entre componentes.
El código fuente completo de la aplicación de ejemplo puedes encontrarlo en GitHub, en https://github.com/RCKSTR1/GLTrack2 o si prefieres, puedes clonar el repositorio directo en https://github.com/RCKSTR1/GLTrack2.git
Si tuviste algún problema o duda al seguir los pasos, o si tienes alguna pregunta acerca de este post, contáctame en Twitter, Facebook o LinkedIn.
Es cuánto.
Fuentes
- Documentación de Angular. "Template-driven forms". https://angular.io/guide/forms
- Documentación de Angular. "Introducción a los servicios y dependency injection". https://angular.io/guide/architecture-services
- Documentación de Angular Material, "Form field". https://material.angular.io/components/form-field/overview
- Documentación de Angular Material, "Input". https://material.angular.io/components/input/overview
- Documentación de Angular Material, "Select". https://material.angular.io/components/select/overview
- Documentación de Angular Material, "Table". https://material.angular.io/components/table/overview