logocloudstic

Resumen

Angular es uno de los frameworks Javascript más utilizados en el momento junto a React y Vue.js. Angular ofrece una solución completa para el manejo de rutas, formularios, inyección de dependencias y building, entre otros. Además, el uso en UAL-STIC de NestJS para backend, el cual tiene una arquitectura muy basada en Angular, ha propiciado que sea la tecnología actual elegida en UAL-STIC para el desarrollo frontend. Así, tenemos la arquitectura de Angular tanto en frontend como en backend, facilitando a los equipos de desarrollo el cambio entre desarrollo frontend y backend.

En este tutorial se abordan los principales temas y desafíos con los que nos solemos encontrar en cada aplicación en el ámbito de gestión. Se hace a través del desarrollo de una aplicación de ejemplo sencilla y se tratan desde temas metodológicos, como la organización de carpetas y archivos del proyecto, hasta los temas particulares del desarrollo de aplicaciones CRUD, como formularios reactivos, manejo de rutas, paso de datos entre componentes e interacción con servicios REST.

Objetivos
  • Aprender las técnicas básicas y habituales de Angular mediante el desarrollo de una aplicación CRUD.

  • Definir una estructura de carpetas y archivos adecuada para el desarrollo y posterior mantenimiento.

  • Usar las rutas para la navegación de la aplicación y la selección de componentes.

  • Crear servicios que interactúen con una API REST.

  • Manejar correctamente la asincronía de los servicios REST.

  • Crear y utilizar formularios reactivos y validadores de datos.

  • Compartir datos entre componentes.

  • Crear páginas con opciones de filtrado.

  • Crear cuadros de diálogo informativos y de validación.

  • Desarrollar una aplicación Angular sin necesidad de tener un backend definitivo.

  • Utilizar la técnica del lazy loading.

  • Usar variables de entorno.

  • Utilizar Angular Material para la intefaz de la aplicación.

  • Utilizar Angular Flex-Layout para la organización de elementos de la interfaz de usuario.

Disponible el repositorio usado en este tutorial.

1. Introducción

Angular es un framework JavaScript open source mantenido por Google. Es uno de los frameworks Javascript más utilizados en el momento junto a React y Vue.js. Angular ofrece una solución completa para el manejo de rutas, formularios, inyección de dependencias y building, entre otros. Dado que en UAL-STIC se usa NestJS para backend, el cual tiene una arquitectura muy basada en Angular e Ionic para la APP, Angular es la tecnología más adeacuada para desarrollo frontend en UAL-STIC. De esta forma, tenemos la arquitectura de Angular tanto en frontend como en backend y en la APP.

En este tutorial seguiremos los pasos para el desarrollo de una aplicación sencilla de gestión de espacios con el objeto de introducir los principales componentes y técnicas que se siguen en el desarrollo de una aplicación Angular. Trataremos la creación de una aplicación como un conjunto de componentes que se pueden comunicar entre sí, el uso de mecanismos de routing para la presentación de componentes en función de la URL, uso de servicios, separación de la lógica de la presentación, manejo de formularios reactivos y uso de Angular Material.

Se trata sólo de una introducción a aspectos básicos. No obstante, temas tan interesantes, útiles y necesarios como la autenticación, el uso de guardas para la protección de rutas, interceptores, logging y testing, entre otros, no están presentes en este tutorial. En tutoriales posteriores se irán tratando estos y otros temas de interés.

La aplicación a desarrollar ofrece básicamente dos funcionalidades:

  • Una relacionada con la consulta de espacios disponibles para reservar. Ofrecerá una lista de espacios con cada una de las reservas asignadas indicando para cada una de ellas el edificio y aula que ocupa, su fecha, hora, descripción, asignatura y profesor. Se permitirá su consulta mediante cada una de esas características.

  • Otra relacionada con la gestión de solicitudes. Permitirá las cuatro operaciones CRUD de creación, consulta, actualización y eliminación de solicitudes. De las solicitudes se guardarán datos personales de la persona que realiza la solicitud, el tipo de actividad, y el horario de la reserva. En el Anexo II. Prototipo de la aplicación se muestra un prototipo de la aplicación y los comandos y navegación entre pantallas.

2. Creación de la aplicación

El objetivo de esta sección es crear el proyecto de la aplicación junto con las librerías que vaya a usar.

2.1. Creación de la aplicación con el CLI de Angular

Comenzamos creando la aplicación con el CLI de Angular.

$ ng new angular-espacios

? Would you like to add Angular routing? Yes (1)
? Which stylesheet format would you like to use? CSS
1 Indicamos que queremos que genere el archivo de routing.
El archivo app.routing.module.ts

Al crear la aplicación de Angular podemos indicar al CLI que deje configurado un archivo para las rutas principales de la aplicación. Las rutas indican a Router la pantalla (realmente un componente) que hay que mostrar cuando un usuario selecciona ir a la ruta indicada en la URL. Esta ruta puede ser escrita directamente en la barra de direcciones, aunque lo más habitual es que se llegue a ella al seleccionar un enlace o botón en la aplicación (p.e. un elemento del menú o un botón de Crear).

Las rutas las definiremos en la constante routes que aparece en el código siguiente. El código que se muestra es el archivo app.routing.module.ts, que es el que el CLI de Angular genera cuando indicamos que queremos añadir Angular routing al crear el proyecto.

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

const routes: Routes = []; (1)

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }
1 Array donde guardaremos las rutas principales admitidas por la aplicación y la pantalla que mostrará.

Al especificar una ruta no siempre tiene que indicarse el componente de la pantalla que se quiere mostrar al dirigir la aplicación a la ruta. También puede indicarse un módulo de rutas de un bloque funcional de la aplicación (p.e. productos, clientes) si se usa la técnica de lazy loading, que veremos más adelante

2.2. Instalación de librerías

En este proyecto usaremos las librerías siguientes:

  • Angular Material como librería de componentes de la interfaz de usuario. Nos proporcionará botones, tarjetas, desplegables para selección de fechas (date-pickers), cuadros de diálogo, barras para presentar mensajes (snackbars) y demás.

  • Angular Flex-Layout es una librería para la organización o disposición de componentes en la pantalla.

Podíamos haber usado Bootstrap para la organización de los componentes en las pantalla de la aplicación. Sin embargo, usaremos Angular Flex-Layout porque es lo que usa Fuse Angular, el template que se usa actualmente para el desarrollo de las aplicaciones de UAL-STIC.

Para la instalación de Angular Material ejecutaremos el comando siguiente eligiendo las opciones por defecto, salvo en la de los tipos, que seleccionaremos que para usar los estilos tipográficos de Angular.

$ ng add @angular/material

Would you like to proceed? Yes
✔ Package successfully installed.
? Choose a prebuilt theme name, or "custom" for a custom theme: Indigo/Pink        [ Preview: https://material.a
ngular.io?theme=indigo-pink ]
? Set up global Angular Material typography styles? Yes
? Set up browser animations for Angular Material? Yes

La instalación de Angular Flex-Layout la haremos con

$ npm i -s @angular/flex-layout @angular/cdk

Para una mayor comodidad a la hora de escribir código, puedes instalar el plugin de Angular Flex-Layout para Visual Studio: https://marketplace.visualstudio.com/items?itemName=1tontech.angular-material

3. Creación del scaffolding de la aplicación

En esta sección crearemos los módulos, componentes y rutas que tendrá la aplicación. La aplicación constará de una barra lateral a la izquierda, un pie inferior y una zona central donde ser presentarán las pantallas de la aplicación. La figura siguiente ilustra la disposición de estos elementos.

layout

3.1. Creación del módulo shared

Comenzaremos creando un módulo al que denominaremos shared donde incluiremos todos los componentes compartidos de la aplicación, como son las dos barras laterales. En breve crearemos los componentes de las barras.

$ ng g module shared

3.2. Actualización de los módulos de app.module.ts

Para que el módulo shared pueda ser usado desde el componente de la aplicación (app.component), hay que incluir el módulo shared en app.module.ts. También incluiremos en app-module.ts el módulo de Flex-Layout que usaremos para la distribución de elementos en la aplicación. A continuación se muestran los cambios introducidos en app.module.ts para importar SharedModule y FlexLayoutModule

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { SharedModule } from './shared/shared.module';
import { FlexLayoutModule } from '@angular/flex-layout';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    AppRoutingModule, (1)
    BrowserAnimationsModule, (2)
    FlexLayoutModule, (2)
    SharedModule, (3)
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
1 Módulo incluido automáticamente por el CLI de Angular al crear el proyecto con la opción de Angular routing.
2 Módulo de efectos visuales introducido por nuestras nuevas librerías
3 Módulo de Flex-Layout
4 Módulo shared
Angular Flex-Layout

Angular Flex-Layout es una librería de layout para la distribución de elementos en la interfaz de usuario. En este tutorial nos ajustaremos al funcionamiento básico:

En una etiqueta <div> incluiremos fxLayout="row" si queremos que los componentes que hay dentro del <div> se alineen uniformemente en horizontal (a lo largo de una fila -row) o incluiremos fxLayout="column" si queremos que los componentes que hay dentro del <div> se alineen uniformemente en vertical (a lo largo de una columna -column).

La distribución interna dentro del <div> la haremos con fxLayoutAlign, que admite 2 parámetros. El primero representa a la dirección usada en fxLayout (horizontal o vertical) y el segundo representa a su perpendicular. Es decir:

  • Con fxLayout="row", fxLayoutAlign="<row-alignment> <column-alignment>"

  • Con fxLayout="column", fxLayoutAlign="<column-alignment> <row-alignment>"

Los valores predeterminados son

  • start (distribución desde el inicio, uno a continuación del otro) para la primera componente.

  • stretch (estirar ocupando todo) para la componente contraria (la perpendicular).

Para más información, consultar estos enlaces:

3.3. Creación de las barras laterales

Para cada barra crearemos un componente, al que incluiremos dentro del componente shared.

$ ng g component shared/sidebar
$ ng g component shared/footbar

Al crear los componentes dentro de la carpeta del módulo shared, el CLI de Angular incluirá los componentes en declarations, indicando que son componentes del módulo y que podrán referenciarse entre ellos. Sin embargo, dichos compomentes aún no podrán ser utilizados por otros componentes o por otros módulos aunque incluyan al módulo shared.

Para que un componente pueda ser usado fuera del módulo en el que está definido, debe incluirse en el módulo exports del módulo. Así, los módulos que importen dicho módulo ya sí podrán tener acceso a dichos componentes.

A continuación, modificaremos el módulo shared/shared.module.ts para exportar los componentes de las barras laterales y de pie, de forma que se puedan usar fuera de su módulo.

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SidebarComponent } from './sidebar/sidebar.component';
import { FootbarComponent } from './footbar/footbar.component';

@NgModule({
  declarations: [SidebarComponent, FootbarComponent],
  imports: [CommonModule],
  exports: [SidebarComponent, FootbarComponent], (1)
})
export class SharedModule {}
1 Incluir los componentes en el módulo para que puedan ser usados por otros módulos

3.4. Configuración del layout de la aplicación

Como hemos comentado, crearemos un layout en el que tenemos una disposición horizontal con la barra laterial la izquierda y un bloque de dos componentes verticales a continuación.

Definiremos este layout en app.component.html

<div fxLayout="row" fxLayoutAlign="start stretch" fxFill> (1)
  <div>
    <app-sidebar></app-sidebar>
  </div>
  <div fxFlex fxLayout="column">
    <div fxFlex>
      <router-outlet></router-outlet>
    </div>
    <div>
      <app-footbar></app-footbar>
    </div>
  </div>
</div>
1 Disposición de componenes en fila. Alineación desde el inicio en horizontal y ocupando todo en vertical
2 Disposición de componentes en columna

3.5. Creación de un módulo para los módulos de Angular Material

La aplicación de este tutorial usa varios componentes de Angular, como botones, cuadros de diálogo, un módulo de calendario, barra de presentación de mensajes y demás. Lo más adecuado y eficiente es hacer que cada módulo de la aplicación sólo importe los módulos de los componentes Material que va a utilizar. Sin embargo, en este tutorial, por comodidad y facilidad crearemos un módulo que denominaremos Material que exportará todos los módulos de componentes de Angular Material que va a usar la aplicación en su conjunto. Posteriormente, importaremos este módulo desde el resto de módulos de la aplicación. Es cierto que habrá módulos que necesiten todos los módulos de nuestro módulo Material, mientras que habrá otros que quizá no los usen todos. Como hemos dicho esto no es lo más correcto, pero lo haremos aquí por comodidad.

Para crear el módulo Material ejecutaremos

$ ng g module material
Módulos de Angular Material

Para saber los módulos que tenemos que importar para usar un componente de Angular Material, en la sección de componentes de la documentación oficial de Angular Material seleccionaremos el componente deseado. En la pestaña API se indica el módulo que hay que importar para usar el componente de Material.

La figura siguiente ilustra el módulo que hay que importar para usar un botón Material.

MatButtonModule

A continuación se indican los componentes Material que usará la aplicación de este tutorial:

  • Button para los botones de la aplicación.

  • Card para agrupar elementos en tarjetas.

  • DatePicker para la selección de fechas.

  • Dialog para cuadros de diálogo.

  • Form field para los campos de los formularios.

  • Icon para uso de iconos Material.

  • Input para elementos input de los formularios.

  • List para la creación de listas.

  • Select para listas desplegables.

  • Snackbar para barra de mensajes.

  • Steeper para definir un asistente con pasos.

  • Table para presentación de datos en tablas.

También incluiremos el módulo de Flex-Layout para la distribución de elementos en la pantalla. Así quedaría nuestro módulo angular/angular.module.ts:

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

import { FlexLayoutModule } from '@angular/flex-layout';

import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatNativeDateModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatStepperModule } from '@angular/material/stepper';
import { MatTableModule } from '@angular/material/table';

@NgModule({
  declarations: [],
  imports: [CommonModule],
  exports: [ (1)
    FlexLayoutModule,  (2)

    MatButtonModule, (3)
    MatCardModule,
    MatDatepickerModule,
    MatDialogModule,
    MatExpansionModule,
    MatFormFieldModule,
    MatIconModule,
    MatInputModule,
    MatListModule,
    MatNativeDateModule,
    MatSelectModule,
    MatSnackBarModule,
    MatStepperModule,
    MatTableModule,
  ],
})
export class MaterialModule {}
1 Lista de módulos Material exportados para que puedan ser usado al importar este módulo
2 Módulo de Flex-Layout
3 Módulos Material para nuestra aplicación

Para una mayor legibilidad del código, se recomienda dejar ordenada las listas de imports y exports . También se recomienda dejar líneas en blanco entre los grupos de imports y exports para mejorar la legibilidad (p.e. separando los módulos de Angular, de los genéricos de nuestra aplicación y otro bloque para los específicos).

3.6. Creación de los módulos de la aplicación

A partir de la organización o descomposición funcional a un primer nivel de la aplicación crearemos los módulos de la aplicación Angular. En el caso de este tutorial, la aplicación va a estar formada funcionalmente por un módulo de solicitudes y un módulo de espacios. Además, se crerá un módulo home

  • El módulo de solicitudes permitirá listar, crear, modificar y eliminar solitudes de espacios.

  • El módulo de espacios permitirá realizar consultas sobre ocupación de espacios.

  • El módulo home incluye el componente de inicio de la aplicación, que se mostrará al inicio o al tratar de ir a una ruta no disponible.

A continuación se muestra un diagrama que ilustra esta organización funcional.

OrganizacionFuncional

Para reducir el tiempo y el tamaño de la carga inicial de la aplicación utilizaremos la técnica de lazy loading.

Lazy loading

De forma predeterminada, al iniciar la aplicación se cargan todos los módulos presentes en app.module.ts. Si colocamos ahí todos los módulos de la aplicación, en aplicaciones grandes con gran cantidad de módulos se ralentizará su carga y funcionamiento inicial. Esta situación se puede prevenir con lo que se conoce como lazy loading, que consiste en separar los distintos módulos de la aplicación y cargarlos conforme vayan siendo necesarios. El concepto necesario básicamente hace referencia a que el usuario acceda a las rutas de la aplicación que utilizan los componentes de un módulo. Esto tiene un impacto inmediato en la reducción de los tiempos de carga.

Implementaremos lazy loading definiendo un módulo exclusivo de routing app-routing.module.ts que será importado en app.module.ts. Sin embargo, app-routing.module.ts pospone la carga de cada módulo concreto a la activación de la ruta asociada a la funcionalidad que proporciona cada módulo.

Archivo app.module.ts:

...
import { AppRoutingModule } from './app-routing.module';
...
@NgModule({
  ...
  imports: [
    ...
    AppRoutingModule, (1)
    ...
  ],
  ...
})
export class AppModule {}
1 Importación del módulo global de routing

Archivo app-routing.module.ts:

const routes: Routes = [
  {
    path: 'items',
    loadChildren: () => import('./items/items.module').then(m => m.ItemsModule) (1)
  }
];
1 El módulo ItemsModule no es cargado hasta que no se acceda a la ruta items en la URL.

Crearemos los módulos con estas instrucciones. Incluiremos el parámetro --routing para que genere un archivo de rutas a nivel de módulo.

$ ng g module main/home --routing
$ ng g module main/solicitudes --routing
$ ng g module main/espacios --routing

Los archivos de rutas a nivel de módulo permiten organizar mejor las rutas de una aplicación. A un nivel general, app-routing.module.ts cargará las rutas de cada módulo, y cada módulo incluirá sus propias rutas locales relativas.

3.7. Creación de los componentes de la aplicación

Un módulo organiza un bloque funcional del dominio de la aplicación (p.e. solicitudes, espacios, …​). Los componentes Angular permitirán llevar a cabo la funcionalidad del módulo.

La organización que seguiremos para los componentes de la aplicación podría resumirse de esta forma.

Dentro de la carpeta de cada módulo encontraremos:

  • Un archivo de módulo

  • El archivo de routing del módulo para implementar lazy loading

  • Un directorio pages que contendrá a su vez un directorio para las funcionalidades

    • consultar

    • crear

  • Un directorio components que contendrá a su vez un directorio dialogo-eliminar que incluirá un componente de cuadro de diálogo para la funcionalidad de eliminar.

pages vs components

En la carpeta pages de la aplicación Angular incluiremos componentes Angular que van a ser directamente alcanzables por una ruta. Por ejemplo: <url-base>/solicitudes/crear

En la carpeta components se incluirán componentes que no estarán directamente asociados a una ruta de la aplicación, pero que serán usados por otros componentes (que pondran estar en pages o su vez también en components porque sean usados por otros componentes).

La figura siguiente ilustra cómo quedaría la carpeta de un módulo:

OrganizacionModulo
Organización básica de los archivos de la aplicación

A grandes rasgos la aplicación quedará organizada de esta forma:

  • app.module.ts

  • app-routing.ts

  • app-component.ts

  • material

    • material.module.ts

  • shared

    • shared.module.ts

    • sidebar

      • sidebar.component.html

      • sidebar.component.ts

    • footbar

      • footbar.component.html

      • footbar.component.ts

  • main

    • home

      • home-routing.module.ts

      • home.module.ts

      • pages

        • home.component.html

        • home.component.ts

    • espacios

      • espacios-routing.module.ts

      • espacios.module.ts

      • pages

        • consultar

    • solicitudes

      • solicitudes-routing.module.ts

      • solicitudes.module.ts

      • pages

        • consultar

        • crear

Crearemos los componentes con estas instrucciones

$ ng g c main/home/pages/home
$ ng g c main/espacios/pages/consultar
$ ng g c main/solicitudes/pages/consultar
$ ng g c main/solicitudes/pages/crear

3.8. Creación de las rutas

Ahora vamos a crear cada una de las rutas permitidas en la aplicación. Una vez creadas, habremos indicado el componente que mostrará la aplicación al ir a cada ruta. En esta sección configuraremos:

  • el archivo app-routing.module.ts para hacer lazy loading indicando la ruta raíz de cada bloque funcional de la aplicación (p.e. home, solicitudes, espacios) y la ubicación de la clase del módulo de rutas correspondiente.

  • cada uno de los archivos de rutas parciales de cada módulo.

Archivo de rutas desde app-routing.module.ts:

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

const routes: Routes = [
  {
    path: 'solicitudes', (1)
    loadChildren: () => (2)
      import('./main/solicitudes/solicitudes.module').then(
        (m) => m.SolicitudesModule
      ),
  },
  {
    path: 'espacios',
    loadChildren: () =>
      import('./main/espacios/espacios.module').then((m) => m.EspaciosModule),
  },
  {
    path: '', (3)
    loadChildren: () => (4)
      import('./main/home/home.module').then((m) => m.HomeModule),
  },
  {
    path: '**', (5)
    redirectTo: '',
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}
1 Configuración de la URL de la ruta de un módulo
2 Lazy loading del módulo indicando el archivo y la clase del módulo
3 Configuración de la ruta vacía
4 Módulo asociado a la ruta vacía (después del resto)
5 Expresión regular para indicar que redirija cualquier otro path no indicado al path que consideramos predeterminado (en nuestro caso, el vacío)

Los path son evaluados de arriba abajo. Hay que tener cuidado de no poner un path demasiado genérico arriba porque impediría la evaluación de otros path que estén configurados después. Por eso, se colocan al final los path '' y **.

A continuación, creremos los archivos de rutas de cada módulo de la aplicación. En ellos se indica por un lado la ruta parcial a añadir a la ruta de su módulo global; por otro lado, se indica el componente asociado a la ruta parcial y que se mostrará, por tanto, al activar cada ruta.

Archivo main/home/home.routing.ts:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './pages/home/home.component';

const routes: Routes = [
  {
    path: '',
    children: [{ path: '', component: HomeComponent }],
  },
  {
    path: '**',
    redirectTo: '',
  },
];

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

Archivo main/espacios/espacios.routing.ts:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { ConsultarComponent } from './pages/consultar/consultar.component';

const routes: Routes = [
  {
    path: '',
    children: [
      { path: 'consultar', component: ConsultarComponent },
      { path: '', redirectTo: 'consultar' },
    ],
  },
  {
    path: '**',
    redirectTo: 'consultar',
  },
];

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

Archivo main/solicitudes/solicitudes.routing.ts:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CrearComponent } from './pages/crear/crear.component';
import { ConsultarComponent } from './pages/consultar/consultar.component';

const routes: Routes = [
  {
    path: '',
    children: [
      { path: 'crear', component: CrearComponent },
      { path: 'consultar', component: ConsultarComponent },
      { path: '', redirectTo: 'consultar' },
    ],
  },
  {
    path: '**',
    redirectTo: 'crear',
  },
];

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

A continuación, comprobaremos que las rutas definidas funcionan correctamente. Escribiremos las siguientes URL en el navegador y deben ser respetadas aunque aún no muestren nada. Es decir, no deben redirigirnos a la ruta predeterminada, señal de que es una ruta incorrecta.

Correspondencia entre rutas y componentes

De acuerdo con la figura que mostramos de los bloques funcionales de la aplicación y sus operaciones asociadas, si observamos, las rutas anteriores se corresponderían con los cuadros azules. Estos representan a componentes que implementarán la funcionalidad en cuestión y que tendrán una ruta asociada. (A la operación de modificar solicitud no le hemos creado ruta aún. Ya veremos el motivo cuando tratemos más adelante la modificación de datos).

Los componentes amarillos representan a funcionalidad que se implementará mediante cuadros de diálogo y que por tanto no tendrán pantalla asociada y no necesitarán un ruta.

OrganizacionFuncional

Por contra, las siguientes rutas no serán reconocidas y seremos redirigidos a las rutas predeterminadas de cada módulo:

4. Primeros componentes de la aplicación

4.1. Barra lateral y la barra de pie

Comenzaremos con la configuración del módulo shared. Como tanto la barra lateral como la de pie usarán componentes de Angular Material, habrá que importar el módulo Material creado anteriormente.

Además, como la barra lateral hará uso de los routerLink para cargar en la zona de páginas de la aplicación los componentes seleccionados, también tendrá que importarse RouterModule.

Así queda shared/shared.module.ts:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SidebarComponent } from './sidebar/sidebar.component';
import { FootbarComponent } from './footbar/footbar.component';
import { MaterialModule } from '../material/material.module';
import { RouterModule } from '@angular/router';

@NgModule({
  declarations: [SidebarComponent, FootbarComponent],
  imports: [CommonModule, MaterialModule, RouterModule], (1)
  exports: [SidebarComponent, FootbarComponent],
})
export class SharedModule {}
1 Importación de los módulos de Material y de routing

No olvidar añadir RouterModule a los imports del módulo de la barra de navegación. De no hacerlo, las selecciones en el menú de la barra lateral no abrirían ningún componente.

A continuación se muestra el código de la barra lateral con el menú de operaciones de la aplicación. Se trata de un botón Home, un desplegable con acciones de solicitudes y un desplegable con acciones para espacios. Posteriormente agregaremos el botón de Créditos.

Archivo shared/sidebar/sidebar.component.html:

<div fxLayout="column">
  <button mat-button routerLink="/">Home</button>
  <hr />

  <mat-accordion>
    <mat-expansion-panel>
      <mat-expansion-panel-header>
        <mat-panel-title> Solicitudes </mat-panel-title>
      </mat-expansion-panel-header>
      <div fxLayout="column">
        <div>
          <button mat-button routerLink="./solicitudes/crear">Crear</button>
        </div>
        <div>
          <button mat-button routerLink="./solicitudes/consultar">
            Consultar
          </button>
        </div>
      </div>
    </mat-expansion-panel>

    <mat-expansion-panel>
      <mat-expansion-panel-header>
        <mat-panel-title> Espacios </mat-panel-title>
      </mat-expansion-panel-header>
      <div fxLayout="column">
        <a mat-button routerLink="./espacios/consultar">Consultar</a>
      </div>
    </mat-expansion-panel>
  </mat-accordion>
</div>

La barra del pie estará formada por tres botones con los enlaces al Aviso legal y a las políticas de privacidad y accesibilidad.

Archivo shared/footbar/footbar.component.html

<div fxLayout="row">
  <a mat-button href="https://www.ual.es/avisolegal" target="_blank"
    >Aviso legal</a
  >

  <a mat-button href="https://www.ual.es/politicaprivacidad" target="_blank"
    >Política de Privacidad</a
  >

  <a mat-button href="https://www.ual.es/accesibilidad" target="_blank"
    >Política de Accesibilidad</a
  >
</div>

4.2. Página de inicio

La página de inicio dará la bienvenida usando componentes Material y permitirá acceder a la consulta de espacios.

Comenzaremos añadiendo el módulo de componentes Material de nuestra aplicación al módulo Home.

Archivo main/home/home.module.ts:

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

import { HomeRoutingModule } from './home-routing.module';
import { HomeComponent } from './pages/home/home.component';
import { MaterialModule } from '../../material/material.module';

@NgModule({
  declarations: [HomeComponent],
  imports: [CommonModule, HomeRoutingModule, MaterialModule], (1)
})
export class HomeModule {}
1 Inclusión de nuestro módulo de Material

Para la página de inicio busca tu propia imagen, colócala en assets/images/ y añade un código como este.

Archivo main/home/pages/home.component.html:

<div fxLayout="row" fxLayoutAlign="center center">
  <mat-card class="text-center" fxLayout="column" fxLayoutAlign="center center">
    <img src="assets/images/empty.png" />
    <h1>¡Hola! ¿Aún no has reservado ningún espacio?</h1>
    <p>
      Si deseas reservar un espacio, consulta la disponibilidad a través del
      siguiente enlace.
    </p>
    <button
      [routerLink]="['/espacios/consultar']"
      mat-stroked-button
      ngClass.xs="mat-fab"
      color="primary"
    >
      <span fxHide fxShow.gt-xs>Ver disponibilidad de espacios</span>
    </button>
  </mat-card>
</div>

Quedará algo así:

Home

4.3. Formularios reactivos

Los formularios reactivos ofrecen una solución limpia a la gestión de los datos que maneja un formulario (p.e. manipulación de su contenido y validación de datos). Por tanto, tendremos la lógica del formulario separada de su presentación.

Para usar formularios reactivos en una aplicación tendremos que seguir estos pasos:

  1. Importar el módulo ReactiveFormsModule en el módulo en el que esté el formulario.

  2. En la parte Typescript del formulario:

    1. Inyectar FormBuilder en el constructor.

    2. Crear un objeto FormGroup que representa al formulario. En él se definen todos los campos que tendrá el formulario. FormGroup permite la manipulación de los valores de los campos y permite obtener su validez.

    En realidad un objeto FormGroup está formado por una colección de objetos FormControl. Cada FormControl es el objeto TypeScript que representa a un elemento del formulario. Por tanto, el FormGroup está formado por todos los FormControl que representan a los elementos HTML del formulario.

  3. En la parte HTML del formulario:

    1. En la etiqueta <form> de creación del formulario asociarlo con el objeto FormGroup creado en la parte TypeScript. Esto se hace añadiéndole [formGroup]="<nombre-del-objeto-FormGroup>".

    2. Cada campo está conectado a su FormControl mediante un atributo formControlName

A continuación se muestra un ejemplo y la correspondencia entre ellos:

FormulariosReactivos

Como en el FormControl email se ha indicado que es requerido, en el formulario aparece la indicación de obligatorio con el asterisco (*).

TypeScript del componente:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-mi-form',
  templateUrl: './mi-form.component.html',
  styleUrls: ['./mi-form.component.css'],
})
export class MiFormComponent implements OnInit {
  constructor(private fb: FormBuilder) {} (1)

  ngOnInit(): void {}

  formPersona: FormGroup = this.fb.group({ (2)
    nombre: [{ value: 'John Smith', disabled: true }], (3)
    email: [, [Validators.required, Validators.email]], (4)
  });
}
1 Inyección de FormBuilder
2 Creación del FormGroup que representa al formulario
3 FormControl configurado a un valor y desactivado
4 FormControl que impone dos validadores de Angular (requerido y formato de email)

HTML del componente

<form [formGroup]="formPersona" fxLayout="column" fxFlexOffset="10"> (1)
  <mat-form-field>
    <mat-label>Nombre</mat-label
    ><input matInput type="text" formControlName="nombre" /> (2)
  </mat-form-field>
  <mat-form-field>
    <mat-label>Email</mat-label
    ><input matInput type="text" formControlName="email" /> (3)
  </mat-form-field>
</form>
1 Asociación del formulario al objeto FormGroup formPersona de la parte TypeScript
2 Asociación al FormControl name mediante formControlName
3 Asociación al FormControl email mediante formControlName

4.4. Formulario de creación de solicitudes

En este tutorial trabajaremos con formularios reactivos. Esto nos permitirá desviar la lógica asociada al formulario a la parte TypeScript del componente y dejar más limpia la parte HTML del componente. Cada objeto del formulario HTML tendrá su homólogo en la parte TypeScript, que permitirá acceder, modificar, y en general, controlar sus datos, quedando así el HTML y el TypeScript del formulario totalmente conectados. Esto supone:

  • Importar ReactiveFormsModule en el módulo de solicitudes para poder trabajar con formularios reactivos.

  • Crear un objeto formulario en la parte TypeScript del componente.

A continuación se muestra un mock del aspecto deseado del formulario de creación de solicitudes integrado en la aplicación.

MockCrearSolicitud

Comenzamos con las importaciones al módulo que contiene el componente en el que está nuestro componente de formulario. Como se trata de un formulario reactivo y en el usaremos componentes Material, tendremos que importar el módulo ReactiveFormsModule y nuestro módulo de uso de componentes Material.

Archivo main/solicitudes/solicitudes.module.ts:

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

import { SolicitudesRoutingModule } from './solicitudes-routing.module';
import { ConsultarComponent } from './pages/consultar/consultar.component';
import { CrearComponent } from './pages/crear/crear.component';
import { ReactiveFormsModule } from '@angular/forms';
import { MaterialModule } from '../../material/material.module';

@NgModule({
  declarations: [ConsultarComponent, CrearComponent],
  imports: [
    CommonModule,
    SolicitudesRoutingModule,
    ReactiveFormsModule, (1)
    MaterialModule, (2)
  ],
})
export class SolicitudesModule {}
1 Módulo de formularios reactivos
2 Módulo de los componentes Material de nuestra aplicación

Si no se importa ReactiveFormsModule tendremos un error del tipo

Uncaught (in promise): NullInjectorError: R3InjectorError(SolicitudesModule)[FormBuilder -> FormBuilder -> FormBuilder -> FormBuilder]:
  NullInjectorError: No provider for FormBuilder!
---

A continuación crearemos la parte TypeScript del componente de creación de solicitudes. Se trata de:

  • Definir el objeto formulario con los campos que habrá en la pantalla

  • Para cada campo se define si tiene valores predeterminados, si el campo está desactivado y sus validadores.

Definiremos los campos como pares JSON con los nombres del campo, los arrays indicando los valores predeterminados, validadores, si están desactivados, y demás.

Este componente tendrá que implementar un método save que será llamado por la parte HTML del componente cuando se quiera crear la solicitud. Por ahora será un método que simplemente imprimirá por consola los valores introducidos a modo de comprobación. Posteriormente, llamará a un servicio que crearemos más adelante y que se dedicará a almacenar la solicitud.

Archivo main/solicitudes/pages/solicitudes.component.ts:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-crear',
  templateUrl: './crear.component.html',
  styleUrls: ['./crear.component.css'],
})
export class CrearComponent implements OnInit {
  formHorario: FormGroup = this.fb.group({ (1)
    nombre: [{ value: '', disabled: true }], (2)
    cargo: [, [Validators.required]], (3)
    unidad: [{ value: '', disabled: true }],
    telefono: [{ value: '', disabled: true }],
    email: [, [Validators.required, Validators.email]], (4)
    tipo: [,],
    nombreActividad: [, [Validators.required, Validators.minLength(5)]], (5)
    start: [,],
    end: [,],
    dia: [,],
    horaInicio: [,],
    horaFin: [,],
  });

  cargos: string[] = [ (6)
    'Profesor Titular de Universidad',
    'Director de Secretariado de Innovación Tecnológica',
  ];


  diasSemana: string[] = [ (7)
    'lunes',
    'martes',
    'miercoles',
    'jueves',
    'viernes',
    'sabado',
    'domingo',
  ];

  horas = Array.from(Array(24).keys()); (8)

  constructor(private fb: FormBuilder)  {} (9)

  ngOnInit(): void { (10)
    this.formHorario.controls['nombre'].setValue('Manuel Torres Gil');
    this.formHorario.controls['unidad'].setValue('Informática');
    this.formHorario.controls['telefono'].setValue('84030');
  }

  save() { (11)
    console.log('this.formHorario :>> ', this.formHorario);
  }
1 Los campos son creados en JSON como valores del método group del objeto FormBuilder que representa al formulario.
2 Definición del nombre del campo y su configuración (valores predeterminados, validadores, si está desactivado, …​)
3 Uso del validador Required. Va después del elemento dedicado al valor inicial
4 Se puede usar una lista de validadores
5 Validador de longitud mínima
6 Array para inicializar una lista de cargos de ejemplo
7 Array para inicializar la lista de día de la semana en un listbox
8 Array para inicializar las horas en un listbox
9 Inyección de FormBuilder
10 Inicialización de valores
11 Método save inicializado con código de prueba
Validadores

Angular proporciona una serie de validadores útiles para la validación de campos. Destacan required, email, min(<valor>), max(<valor>), minLength(<valor>), maxLength(<valor>) y pattern(<expresión-regular>). El validador email permite comprobar si el valor introducido se ajusta a un email. Con el validador pattern se pueden definir expresiones regulares para la validación de datos de los controles del formulario.

El uso de validadores deja un código muy limpio comparado con hacerlo mediante métodos propios. Implementar validadores con métodos propios implicaría que además de tener que implementarlos en la parte TypeScript, habría que llamarlos desde la parte HTML. Además, la posibilidad de usar varios validadores mediante su inclusión en un array facilita mucho las validaciones compuestas.

Por último, el formulario (sus datos) no será considerado como válido mientras todos sus campos no hayan satisfecho todos sus validadores.

Más información en la documentación oficial.

A continuación crearemos la parte visual del componente. Como hemos comentado, se trata de un formulario reactivo ligado al objeto formHorario creado en la parte TypeScript. Tal y como se mostró en la figura del mock del formulario de creación de solicitudes, organizaremos sus elementos en tres tarjetas (Datos personales, Datos de la actividad, Horario de la reserva). Para la selección de fechas usaremos un componente Datepicker de Material. Las horas las seleccionaremos mediante listas desplegables. Se trata de la primera aproximación al formulario. Por ahora:

  • No usamos servicios de recuperación de los datos del usuario. Más adelante, al introducir el email se recuperarán el resto de datos personales mediante un servicio.

  • Por ahora gestionaremos las horas con listas desplegables con valores sólo para las horas, sin minutos.

Archivo main/solicitudes/pages/solicitudes.component.html:

<div fxFlexAlign="center" fxLayoutAlign="center center">
  <form [formGroup]="formHorario"> (1)
    <h1>Crear reserva</h1>
    <hr />
    <div fxLayout="column wrap" fxLayoutGap="20px">
      <mat-card> (2)
        <mat-card-subtitle>Datos personales</mat-card-subtitle>
        <div fxLayout="row" fxLayoutGap="20px">
          <div fxFlex>
            <mat-form-field appearance="outline" fxFill> (3)
              <mat-label>Email</mat-label> (4)
              <input
                matInput (5)
                formControlName="email" (6)
              />
            </mat-form-field>
          </div>
          <div fxFlex>
            <mat-form-field appearance="outline" fxFill>
              <mat-label>Nombre</mat-label>
              <input matInput formControlName="nombre" />
            </mat-form-field>
          </div>

          <div fxFlex>
            <mat-form-field appearance="outline" fxFill>
              <mat-label>Unidad/Departamento/Centro</mat-label>
              <input matInput formControlName="unidad" />
            </mat-form-field>
          </div>
        </div>
        <div fxLayout="row" fxLayoutGap="20px">
          <div fxFlex>
            <mat-form-field appearance="outline" fxFill>
              <mat-label>Teléfono</mat-label>
              <input matInput formControlName="telefono" />
            </mat-form-field>
          </div>
          <div fxFlex>
            <mat-form-field appearance="outline" fxFill>
              <mat-label>Cargo</mat-label>
              <div>
                <mat-select formControlName="cargo"> (7)
                  <mat-option
                    *ngFor="let cargo of cargos"
                    value="{{ cargo }}"
                    >{{ cargo }}</mat-option
                  >
                </mat-select>
              </div>
            </mat-form-field>
          </div>
          <div fxFlex></div>
        </div>
      </mat-card>

      <mat-card> (8)
        <mat-card-subtitle>Datos de la actividad</mat-card-subtitle>
        <div fxLayout="row" fxLayoutGap="20px">
          <div>
            <mat-form-field appearance="outline">
              <mat-label>Tipo</mat-label>
              <mat-select formControlName="tipo">
                <mat-option value="docente">Docente</mat-option>
                <mat-option value="noDocente">No docente</mat-option>
              </mat-select>
            </mat-form-field>
          </div>
          <div fxFlex>
            <mat-form-field appearance="outline" fxFill>
              <mat-label>Actividad</mat-label>
              <input matInput formControlName="nombreActividad" />
            </mat-form-field>
          </div>
        </div>
      </mat-card>

      <mat-card>
        <mat-card-subtitle>Horario de la reserva</mat-card-subtitle>
        <div fxLayout="row" fxLayoutGap="20px">
          <div fxFlex>
            <mat-form-field appearance="fill">
              <mat-label>Rango de fechas</mat-label>
              <mat-date-range-input [rangePicker]="picker"> (9)
                <input
                  matStartDate
                  formControlName="start"
                  placeholder="Start date"
                />
                <input
                  matEndDate
                  formControlName="end"
                  placeholder="End date"
                />
              </mat-date-range-input>
              <mat-datepicker-toggle
                matSuffix
                [for]="picker"
              ></mat-datepicker-toggle>
              <mat-date-range-picker #picker></mat-date-range-picker> (10)
            </mat-form-field>
          </div>
          <div fxFlex>
            <mat-form-field appearance="outline">
              <mat-label>Día</mat-label>
              <mat-select formControlName="dia"> (11)
                <mat-option *ngFor="let day of daysOfWeek" value="{{ day }}">{{
                  day | titlecase
                }}</mat-option>
              </mat-select>
            </mat-form-field>
          </div>
          <div fxFlex>
            <mat-form-field appearance="outline">
              <mat-label>Hora de inicio</mat-label>
              <mat-select formControlName="horaInicio"> (12)
                <mat-option *ngFor="let hour of hours" value="{{ hour }}">{{
                  hour
                }}</mat-option>
              </mat-select>
            </mat-form-field>
          </div>
          <div fxFlex>
            <mat-form-field appearance="outline">
              <mat-label>Hora de fin</mat-label>
              <mat-select formControlName="horaFin"> (13)
                <mat-option *ngFor="let hour of hours" value="{{ hour }}">{{
                  hour
                }}</mat-option>
              </mat-select>
            </mat-form-field>
          </div>
        </div>
        <div fxLayout="row" fxLayoutAlign="end">
          <button mat-stroked-button color="primary" (click)="save()"> (14)
            Guardar
          </button>
        </div>
      </mat-card>
    </div>
  </form>
</div>
1 Objeto formulario ligado al formulario reactivo formHorario definido en la parte TypeScript
2 Tarjeta para elementos de datos personales
3 Creación de campo de formulario Material
4 Etiqueta
5 Input de tipo Material
6 Vinculación del campo email del formulario a su homólogo en la parte TypeScript
7 Listbox inicializado con los valores de ejemplo definidos en la parte TypeScript
8 Tarjeta para los datos de la actividad
9 Elemento para los datos del rango de fechas
10 Elemento para la selección del rango de fechas
11 Desplegable para la selección de días
12 Desplegable para la selección de la hora de inicio
13 Desplegable para la selección de la hora de fin
14 Botón con llamada al método que gestionará el formulario

Si pulsamos el botón Crear y no se cumple alguno de los validadores, los campos no válidos aparecerán marcados en rojo. Y si activamos en el navegador las Herramientas para desarrolladores, como el método save hace un console.log del objeto formHorario, vemos que su estado es INVALID. Esto se debe a que no se está cumpliendo alguno de sus validadores.

CrearReservaInvalid

A continuación veremos cómo mostrar mensajes de error en las validaciones y cómo desactivar el botón del formulario hasta que éste sea válido.

4.4.1. Añadiendo mensajes de error en las validaciones

Cuando no se cumple un validador, el campo en cuestión debería mostrar alguna señal. Necesitamos por tanto métodos que nos devuelvan si los campos son válidos o no. Pero en lugar de tener varios métodos que indiquen si un campo tiene errores o no, vamos a construir un método genérico, un método al que le podamos pasar un campo como parámetro y nos indique si el campo tiene errores o no. En nuestro caso, los campos tendrán errores si no se cumplen algunos de los validadores. No obstante, para mejorar la experiencia de usuario, no queremos que se muestren mensajes de error al abrir el formulario, cuando un usuario aún no ha introducido datos, ya que aunque no se cumplirán los validadores porque los campos aún están vacíos, no conviene abrir un formulario indicando que ya se tienen errores. Para mostrar un error sobre un campo deberíamos esperar a que al menos haya sido tocado. Por tanto, en el método de comprobación de las validaciones introduciremos además la condición de que los campos hayan sido tocados para que inicialmente no se consideren erróneos los campos que aún no han sido tocados.

A continuación se muestra el método isNotValidField() que devuelve que un campo no es válido si ha sido tocado y contiene errores. Archivo main/solicitudes/pages/solicitudes.component.ts:

  ...
  isNotValidField(field: string) {
    return (
      this.formHorario.controls[field].errors &&
      this.formHorario.controls[field].touched
    );
  }
  ...

Para personalizar la presentación de los mensajes de error en los campos no válidos definimos una clase invalid-mat-form-field en styles.css

.invalid-mat-form-field {
  font-size: small;
  color: red;
}

Por último, si isNotValidField devuelve que el campo no es válido añadimos la presentación (condicional) del error en un elemento <span>

Archivo main/solicitudes/pages/solicitudes.component.html:

...
            <mat-form-field appearance="outline" fxFill>
              <mat-label>Email</mat-label>
              <input
                matInput
                formControlName="email"
              />
              <span class="invalid-mat-form-field" *ngIf="isNotValidField('email')"
                >* Formato de email incorrecto</span
              > (1)
            </mat-form-field>
...
            <mat-form-field appearance="outline" fxFill>
              <mat-label>Actividad</mat-label>
              <input matInput formControlName="nombreActividad" />
              <span
                class="invalid-mat-form-field"
                *ngIf="isNotValidField('nombreActividad')"
                >Al menos 5 caracteres</span
              > (2)
            </mat-form-field>
...
1 Presentación de mensaje de error si el email no es válido
2 Presentación de mensaje de error si la actividad no es válida

A continuación se muestra el efecto de la presentación del mensaje de error cuando los campos no son válidos.

IsNotValidField

4.4.2. Desactivación condicional del botón Crear

Otra funcionalidad interesante es hacer que el botón Crear no esté habilitado si el formulario no es válido. Dado que los formularios disponen de la propiedad valid que indica si el formulario es válido, podemos aprovechar el valor de esta propiedad para controlar la activación del botón Crear. Para ello, comenzaremos añadiendo un método al TypeScript del componente que indique si el formulario es válido o no basándonse en la propiedad valid de los formularios.

El formulario es valid si se cumplen todos los validadores de sus campos.

Archivo main/solicitudes/pages/crear/crear.component.ts:

...
  isValidForm() {
    return this.formHorario.valid;
  }
...

Ahora sólo falta configurar la propiedad disabled del formulario en función de lo que devuelva el método isValidForm.

Archivo main/solicitudes/pages/crear/crear.component.html:

...
          <button
            mat-stroked-button
            color="primary"
            (click)="save()"
            [disabled]="!isValidForm()" (1)
          >
            Crear
          </button>
...
1 Desactivación del botón Crear si el formulario no es válido

Si ahora alguno de los campos no cumple sus validaciones el formulario no será válido y el botón Crear estará desactivado.

BotonCrearDisabled

5. Creación de un servicio de datos de persona

En esta sección crearemos un servicio que recupere datos de persona. Para ello, usaremos una API REST de prueba que contiene datos de personas, espacios y permite el almacenamiento de solicitudes. En el Anexo I. Creación de un servidor JSON para datos de prueba se describe cómo se crea esa API.

Comenzaremos creando un servicio para la recuperación de los datos de personas.

$ ng g service services/persona

Los servicios de nuestra aplicación los organizaremos en una carpeta services

Inclusión de los servicios en la organización básica de los archivos de la aplicación

A grandes rasgos la aplicación quedará ahora organizada de esta forma:

  • app.module.ts

  • app-routing.ts

  • app-component.ts


  • services // Carpeta para la organización de servicios

    • persona.service.ts // Servicio para personas


  • material

    • material.module.ts

  • shared

    • shared.module.ts

    • sidebar

      • sidebar.component.html

      • sidebar.component.ts

    • footbar

      • footbar.component.html

      • footbar.component.ts

  • main

    • home

      • home-routing.module.ts

      • home.module.ts

      • pages

        • home.component.html

        • home.component.ts

    • espacios

      • espacios-routing.module.ts

      • espacios.module.ts

      • pages

        • crear

        • consultar

Importación de HttpClientModule

Los servicios Angular usan la clase HttpClient. Para usar esta clase es necesario que previamente se haya importado HttpClientModule. La mayoría de las aplicaciones realizan esta importación en app.module.ts.

Archivo app.module.ts

...
  imports: [
    BrowserModule,
    HttpClientModule, (1)
    ...
  ],
1 Incorporación a la lista de imports de la aplicación

No importar este módulo provocaría el error siguiente a la hora de usar el servicio indicando que no existe provider para HttpClient:

errorHttpClientModule

HttpClientModule en app.module.ts

import { NgModule } from '@angular/core';
import { FlexLayoutModule } from '@angular/flex-layout';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http'; (1)

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { SharedModule } from './shared/shared.module';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    HttpClientModule, (2)
    AppRoutingModule,
    BrowserAnimationsModule,
    FlexLayoutModule,
    SharedModule,
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
1 Importación del módulo de HttpClientModule
2 Incorporación a la lista de imports

Para implementar el servicio:

  • Inyectaremos HttpClient en el constructor para poder realizar peticiones HTTP.

  • Inicializaremos la URL de acceso a la API.

  • Implementaremos un método que permita la recuperación de una persona por su email.

Servicio en services/people.ts:

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

@Injectable({
  providedIn: 'root',
})
export class PeopleService {
  url = 'http://localhost:3000/personas'; (1)
  constructor(private http: HttpClient) {} (2)

  findOne(email: any): Observable<any> { (3)
    return this.http.get(`${this.url}?email=${email}`);
  }
}
1 Configuracion de la ruta base
2 Inyección de HttpClient para realizar operaciones HTTP contra la API REST
3 Método que devuelve un observable con los datos de una persona a partir de su email.

En la documentación de JSON Server se indica cómo filtrar y ordenar resultados.

5.1. Uso de variables de entorno

En el ejemplo anterior, teníamos la URL de la API REST en el propio código de la aplicación (lo que se conoce como hardcoded). Esto presenta problemas de mantenimiento porque si cambiase la URL tendríamos que hacer cambios en todos los archivos en los que se esté usando. Pero otro detalle muy importante, es que probablemente tendremos que cambiar el valor en función de si estamos en el entorno de desarrollo o en el entorno de producción.

Angular permite la definición de archivos de variables de entorno y permite tener archivos separados para los entornos de desarrollo y producción. Los procesos de despliegue en los entornos de CI/CD tomarán los valores del archivo del entorno de producción, mientras que cuando estemos desarrollando, ng serve toma los valores del entorno de desarrollo al ejecutar la aplicación.

Estos son los archivos de variables de entorno que manejaremos en nuestra aplicación Angular:

  • environments/environments.ts: Variables de entorno para desarrollo.

  • environments/environments.prod.ts: Variables de entorno para producción.

A continuación se muestra el archivo de variables de entorno para desarrollo.

Archivo environments/environments.ts:

export const environment = {
  production: false,
  urlPersonas: 'http://localhost:3000/personas', (1)
};

Una vez definido, podremos usar sus variables en el resto de la aplicación. Veamos cómo quedaría el servicio usando variables de entorno.

Servicio en services/people.ts:

import { environment } from './../../environments/environment'; (1)
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

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

  findOne(email: any): Observable<any> {
    return this.http.get(`${environment.urlPersonas}?email=${email}`); (2)
  }
}
1 Importación de variables de entorno de desarrollo
2 Uso de las variables de entorno

Hay que tener cuidado a la hora de importar las variables de entorno y no importar el de producción (environment.prod).

También habría que configurar las variables de entorno de producción. A continuación se muestra un ejemplo para producción en el que no se ha puesto un servidor específico para la API REST.

Archivo environments/environments.prod.ts:

export const environment = {
  production: true,
  urlPersonas: 'http://<your-production-people-api-server>/personas', (1)
};
1 Configuración para producción

6. Mejora de la carga de datos en el formulario de creación de solicitudes

Hasta ahora, al inicializar el formulario de creación de solicitudes, los datos de la persona eran incluidos sin capacidad de ser cambiados mediante una inicialización de valores en el método ngOnInit.

  ngOnInit(): void {
    this.formHorario.controls['nombre'].setValue('Manuel Torres Gil');
    this.formHorario.controls['unidad'].setValue('Informática');
    this.formHorario.controls['telefono'].setValue('84030');
  }

Sin embargo, el funcionamiento esperado es que estos datos fuesen cargados a partir del email introducido en el formulario. Actualmente contamos con el método findOne() en el servicio PersonaService que permite recuperar los datos de una persona a partir de su email. Sin embargo, esto aún no está siendo explotado por la aplicación. Veamos cómo hacerlo.

En primer lugar, dejaremos el método ngOnInit() vacío. Ahora la inicialización se delegará en un método dedicado a ello. Dicho método será llamado cada vez que se introduzca un email en el formulario.

Realmente necesitaremos dos métodos:

  • Un método buscarPersona() que llamará al servicio de búsqueda de personas por email.

  • Un método actualizarCamposPersona() que será el que actualice el formulario con los datos recuperados por el método anterior. El método actualizarCamposPersona() será llamado cuando se introduzca un email en el formulario.

Hacemos una prueba llamando directamente a la API REST con Postman o con un navegador recuperando la persona a partir de su email para ver la estructura de datos de la respuesta. Al hacer la petición siguiente:

http://localhost:3000/personas?email=mtorres@ual.es

obtenemos la respuesta siguiente:

[
  {
    "email": "mtorres@ual.es",
    "nombre": "Manuel Torres Gil",
    "telefono": "84030",
    "unidad": "Departamento de Informática",
    "cargo": [
      "Profesor Titular de Universidad",
      "Director de Secretariado de Innovación Tecnológica"
    ],
    "docente": true
  }
]

Importante: Vemos que la persona es un objeto que pertenece a un array.

Archivo main/solicitudes/pages/crear/crear.component.ts:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { lastValueFrom, Observable, of, shareReplay } from 'rxjs';
import { PersonaService } from '../../../../services/persona.service';

@Component({
  selector: 'app-crear',
  templateUrl: './crear.component.html',
  styleUrls: ['./crear.component.css'],
})
export class CrearComponent implements OnInit {
  cargos: string[] = []; (1)
  persona: any; (2)

  formHorario: FormGroup = this.fb.group({
    nombre: [{ value: '', disabled: true }],
    cargo: [, [Validators.required]],
    unidad: [{ value: '', disabled: true }],
    telefono: [{ value: '', disabled: true }],
    email: [, [Validators.required, Validators.email]],
    tipo: [,],
    nombreActividad: [, [Validators.required, Validators.minLength(5)]],
    start: [,],
    end: [,],
    dia: [,],
    horaInicio: [,],
    horaFin: [,],
  });

  diasSemana: string[] = [
    'lunes',
    'martes',
    'miercoles',
    'jueves',
    'viernes',
    'sabado',
    'domingo',
  ];

  horas = Array.from(Array(24).keys());

  constructor(
    private fb: FormBuilder,
    private personaService: PersonaService (3)
  ) {}

  ngOnInit(): void {} (4)

  isNotValidField(field: string) {
    return (
      this.formHorario.controls[field].errors &&
      this.formHorario.controls[field].touched
    );
  }

  isValidForm() {
    return this.formHorario.valid;
  }

  buscarPersona(email: any) { (5)
    this.personaService.findOne(email).subscribe((res) => { (6)
      this.persona = res[0]; (7)
    });
  }

  actualizarCamposPersona() { (8)
    const email = this.formHorario.controls['email'].value; (9)

    this.buscarPersona(email); (10)

    if (this.persona) { (11)
      this.cargos = [...this.persona.cargo];

      this.formHorario.controls['nombre'].setValue(this.persona.nombre);
      this.formHorario.controls['unidad'].setValue(this.persona.unidad);
      this.formHorario.controls['telefono'].setValue(this.persona.telefono);

      this.persona.cargo = this.formHorario.controls['cargo'].value;

      return;
    }
  }

  save() {
    console.log('this.formHorario :>> ', this.formHorario);
  }
}
1 Variable para almacenar los cargos recuperados de una persona. Se usa para poblar el desplegable en el formulario
2 Variable para almacenar la persona recuperada del servicio
3 Inyección del servicio en el constructor
4 Ahora ya no se inicializan los datos de la persona desde ngOnInit
5 Método para la búsqueda de una persona mediante su email
6 Llamada al método del servicio que recupera los datos de una persona
7 Almacenamiento de los datos recuperados en la variable de instancia persona. Vimos que la persona recuperada está en la primera posición del array
8 Método de actualización de datos en el formulario
9 Acceso al valor del email introducido en el formulario
10 Llamada al método de búsqueda de personas por email
11 Actualización de datos en el formulario si se recupera una persona

Ahora ya sólo falta llamar al método actulizarCamposPersona() desde el cuadro de texto del email de la parte HTML del componente.

Archivo main/solicitudes/pages/crear/crear.component.html:

<div fxFlexAlign="center" fxLayoutAlign="center center">
  <form [formGroup]="formHorario" autocomplete="off">
    <h1>Crear reserva</h1>
    <hr />
    <div fxLayout="column wrap" fxLayoutGap="20px">
      <mat-card>
        <mat-card-subtitle>Datos personales</mat-card-subtitle>
        <div fxLayout="row" fxLayoutGap="20px">
          <div fxFlex>
            <mat-form-field appearance="outline" fxFill>
              <mat-label>Email</mat-label>
              <input
                matInput
                formControlName="email"
                (blur)="actualizarCamposPersona()" (1)
              />
              <span
                class="invalid-mat-form-field"
                *ngIf="isNotValidField('email')"
                >* Formato de email incorrecto</span
              >
            </mat-form-field>
          </div>

...
1 Llamada al método actualizarCamposPersona() tras perder el foco (evento blur)
DatosPersonaDesdeServicio

Tras introducir un email registrado en el backend, se cargarán los datos de la persona. No obstante, vemos un comportamiento anómalo. Los datos no aparecen actualizados al retirar el foco de email por primera vez. Parece que hubiera que cambiar dos veces el foco, entrando y saliendo dos veces del email. Este comportamiento anómalo se debe a que los datos de la persona están llegando tarde y no están aún al perder el foco la primera vez, pero sí parece que ya están disponibles si se vuelve a cambiar el foco por segunda vez. Es decir, los datos están llegando entre los dos cambios de foco. A continuación veremos cómo solucionar este problema.

6.1. Actualizaciones con datos asíncronos.

Para evitar el problema de que los datos que devuelve el servicio lleguen con retraso y no estén a tiempo para presentarlos en la pantalla esperaremos a que lleguen los datos antes de proceder a su presentación en pantalla. El problema radica en que el método buscarPersona() actualizaba tarde los datos de la persona. El código siguiente ilustra los cambios que hacemos en el código

  buscarPersona(email: any) {
    /*
    this.personaService.findOne(email).subscribe((res) => { (1)
      this.persona = res[0];
    });
    */

    return lastValueFrom(this.personaService.findOne(email));(2)
  }
1 Antigua llamada al servicio de búsqueda de persona por email
2 Ahora buscarPersona devuelve una promesa de un observable, que se consumirá con async/await.
La función lastValueFrom

lastValueFrom es una función de RxJS, la librería que nos permite tratar las llamadas asíncronas mediante observables.

lastValueFrom convierte un observable en una promesa mediante una suscripción al observable, esperando a que se complete y devolviendo el último valor del servicio llamado.

Posteriormente consumiremos el valor devuelto por lastValueFrom con async/await.

También habrá que cambiar la llamada a buscarPersona() desde actualizarDatosPersona(). Quedará así:

  async actualizarCamposPersona() { (1)
    const email = this.formHorario.controls['email'].value;

    // this.buscarPersona(email); (2)

    this.persona = (await this.buscarPersona(email))[0]; (3)

    if (this.persona) {
      this.cargos = [...this.persona.cargo];

      this.formHorario.controls['nombre'].setValue(this.persona.nombre);
      this.formHorario.controls['unidad'].setValue(this.persona.unidad);
      this.formHorario.controls['telefono'].setValue(this.persona.telefono);

      this.persona.cargo = this.formHorario.controls['cargo'].value;

      return;
    }
  }
1 Ahora el método es async porque dentro contiene un await
2 Antigua forma de llamada a buscarPersona()
3 Carga de datos en persona. Recordemos que la API devolvía la persona en un array y había que recuperar el primero.

Ahora, la carga de datos en persona no se realiza hasta que no se hayan recuperado sus datos del servicio y se habrá corregido aquel comportamiento anómalo.

6.2. Mejora del código de actualización del formulario con patchValue

En el código anterior teníamos un código engorroso que puede ser mejorado. Se trata de:

      this.formHorario.controls['nombre'].setValue(this.persona.nombre);
      this.formHorario.controls['unidad'].setValue(this.persona.unidad);
      this.formHorario.controls['telefono'].setValue(this.persona.telefono);

Esto podría ser aún peor si en lugar de tener que actualizar 3 campos tuviésemos que actuliazar 10.

Para ello, cuando los nombres de los controles del formulario coincidan con los nombres usados en los objetos que contienen los datos (nombre - nombre, unidad - unidad, telefono - telefono) podemos usar patchValue que actualizará todos los valores que tengan el mismo nombre.

Así, el código anterior quedaría de la siguiente forma, mucho más limpio.

  async actualizarCamposPersona() {
    const email = this.formHorario.controls['email'].value;

    this.buscarPersona(email);

    //this.persona = (await this.buscarPersona(email))[0];

    if (this.persona) {
      this.cargos = [...this.persona.cargo];

      this.formHorario.patchValue(this.persona); (1)

      this.persona.cargo = this.formHorario.controls['cargo'].value;

      return;
    }

    this.clearPersonalData();
  }
1 patchValue hace la actualización de todos los datos en una sola línea

6.3. Limpieza de datos cuando la persona no existe

Si probamos a introducir una persona que no existe, comprobaremos que no se actualizan los datos, lo que podría inducir a error. Si el formulario estaba vacío y se introduce un email inexistente, no se mostrarán datos, por lo que este fallo pasaría desapercibido. Pero si ya había datos y se introduce un nuevo email inexistente en la API REST, se mantendrán los datos de la persona anterior, lo que no es correcto.

La solución planteada consiste en crear un método que limpie el formulario si no se recuperan datos (persona no contiene datos). Para mejorar la experiencia de usuario usaremos el componente Snackbar de Material, que muestra una barra al pie útil para mensajes.

El módulo SnackbarModule que contiene al componente MatSnackbar es uno de los módulos que tenemos incluidos en nuestro módulo Material. Como está importado en el módulo del componente de solicitudes, permite usar todos los compomentes de nuestro módulo Material.

A continuación se muestra el código completo de cómo quedaría el componente con el nuevo método de limpieza de datos cuando se introducen emails no existentes.

Archivo main/solicitudes/pages/crear/crear.component.ts:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { lastValueFrom } from 'rxjs';
import { PersonaService } from '../../../../services/persona.service';
import { MatSnackBar } from '@angular/material/snack-bar'; (1)

@Component({
  selector: 'app-crear',
  templateUrl: './crear.component.html',
  styleUrls: ['./crear.component.css'],
})
export class CrearComponent implements OnInit {
  cargos: string[] = [];

  persona: any;

  formHorario: FormGroup = this.fb.group({
    nombre: [{ value: '', disabled: true }],
    cargo: [, [Validators.required]],
    unidad: [{ value: '', disabled: true }],
    telefono: [{ value: '', disabled: true }],
    email: [, [Validators.required, Validators.email]],
    tipo: [,],
    nombreActividad: [, [Validators.required, Validators.minLength(5)]],
    start: [,],
    end: [,],
    dia: [,],
    horaInicio: [,],
    horaFin: [,],
  });

  diasSemana: string[] = [
    'lunes',
    'martes',
    'miercoles',
    'jueves',
    'viernes',
    'sabado',
    'domingo',
  ];

  horas = Array.from(Array(24).keys());

  constructor(
    private fb: FormBuilder,
    private personaService: PersonaService,
    private snackBar: MatSnackBar (2)
  ) {}

  ngOnInit(): void {}

  isNotValidField(field: string) {
    return (
      this.formHorario.controls[field].errors &&
      this.formHorario.controls[field].touched
    );
  }

  isValidForm() {
    return this.formHorario.valid;
  }

  buscarPersona(email: any) {
    return lastValueFrom(this.personaService.findOne(email));
  }

  async actualizarCamposPersona() {
    const email = this.formHorario.controls['email'].value;

    this.persona = (await this.buscarPersona(email))[0];

    if (this.persona) {
      this.cargos = [...this.persona.cargo];

      this.formHorario.patchValue(this.persona);

      this.persona.cargo = this.formHorario.controls['cargo'].value;

      return;
    }

    this.clearPersonalData(); (3)
  }

  clearPersonalData() { (4)
    this.formHorario.reset(); (5)

    this.snackBar.open('Persona no disponible', '', { (6)
      duration: 1500,
    });
  }

  save() {
    console.log('this.formHorario :>> ', this.formHorario);
  }
}
1 Importación del componente MatSnackBar
2 Inyección del componente MatSnakcBar para poder usarlo en el compomente.
3 Llamada al método de limpieza si persona no tiene datos
4 Método de limpieza del formulario
5 Limpieza de los datos del formulario
6 Presentación del mensaje de error en la snackbar durante 1500 ms (1.5 segudos)

A continuación se muestra el efecto de borrado de los datos del formulario y la presentación del mensaje de error en la barra tras introducir un email que no existe.

PersonaNoDisponible

7. Guardado de datos en backend

En esta sección veremos cómo guardar los datos en el backend. Básicamente tendremos que

  • Crear el servicio que se encargará del almacenamiento en el backend.

  • Actualizar el método save() para que llame al servicio de almacenamiento anterior.

7.1. Creación del servicio de almacenamiento

Comenzamos creando un nuevo servicio para las solicitudes

$ ng g service services/solicitudes

7.2. Modificación de las variables de entorno

Hasta ahora tenemos una URL desde donde recuperamos los datos de las personas. Este servicio realmente podría ser ajeno al de la aplicación de espacios de este tutorial. Lo normal es que nuestra aplicación de espacios cuente con servicios para gestión de solicitudes y consulta de espacios. Todos ellos los vamos a incluir en la misma URL y posiblemente será diferente de la URL de la API de personas que, como hemos comentado, es algo externo a esta aplicación de espacios. Por tanto, tendremos variables de entorno diferentes.

Archivo environments/environments.ts:

export const environment = {
  production: false,
  urlPersonas: 'http://localhost:3000/personas',
  urlEspacios: 'http://localhost:3000',
};

La API de espacios tendrá endpoints como los siguientes. Todos ellos, tienen como elemento común urlEspacios.

Archivo environments/environments.prod.ts:

export const environment = {
  production: true,
  urlPersonas: 'http://<your-production-people-api-server>/personas',
  urlEspacios: 'http://<your-production-espacios-api-server>',
};

El método de almacenamiento en el servicio en el archivo services/reservations.service.ts sería algo así:

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

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

  save(data: any): Observable<any> { (1)
    return this.http.post(`${environment.urlEspacios}/reservations`, data);
  }
}
1 Método que almacena los datos y devuelve un observable

A continuación modificaremos el método save() del componente de crear solicitudes para que llame al servicio anterior. Además, para ofrecer una mejor experiencia de usuario, mostraremos un mensaje en la snackbar indicando que se ha creado la solicitud y redirigiremos al usuario a la pantalla del listado de solicitudes. Allí podrá ver su solicitud creada, aunque aún no podrá ver nada ya que no está implementada. En la sección siguiente implementaremos la funcionalidad de mostrar el listado de solicitudes.

Archivo main/solicitudes/pages/crear/crear.component.ts:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { lastValueFrom } from 'rxjs';
import { PersonaService } from '../../../../services/persona.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { SolicitudesService } from '../../../../services/solicitudes.service';
import { Router } from '@angular/router';

@Component({
  selector: 'app-crear',
  templateUrl: './crear.component.html',
  styleUrls: ['./crear.component.css'],
})
export class CrearComponent implements OnInit {
  cargos: string[] = [];

  persona: any;

  formHorario: FormGroup = this.fb.group({
    nombre: [{ value: '', disabled: true }],
    cargo: [, [Validators.required]],
    unidad: [{ value: '', disabled: true }],
    telefono: [{ value: '', disabled: true }],
    email: [, [Validators.required, Validators.email]],
    tipo: [,],
    nombreActividad: [, [Validators.required, Validators.minLength(5)]],
    start: [,],
    end: [,],
    dia: [,],
    horaInicio: [,],
    horaFin: [,],
  });

  diasSemana: string[] = [
    'lunes',
    'martes',
    'miercoles',
    'jueves',
    'viernes',
    'sabado',
    'domingo',
  ];

  horas = Array.from(Array(24).keys());

  constructor(
    private fb: FormBuilder,
    private personaService: PersonaService,
    private solicitudesService: SolicitudesService, (1)
    private snackBar: MatSnackBar,
    private router: Router (2)
  ) {}

  ngOnInit(): void {}

  isNotValidField(field: string) {
    return (
      this.formHorario.controls[field].errors &&
      this.formHorario.controls[field].touched
    );
  }

  isValidForm() {
    return this.formHorario.valid;
  }

  buscarPersona(email: any) {
    return lastValueFrom(this.personaService.findOne(email));
  }

  async actualizarCamposPersona() {
    const email = this.formHorario.controls['email'].value;

    this.persona = (await this.buscarPersona(email))[0];

    if (this.persona) {
      this.cargos = [...this.persona.cargo];

      this.formHorario.patchValue(this.persona);

      this.persona.cargo = this.formHorario.controls['cargo'].value;

      return;
    }

    this.clearPersonalData();
  }

  clearPersonalData() {
    this.formHorario.reset();

    this.snackBar.open('Persona no disponible', '', {
      duration: 1500,
    });
  }

  save() {
    let solicitud = this.formHorario.getRawValue(); (3)

    this.solicitudesService.save(solicitud).subscribe((res) => { (4)
      if (res) { (5)
        this.snackBar.open('Solicitud creada', '', {  (6)
          duration: 1500,
        });

        this.router.navigate(['/solicitudes/consultar']); (7)
      }
    });
  }
}
1 Inyección del servicio de gestión de solicitudes
2 Inyección de Router para poder ir a la página del listado de solicitudes tras la creación de una solicitud
3 Inicializar un objeto solicitud con todos los valores introducidos en el formulario
4 Llamada al método save del servicio pasándole los datos de la solicitud a crear
5 Comprobación de almacenamiento correcto
6 Presentación de la snackbar con el mensaje de solicitud creada
7 Redirigir a la página de listado de solicitudes

8. Lista de solicitudes

En esta sección crearemos la página que muestra las solicitudes creadas. Inicialmente las mostrará todas. Después se podría añadir la posibilidad de filtrado para la consulta de solicitudes realizadas.

8.1. Servicio de lista de solicitudes

Comenzaremos añadiendo al servicio Solicitudes un método que recupere todas las solicitudes.

import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';

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

  findAll(): Observable<any> { (1)
    return this.http.get(`${environment.urlEspacios}/solicitudes`); (2)
  }

  save(data: any): Observable<any> {
    return this.http.post(`${environment.urlEspacios}/solicitudes`, data);
  }
}
1 Método para recuperar las solicitudes. Devuelve un observable
2 Llamada al endpoint que recupera las solicitudes

8.2. Carga de datos en el componente de listado de solicitudes

Para la presentación de datos usaremos el componente Table de Angular Material, que ya tenemos incluido en nuestro módulo Material. Este componente tiene una parte TypeScript y una parte HTML. En la parte TypeScript básicamente debemos inicializar el conjunto de datos a mostrar y la lista de columnas a mostrar. Como peculiaridad, indicar que hay que cargar de forma independiente cada columna de datos. Ea decir, la tabla se carga por columnas, no por filas. Veamos cómo hacerlo:

Archivo main/solicitudes/pages/consultar/consultar.component.ts:

import { Component, OnInit } from '@angular/core';
import { SolicitudesService } from '../../../../services/solicitudes.service';

@Component({
  selector: 'app-consultar',
  templateUrl: './consultar.component.html',
  styleUrls: ['./consultar.component.css'],
})
export class ConsultarComponent implements OnInit {
  dataSource: any = [] (1)
  displayedColumns= ['nombre', 'cargo', 'unidad', 'telefono']; (2)

  constructor(
    private solicitudesService: SolicitudesService, (3)
    private snackBar: MatSnackBar (4)
  ) {}
  ngOnInit(): void { (5)
    this.solicitudesService.findAll().subscribe((res) => { (6)
      this.dataSource = res; (7)
      if (this.dataSource.length == 0) { (8)
        this.snackBar.open('No hay solicitudes', '', {
          duration: 1500,
        });
      }
    });
  }
}
1 Variable para almacenar los datos recuperados por el servicio
2 Variable para indicar las columnas a mostrar
3 Inyección del servicio de solicitudes
4 Inyección de la snackbar para presentar mensajes al pie
5 Inicializar la tabla al iniciar el componente
6 Suscripción al método que recupera las solicitudes
7 Almacenar los datos recuperados del servicio
8 Mostrar mensaje de error si no hay datos

A continuación vamos con la parte de la presentación de los datos (el código está copiado tal cual de la documentación cambiando los nombres de campo)

<div fxLayout="column" fxLayoutAlign="center center">
  <h1>Listado de solicitudes</h1>
  <div *ngIf="dataSource.length > 0"> (1)
    <hr />
    <mat-card>
      <table mat-table [dataSource]="dataSource" class="mat-elevation-z8"> (2)
        <ng-container matColumnDef="nombre"> (3)
          <th mat-header-cell *matHeaderCellDef>Nombre</th> (4)
          <td mat-cell *matCellDef="let element">{{ element.nombre }}</td> (5)
        </ng-container>

        <ng-container matColumnDef="cargo">
          <th mat-header-cell *matHeaderCellDef>Cargo</th>
          <td mat-cell *matCellDef="let element">{{ element.cargo }}</td>
        </ng-container>

        <ng-container matColumnDef="unidad">
          <th mat-header-cell *matHeaderCellDef>Unidad</th>
          <td mat-cell *matCellDef="let element">{{ element.unidad }}</td>
        </ng-container>

        <ng-container matColumnDef="telefono">
          <th mat-header-cell *matHeaderCellDef>Teléfono</th>
          <td mat-cell *matCellDef="let element">{{ element.telefono }}</td>
        </ng-container>

        <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr> (6)
        <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr> (7)
      </table>
    </mat-card>
  </div>
</div>
1 Presentación de la tabla si contiene datos
2 Inicialización de la tabla con su fuente de datos definida en la parte TypeScript
3 Definición de la columna del nombre
4 Etiqueta que se quiere presentar en esta columna
5 Indicar el campo del que se recuperarán los datos
6 Crear la fila de cabecera
7 Creación del cuerpo de la tabla

A continuación se muestra el listado de resultados.

ListarSolicitudes

9. Lista de espacios

De forma análoga a como acabamos de hacer con el listado de solicitudes vamos a implementar la parte del listado de espacios, el cual guarda bastante parecido con el anterior. Comenzaremos creando un listado total y posteriormente le añadiremos capacidades de filtrado.

La forma de proceder será la de siempre:

  • Construcción del servicio que interactúa con el backend.

  • Programación de la parte TypeScript del componente para que interactúe con el servicio anterior.

  • Creación de la parte HTML de presentación del componente conectada a su parte TypeScript creada en el paso anterior.

9.1. Generación del servicio

En esta sección crearemos un servicio que recupere datos de espacios. Comenzaremos creando un servicio.

$ ng g service services/espacios

A continuación le añadiremos un método que recupere todos los espacios programando un método para ello en el servicio services/espacios.ts:

import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';

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

  findAll(): Observable<any> { (2)
    return this.http.get(`${environment.urlEspacios}/espacios`); (3)
  }
}
1 Inyección del cliente HTTP
2 Método que devuelve los espacios como un observable
3 Llamada al endpoint usando la variable de entorno

La variable de entorno se reutiliza. Ya la usamos en el servicio de solicitudes. Sólo necesitamos la URL del servidor. El resto de la ruta se añade en cada módulo funcional de la API (p.e. espacios, solicitudes).

Este servicio atacará a la API REST de prueba que estamos usando en este tutorial y que está disponible en Anexo I. Creación de un servidor JSON para datos de prueba.

9.2. Programación del componente

Una vez creado el servicio, ya podemos programar el componente TypeScript que lo usará para recuperar datos de espacios y entregarlos a la parte HTML del componente. Lo haremos en el archivo main/espacios/pages/consultar/consultar.component.ts:

import { Component, OnInit } from '@angular/core';
import { EspaciosService } from '../../../../services/espacios.service';

@Component({
  selector: 'app-consultar',
  templateUrl: './consultar.component.html',
  styleUrls: ['./consultar.component.css'],
})
export class ConsultarComponent implements OnInit {
  espacios: any = []; (1)

  constructor(private espaciosService: EspaciosService) {} (2)

  ngOnInit(): void { (3)
    this.espaciosService.findAll().subscribe((res) => { (4)
      this.espacios = res; (5)
    });
  }
}
1 Variable donde volcaremos los datos de los espacios recuperados por el servicio
2 Inyección del servicio de espacios
3 Recuperación de los espacios al iniciar el componente
4 Llamada al método del servicio que recupera los espacios
5 Carga de los datos leídos en la variable de espacios

9.3. Configuración de las importaciones del módulo

Como el componente de presentación de espacios usará componentes Material, hay que asegurarse que nuestro módulo de componentes Material está incluido en el módulo del componente de espacios, que es con el estamos trabajando para la lista de espacios. A continuación se muestra la configuración de las importaciones de main/espacios/espacios.module.ts para que esté incluido nuestro módulo de Material:

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

import { EspaciosRoutingModule } from './espacios-routing.module';
import { ConsultarComponent } from './pages/consultar/consultar.component';
import { MaterialModule } from '../../material/material.module';

@NgModule({
  declarations: [ConsultarComponent],
  imports: [CommonModule, EspaciosRoutingModule, MaterialModule], (1)
})
export class EspaciosModule {}
1 Módulo de Material

9.4. Programación de la presentación de datos

En esta sección vamos a ver cómo presentamos los datos en el componente de espacios. Básicamente se trata de explotar la variable espacios inicializada en la parte TypeScript del componente y que es la que contiene los datos. Para ello, se iterará sobre espacios y se mostrarán los datos en tarjetas Material. Dentro de cada espacio, puede haber una lista de reservas, tal y como se muestra a continuación. Por tanto, habrá que crear un bucle anidado que itere sobre las reservas de un espacio.

    { (1)
      "edificio": "Aulario II",
      "aula": "Aula 1",
      "fecha": "07/02/2022",
      "reservas": [ (2)
        {
          "hora": "09:00 - 12:00",
          "descripcion": "GRUPO A",
          "asignatura": "( 31103209 ) - Lexicología y Semántica Inglesas",
          "profesor": ""
        },
        {
          "hora": "12:00 - 15:00",
          "descripcion": "GRUPO A",
          "asignatura": "( 12104226 ) - Historia de la Lengua Española II",
          "profesor": ""
        },
        {
          "hora": "16:00 - 19:00",
          "descripcion": "GRUPO UNICO",
          "asignatura": "( 40153329 ) - Teoría de Códigos y Criptografía",
          "profesor": ""
        }
      ]
    }
1 Datos correspondientes a la ocupación de un espacio en un día concreto
2 Lista de reservas realizadas en un espacio en un día concreto

Consideramos que la API REST devuelve ocupaciones diarias de un espacio. Cada día en un espacio es considerado de forma independiente a otro día en el mismo espacio. En la parte de espacios del Anexo I. Creación de un servidor JSON para datos de prueba se ve claramente esta situación.

Archivo main/espacios/pages/consultar/consultar.component.html:

<div
  *ngIf="espacios.length " (1)
  fxLayout="column"
  fxLayoutAlign="center "
  fxLayoutGap="10px"
>
  <mat-card *ngFor="let espacio of espacios" class="mt-3" fxFill> (2)
    <mat-card-subtitle>
      <mat-label class="mat-body-strong">Edificio: </mat-label
      >{{ espacio.edificio }} (3)
      <mat-label class="mat-body-strong">Aula: </mat-label> {{ espacio.aula }}
      <mat-label class="mat-body-strong">Fecha: </mat-label>{{ espacio.fecha }}
    </mat-card-subtitle>
    <mat-list>
      <mat-list-item *ngFor="let reserva of espacio.reservas"> (4)
        <mat-card-content>
          <mat-label class="mat-body-strong">Hora: </mat-label
          >{{ reserva.hora }} (5)
          <mat-label class="mat-body-strong">Descripción: </mat-label
          >{{ reserva.descripcion }}
          <mat-label class="mat-body-strong">Asignatura: </mat-label
          >{{ reserva.asignatura }}
          <mat-label class="mat-body-strong">Profesor: </mat-label
          >{{ reserva.profesor }}
        </mat-card-content>
      </mat-list-item>
    </mat-list>
  </mat-card>
</div>
1 Impide que haya errores en el *ngFor siguiente cuando no hay datos
2 Bucle para recorrer los espacios
3 Presentación de datos de espacios
4 Bucle anidado para recorrer las reservas de un espacio
5 Presentación de los datos de una reserva

Aquí se muestra el resultado. Por ahora se muestran todas las solicitudes. En el apartado siguiente veremos cómo se le pueden incorporar funcionalidades de filtrado.

ListaDeEspacios

9.5. Incorporación de la funcionalidad de filtrado

En esta sección vamos a ver cómo llevar el código anterior del listado de solicitudes a un componente específico. Haremos esto porque vamos a estructurar la página de espacios en dos componentes. Uno para un componente de filtrado con cuadros de lista y de entrada de datos para especificar condiciones de filtrado. Otro para el componente de presentación de la tabla de datos. La figura siguiente ilustra un esbozo de la página mostrando la funcionalidad de filtrado de espacios.

MockListadoDeEspacios

Comencemos generando el componente de la tabla de espacios para resultados de búsqueda y el del formulario de búsqueda.

$ ng g component main/espacios/components/tabla-espacios
$ ng g component main/espacios/components/form-buscar

Es habitual colocar los componentes de un módulo funcional en una carpeta components al mismo nivel que la carpeta pages del módulo funcional.

Al final, deberemos tener una estructura similar a la de esta figura.

CarpetasEspacios

9.5.1. Flujo de datos entre los componentes

Los componentes individuales (formulario y tabla de resultados) y el componente que los englobla tienen una forma de comunicarse y compartir datos. En la figura siguiente vemos la siguiente organización y flujo de datos entre componentes:

  • El componente padre de la página de espacios incluye los selectores de sus componentes: en este caso el de filtrado y el de tabla de datos. (pasos 1 y 2).

  • El componente del formulario de filtrado define un evento onSearchEspacio, que mediante el decorador @Output() permite enviar datos al componente padre. El componente padre recibe el evento como onSearchEspacio (paso 3). (El objeto que contiene los datos de los espacios está en el componente de espacios, digamos el componente padre.)

  • Se configura filtroBusqueda para que sean los datos que se envíen al padre cuando se produczca el evento onSearchEspacio (pasos 4 y 6).

  • Se define y se implementa en el componente y el template del padre el método asociado a gestionar el evento onSearchEspacio (paso 5).

  • Se inicializa el valor de espacios en el componente padre mediante la llamada al servicio de búsqueda pasándole los parámetros de filtrado (paso 7).

  • El componente padre pasa al componente hijo de tabla de datos los datos de los espacios. Lo hace mediante la técnica de ligado de propiedades en el selector de inclusión al hijo (paso 8). El componente hijo de tabla de datos recibe en el TypeScript los datos de los espacios mediante un decorador @Input()

FlujoPadreHijosCompleto

La figura ilustra los componentes, el flujo y la parte relevante del código para conseguirlo.

Cómo compartir datos entre componentes padre/hijo

Los componentes padre/hijo usando los decoradores @Input() y Output(). Estos decoradores proporcionan la forma de comunicarse con el componente padre.

Con @Input() el padre pasa un valor al hijo. El decorador @Input() se sitúa en el componente hijo y le indica a una propiedad (variable) que puede recibir datos del padre. El padre liga la propiedad del hijo a una variable suya añadiéndole al selector del hijo, la propiedad del hijo que está decorada con @Input junto al valor que quiere propocionar.

A continuación se indica quién es quién en la inclusión del componente hijo en el template del padre.

Selector del hijo Variable decorada del hijlo Valor al que se inicializa Resto

<app-tabla-espacios

[espacios]=

"espacios"

></app-tabla-espacios>

FlujoPadreHijosBasico

El decorador Output() en un componente hijo permite enviar datos desde el hijo al padre. El componente hijo usa el decorador Output() para disparar un evento personalizado y notificar al padre de un cambio. En el componente hijo se define un método donde se emitirá el evento incluyendo los valores que se quieren enviar al padre. En la plantilla del hijo habrá algún elemento al que asociarle la ejecución del método anterior desde el que se emite el evento al padre. La plantilla del padre responderá al evento y recibirá los datos, los cuales serán enviados como parámetro a un método implementado en la parte TypeScript del componente.

Para más información sobre cómo compartir datos entre componentes padre/hijo consultar la documentación oficial.

9.5.2. Programación de la parte TypeScript del componente de tabla de datos

Este componente se dedica a gestionar los datos de la tabla de datos de los espacios recuperados por el servicio. Como los datos de los espacios están en el componente padre, el componente de tabla de datos tendrá que añadir el decorador @Input y asociarle una variable, que será la que usen otros componentes para inyectarle datos.

Archivo main/espacios/component/tabla-espacios/tabla-espacios.component.ts:

import { Component, Input, OnInit } from '@angular/core';

@Component({
  selector: 'app-tabla-espacios', (1)
  templateUrl: './tabla-espacios.component.html',
  styleUrls: ['./tabla-espacios.component.css'],
})
export class TablaEspaciosComponent implements OnInit {
  @Input() espacios: any; (2)

  constructor() {}

  ngOnInit(): void {}
}
1 Selector para usar este componente
2 Decorador Input() que define una variable espacios en la que se recibirán los espacios

9.5.3. Refactorización en un componente dedicado a la tabla de datos

Ahora llevaremos el código que creamos para el HTML del listado de espacios hasta el HTML de la tabla de datos, tal cual, respetando el uso de espacios. Lo podemos llevar tal cual porque la presentación no va a variar y los datos los inyectaremos desde el padre al componete hijo a través de su @Input para espacios.

Archivo main/espacios/component/tabla-espacios/tabla-espacios.component.html:

<div *ngIf="espacios.length">
  <div fxLayout="column" fxLayoutAlign="center " fxLayoutGap="10px">
    <mat-card *ngFor="let espacio of espacios" class="mt-3" fxFill>
      <mat-card-subtitle>
        <mat-label class="mat-body-strong">Edificio: </mat-label
        >{{ espacio.edificio }}
        <mat-label class="mat-body-strong">Aula: </mat-label>
        {{ espacio.aula }} <mat-label class="mat-body-strong">Fecha: </mat-label
        >{{ espacio.fecha }}
      </mat-card-subtitle>
      <mat-list>
        <mat-list-item *ngFor="let reserva of espacio.reservas">
          <mat-card-content>
            <mat-label class="mat-body-strong">Hora: </mat-label
            >{{ reserva.hora }}
            <mat-label class="mat-body-strong">Descripción: </mat-label
            >{{ reserva.descripcion }}
            <mat-label class="mat-body-strong">Asignatura: </mat-label
            >{{ reserva.asignatura }}
            <mat-label class="mat-body-strong">Profesor: </mat-label
            >{{ reserva.profesor }}
          </mat-card-content>
        </mat-list-item>
      </mat-list>
    </mat-card>
  </div>
</div>

9.5.4. Configuración de la página de listado de espacios

Ahora la página de listado de espacios, que antes mostraba directamente el listado de espacios, se convertirá en una página contenedor o en una página padre que incluye dos ( componente de formulario de búsqueda y componente de filtrado de datos), tal y como se muestra a continuación.

<div fxLayout="column" fxLayoutAlign="center center" fxLayoutGap="20px">
  <div fxFlex>
    <h1>Lista de espacios</h1>
    <app-form-buscar ></app-form-buscar> (1)
  </div>
  <div fxLayoutAlign="center stretch">
    <app-tabla-espacios [espacios]="espacios"></app-tabla-espacios> (2)
  </div>
</div>
1 Selector para incluir el componente del formulario de búsqueda
2 Selector para incluir el componente de tabla de datos. Se le pasa al decorador @Input definido como espacios el valor que tenemos almacenado en espacios de este componente, el componente padre.

Al pasar datos a un componente a través de @Input, la sintaxis

<app-tabla-espacios [espacios]="espacios"></app-tabla-espacios>

se interpreta así: la primera parte es el nombre del Input en el componente de destino; la segunda parte es el valor que le pasamos.

La página se verá así. Vemos que aún no aparece el formulario para el filtrado. Lo crearemos en breve. Antes, hay que adaptar el módulo de estos componentes de espacios para incluir formularios reactivos. Pero ya hemos conseguido segregar la tabla de datos a un componente hijo.

ListaDeEspaciosSinFormularioDeBusqueda

9.5.5. Modificación del módulo de espacios

Hay que hace una modificación ligera al módulo de espacios para importar el módulo de formularios reactivos. Lo va a necesitar el componente del formulario de filtrado de espacios, que lo crearemos como formulario reactivo.

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

import { EspaciosRoutingModule } from './espacios-routing.module';
import { ConsultarComponent } from './pages/consultar/consultar.component';
import { MaterialModule } from '../../material/material.module';
import { TablaEspaciosComponent } from './components/tabla-espacios/tabla-espacios.component';
import { FormBuscarComponent } from './components/form-buscar/form-buscar.component';

@NgModule({
  declarations: [
    ConsultarComponent,
    TablaEspaciosComponent,
    FormBuscarComponent,
  ],
  imports: [
    CommonModule,
    EspaciosRoutingModule,
    MaterialModule,
    ReactiveFormsModule, (1)
  ],
})
export class EspaciosModule {}
1 Incorporación del módulo de formularios reactivos

9.5.6. Adaptación del servicio de búsqueda para que acepte parámetros de filtrado

En el servicio de espacios, el método findAll() actualmente devuelve todos los espacios y no ofrece posibilidad de filtrado. A continuación se muestra una modificación para que permita parámetros de filtrado. Dichos parámetros serán los que recojan los datos del formulario de filtrado.

El filtro de búsqueda llega como un objeto JSON clave-valor, donde la clave es el nombre del campo a filtrar y el valor, el valor de filtrado. La idea es ir concatenando en las peticiones los parámetros de filtrado uno detrás de otro, como se muestra a continuación. Nótese que también se ordena para que aparezcan primero los espacios con las solicitudes ordenadas en el tiempo.

http://localhost:3002/espacios?_sort=fecha&fecha=07/02/2022&aula=Aula%201

En la URL anterior %20 representa un espacio en blanco para consultar las solicitudes en Aula 1.

import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from 'src/environments/environment';

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

  findAll(filtroBusqueda: any): Observable<any> {
    let filtroRequest = ''; (1)

    if (filtroBusqueda.start) { (2)
      filtroRequest = filtroRequest.concat(`&start=${filtroBusqueda.start}`);
    }

    if (filtroBusqueda.end) {
      filtroRequest = filtroRequest.concat(`&end=${filtroBusqueda.end}`);
    }

    if (filtroBusqueda.horaInicio) {
      filtroRequest = filtroRequest.concat(
        `&horaInicio=${filtroBusqueda.horaInicio}`
      );
    }
    if (filtroBusqueda.horaFin) {
      filtroRequest = filtroRequest.concat(
        `&horaFin=${filtroBusqueda.horaFin}`
      );
    }
    if (filtroBusqueda.edificio) {
      filtroRequest = filtroRequest.concat(
        `&edificio=${filtroBusqueda.edificio}`
      );
    }
    if (filtroBusqueda.aula) {
      filtroRequest = filtroRequest.concat(`&aula=${filtroBusqueda.aula}`);
    }

    return this.http.get(
      `${environment.urlEspacios}/espacios?_sort=fecha${filtroRequest}`
    );
  }
}
1 Inicialización de la variable que almacena el filtro
2 Pipeline que va añadiendo filtros de búsqueda al filtro

9.5.7. Creación de un formulario inicial de búsqueda

Se trata de programar el formulario reactivo del componente de búsqueda, tanto en su parte TypeScript como en su parte HTML.

Comenzaremos con un formulario básico que aún no implementará la funcionalidad de búsqueda. Sí tendrá ya un método buscar(), pero su cuerpo aún estará vacío. Iremos construyendo el formulario de búsqueda de forma incremental. La figura siguiente ilustra un mock del formulario a construir.

FormularioFiltrado

Archivo main/espacios/componentes/form-buscar/form-buscar.ts:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';

@Component({
  selector: 'app-form-buscar',
  templateUrl: './form-buscar.component.html',
  styleUrls: ['./form-buscar.component.css'],
})
export class FormBuscarComponent implements OnInit {

  formBuscarEspacio: FormGroup = this.fb.group({ (1)
    start: [,],
    end: [,],
    horaInicio: [,],
    horaFin: [,],
    edificio: [,],
    aula: [,],
    busqueda: [,],
  });

  hours = Array.from(Array(24).keys()); (2)

  constructor(private fb: FormBuilder) {} (3)

  ngOnInit(): void {}

  buscar() {} (4)
}
1 Componentes del formulario reactivo
2 Variable para almacenar las horas con las que poblar el desplegable de horas
3 Inyección de FormBuilder paa usar formularios reactivos
4 Declaración del método buscar() que implementaremos más adelante

A continuación crearemos la parte de presentación del formulario de búsqueda de acuerdo con el mock anterior:

Archivo main/espacios/componentes/form-buscar/form-buscar.html:

<form [formGroup]="formBuscarEspacio"> (1)
  <mat-card>
    <mat-card-subtitle>Datos para la consulta</mat-card-subtitle>
    <div fxLayout="row" fxLayoutGap="20px">
      <div fxFlex>
        <mat-form-field appearance="fill" fxFill>
          <mat-label>Rango de fechas</mat-label>
          <mat-date-range-input [rangePicker]="picker">
            <input
              matStartDate
              formControlName="start" (2)
              placeholder="Start date"
            />
            <input matEndDate formControlName="end" placeholder="End date" />
          </mat-date-range-input>
          <mat-datepicker-toggle
            matSuffix
            [for]="picker"
          ></mat-datepicker-toggle>
          <mat-date-range-picker #picker></mat-date-range-picker>
        </mat-form-field>
      </div>
      <div fxFlex>
        <mat-form-field appearance="outline" fxFill>
          <mat-label>Hora de inicio</mat-label>
          <mat-select formControlName="horaInicio"> (3)
            <mat-option *ngFor="let hour of hours" value="{{ hour }}">{{
              hour
            }}</mat-option>
          </mat-select>
        </mat-form-field>
      </div>

      <div fxFlex>
        <mat-form-field appearance="outline" fxFill>
          <mat-label>Hora de fin</mat-label>
          <mat-select formControlName="horaFin"> (4)
            <mat-option *ngFor="let hour of hours" value="{{ hour }}">{{
              hour
            }}</mat-option>
          </mat-select>
        </mat-form-field>
      </div>
    </div>
    <div fxLayout="row" fxLayoutGap="20px">
      <div fxFlex>
        <mat-form-field appearance="outline" fxFill>
          <mat-label>Edificio</mat-label>
          <mat-select formControlName="edificio"> (5)
            <mat-option value="Aulario I">Aulario I</mat-option>
            <mat-option value="Aulario II">Aulario II</mat-option>
          </mat-select>
        </mat-form-field>
      </div>
      <div fxFlex>
        <mat-form-field appearance="outline" fxFill>
          <mat-label>Aula</mat-label>
          <mat-select formControlName="aula"> (6)
            <mat-option value="Aula 1">Aula 1</mat-option>
            <mat-option value="Aula 2">Aula 2</mat-option>
          </mat-select>
        </mat-form-field>
      </div>
      <div fxFlex>
        <mat-form-field appearance="outline" fxFill>
          <mat-label>Texto de búsqueda</mat-label>
          <input matInput formControlName="busqueda" /> (7)
        </mat-form-field>
      </div>
    </div>
    <div fxLayout="row" fxLayoutAlign="center center">
      <button
        mat-button
        mat-flat-button
        color="primary"
        type="submit"
        (click)="buscar()" (8)
      >
        Buscar
      </button>
    </div>
  </mat-card>
</form>
1 Ligado del formulario al objeto formulario en la parte TypeScript
2 Ligado del input en modo de formulario reactivo
3 Ligado del select en modo de formulario reactivo
4 Ligado del select en modo de formulario reactivo
5 Ligado del select en modo de formulario reactivo
6 Ligado del select en modo de formulario reactivo
7 Ligado del input en modo de formulario reactivo
8 Ligado del input en modo de formulario reactivo

Una vez creadas las partes TypeScript y HTML del componente de formulario, sólo falta añadirlo como componente hijo al componente de consulta de espacios.

Archivo main/espacios/pages/consultar/consultar.component.html:

<div fxLayout="column" fxLayoutAlign="center center" fxLayoutGap="20px">
  <div fxFlex>
    <h1>Lista de espacios</h1>
    <app-form-buscar></app-form-buscar> (1)
  </div>
  <div fxLayoutAlign="center stretch">
    <app-tabla-espacios [espacios]="espacios"></app-tabla-espacios>
  </div>
</div>
1 Incorporación del formulario de búsqueda como componente

Si ahora vamos a la página de consulta de espacios veremos que ya sí aparece el formulario. Nuestra página de consulta de espacios está formada por el componente de formulario y el de tabla de resultados, tal y como indicaba el mock. Sin embargo, si seleccionamos Aulario II y Aula 1 como criterios de búsqueda y pulsamos Buscar vemos que no se filtran los resultados.

FormularioDeBusquedaQueNoBusca

Esto se debe a que aún no se están recuperando los parámetros de filtrado del formulario ni se están pasando al servicio de búsqueda para que devuelva un resultado filtrado. Ews decir, el componente de formulario está creado e incluido en su componente padre, pero ni se recogen sus datos de filtro de búsqueda y por tanto no se hace nada con ellos para restringir la tabla de resultados. A continuación veremos cómo solucionar este problema.

9.5.8. Incorporación de capacidades de búsqueda/filtrado

Para finalizar con el formulario de búsqueda tenemos varias tareas pendientes:

  1. Modificar el TypeScript del componente hijo (el formulario de búsqueda) para implementar la función de configuración del filtro de búsqueda y para enviar los datos al componente padre (la página de consulta de espacios).

  2. Implementar la función de búsqueda con parámetros en el componente padre (la página de espacios).

  3. Recoger en la página de espacios los datos enviados desde el componente formulario.

Configuración del filtro de búsqueda en el componente hijo

Los componentes Angular envían sus datos a otros componentes (en este caso el formulario de búsqueda envía a la página de espacios) generando un evento personalizado a través del cual publican sus datos. En este caso los datos que publicaremos serán los valores del filtro de búsqueda recogidos mediante el formulario. Veamos cómo hacerlo.

Archivo main/espacios/components/form-buscar/form-buscar.component.ts:

import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';

@Component({
  selector: 'app-form-buscar',
  templateUrl: './form-buscar.component.html',
  styleUrls: ['./form-buscar.component.css'],
})
export class FormBuscarComponent implements OnInit {
  @Output() onSearchEspacio = new EventEmitter(); (1)

  filtroBusqueda: any; (2)

  formBuscarEspacio: FormGroup = this.fb.group({
    start: [,],
    end: [,],
    horaInicio: [,],
    horaFin: [,],
    edificio: [,],
    aula: [,],
    busqueda: [,],
  });

  hours = Array.from(Array(24).keys());

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {}

  buscar() {
    this.filtroBusqueda = { (3)
      start: this.formBuscarEspacio.controls['start'].value,
      end: this.formBuscarEspacio.controls['end'].value,
      horaInicio: this.formBuscarEspacio.controls['horaInicio'].value,
      horaFin: this.formBuscarEspacio.controls['horaFin'].value,
      edificio: this.formBuscarEspacio.controls['edificio'].value,
      aula: this.formBuscarEspacio.controls['aula'].value,
    };
    this.onSearchEspacio.emit(this.filtroBusqueda); (4)
  }
}
1 Creación de un evento personalizado para publicar los datos del filtro de búsqueda
2 Objeto JSON donde almacenaremos las condiciones del filtro de búsqueda
3 Inicialización del filtro de búsqueda con los valores recogidos en el formulario.
4 Disparo del evento personalizado añadiéndole (publicando) los datos del filtro de búsqueda
Implementacion de la función de búsqueda con parámetros en el componente padre

Se trata de incorporar al método buscar() los parámetros de búsqueda que serán utilizados en el método findAll() del servicio de espacios. También se usará una snackbar para mostrar posibles mensajes de error.

Archivo main/espacios/pages/consultar/consultar.component.ts:

import { Component, OnInit } from '@angular/core';
import { EspaciosService } from '../../../../services/espacios.service';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
  selector: 'app-consultar',
  templateUrl: './consultar.component.html',
  styleUrls: ['./consultar.component.css'],
})
export class ConsultarComponent implements OnInit {
  espacios: any = [];

  constructor(
    private espaciosService: EspaciosService,
    private snackBar: MatSnackBar (1)
  ) {}

  ngOnInit(): void {} (2)

  buscar(filtroBusqueda: any) {
    this.espacios = this.espaciosService
      .findAll(filtroBusqueda) (3)
      .subscribe((res) => {
        this.espacios = res;

        if (this.espacios.length == 0) { (4)
          this.snackBar.open('No hay datos', '', {
            duration: 1500,
          });
        }
      });
  }
}
1 Inyección de la snackbar
2 El método de inicialización ya no muestra todas los datos de espacios ocupados
3 Llamada al método findAll() del servicio pasándole el filtro de búsqueda
4 Presentación de mensaje de error si no hay datos
Recogida de datos en el componente padre

Aquí se trata de escuchar al evento que está publicando el componente del formulario de búsqueda y disparar una acción, que consisitirá en la llamada al método de búsqueda.

Archivo main/espacios/pages/consultar/consultar.component.html:

<div fxLayout="column" fxLayoutAlign="center center" fxLayoutGap="20px">
  <div fxFlex>
    <h1>Lista de espacios</h1>
    <app-form-buscar (onSearchEspacio)="buscar($event)"></app-form-buscar> (1)
  </div>
  <div fxLayoutAlign="center stretch">
    <app-tabla-espacios [espacios]="espacios"></app-tabla-espacios>
  </div>
</div>
1 Llamar al método buscar() tras la publicación del evento onSearchEspacio pasándole los datos recibidos en el evento $event.

La utilización de un evento publicado por parte de otro componente se hace siguiendo este patrón:

Llamar a un método como respuesta al evento publicado y pasándole al método como parámetro los datos recibidos a través de un objeto $event.

<app-form-buscar (onSearchEspacio)="buscar($event)"></app-form-buscar>

El código anterior escucha a un evento onSearchEspacio publicado por el componente app-form-buscar. En respuesta, llama a un método buscar() pasándole a través de $event los datos publicados/enviados por el evento.

Si ahora volvemos a seleccionar Aulario II y Aula 1 como criterios de búsqueda y pulsamos Buscar vemos que ya sí se filtran los resultados.

EspaciosFiltrados
Flujo datos entre componentes

Envío del evento, recepción del evento, acción y envío de datos…​

Un aspecto útil que debemos controlar es la presentación de información en cuadros de diálogo. Veamos en esta sección cómo tratar con ellos.

10.1. Cuadro de diálogo informativo

Comenzaremos creando un cuadro de diálogo informativo, que son los más sencillos. A modo de ejemplo, se trata de un cuadro de diálogo con información del proyecto. Se abrirá al pulsar un botón Créditos que vamos a añadir a la barra lateral.

Para usar cuadros de diálogo es necesario el módulo Material MatDialogModule. Nuestro módulo Material ya lo tenía incorporado. Si no disponemos de un modo tan genérico como nuestro Material, que como ya hemos indicado puede ser demasiado en algunos casos, MatDialogModule tiene que estar en el módulo en el que está el componente desde el que se va a abrir el cuadro de diálogo.

Comenzaremos creando el componente para el cuadro de diálogo. Como es algo informativo y común a toda la aplicación no lo incluiremos en la carpeta main. Lo colocaremos en shared/sidebar/components.

$ ng g component shared/sidebar/components/dialogoCreditos

A continuación vamos a implementar la lógica del cuadro de diálogo (p.e. qué hacer al aceptar). Para ello, implementaremos un método que denominaremos aceptar() a modo de prueba. Necesitaremos trabajar un objeto MatDialogRef.

De acuerdo con la documentación de Angular Material, al abrir un cuadro de diálogo, éste devolverá una instancia de MatDialogRef. Este MatDialogRef proporciona un ` handle sobre el cuadro de diálogo abierto. Aquí lo usaremos para cerrar el cuadro de diálogo.

Archivo shared/sidebar/components/dialogo-creditos/dialogo-creditos.ts:

import { Component, OnInit } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';

@Component({
  selector: 'app-dialogo-creditos',
  templateUrl: './dialogo-creditos.component.html',
  styleUrls: ['./dialogo-creditos.component.css'],
})
export class DialogoCreditosComponent implements OnInit {
  constructor(public dialogRef: MatDialogRef<DialogoCreditosComponent>) {} (1)

  ngOnInit(): void {}

  aceptar() { (2)
    console.log('Pulsaste Aceptar'); (3)
    this.dialogRef.close(); (4)
  }
}
1 Inyección del handle para manejar el cuadro de diálogo
2 Método al que llamar cuando se pulse el botón Aceptar del cuadro de diálogo
3 Mostrar un eco en la consola para saber que el método se ejecutó.
4 Cerrar el cuadro de diálogo.

A continuación crearemos el contenido del cuadro de diálogo (p.e. información del proyecto). Usaremos las directivas de estilo para los cuadros de diálogo de Material (p.e. mat-dialog-title, mat-dialog-content, mat-dialog-actions y el estilo mat-typography).

Archivo shared/sidebar/components/dialogo-creditos/dialogo-creditos.html:

<h1 mat-dialog-title>Créditos</h1>
<div mat-dialog-content class="mat-typography">
  <h3>Dirección del proyecto</h3>
  <p>
    Culpa incididunt ut occaecat amet dolore eiusmod ex ut sit laborum in
    nostrud.
  </p>

  <h3>Equipo de desarrollo</h3>
  <p>
    Laboris excepteur voluptate deserunt labore ex consequat nisi fugiat non
    incididunt adipisicing nisi ipsum labore.
  </p>

  <h3>Información del proyecto</h3>
  <p>
    <a href="https://www.ual.es" target="_blank">Web del proyecto</a>
  </p>
</div>
<div mat-dialog-actions>
  <button mat-button [mat-dialog-close] cdkFocusInitial>Cancelar</button> (1)
  <button mat-button mat-flat-button color="primary" (click)="aceptar()"> (2)
    Aceptar
  </button>
</div>
1 Botón Cerrar al que asociaremos directamente la directiva de cierre del formulario
2 Botón Aceptar que llamará al método que hemos creado un poco más arriba.

A continuación, añadiremos a la barra lateral la lógica que abrirá el cuadro de diálogo

Archivo shared/sidebar/sidebar.component.ts:

import { Component, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DialogoCreditosComponent } from './components/dialogo-creditos/dialogo-creditos.component';

@Component({
  selector: 'app-sidebar',
  templateUrl: './sidebar.component.html',
  styleUrls: ['./sidebar.component.css'],
})
export class SidebarComponent implements OnInit {
  constructor(public dialog: MatDialog) {} (1)

  ngOnInit(): void {}

  creditos() { (2)
    const dialogRef = this.dialog.open(DialogoCreditosComponent, { (3)
      width: '50%',
    });
  }
}
1 Inyección del objeto MatDialog para poder abrirlo
2 Método que será llamado para abrir el cuadro de diálogo
3 Abrir el cuadro de diálogo y que ocupe el 50% del ancho de la pantalla

Por último, ya sólo falta añadir a la barra lateral el botón que abre el cuadro de diálogo y asociarle el método creditos() que acabamos de crear.

Archivo shared/sidebar/sidebar.component.html:

<div fxLayout="column">
  <button mat-button routerLink="/">Home</button>
  <hr />

  <mat-accordion>
    ...
  </mat-accordion>
  <button mat-button (click)="creditos()">Créditos</button> (1)
</div>
1 Botón que abre el cuadro de diálogo

La figura siguiente ilustra el resultado.

DialogoInformativo

Hemos creado este cuadro informativo con un botón de cerrar y otro de aceptar al que le hemos añadido comportamiento. Un cuadro de información real sólo tendría un botón para cerrar. Hemos añadido otro botón al que asignar comportamiento para aprender a tratar con ello, aunque en este caso sea irrelevante.

11. Eliminación de solicitudes

En esta sección vamos a ver cómo podemos realizar eliminaciones en nuestra aplicación. Para ello, dado que en la sección anterior hemos visto cómo presentar un cuadro de diálogo, nos apoyaremos en esa técnica para mostrar en un cuadro de diálogo informativo los datos que se van a eliminar antes de proceder a su eliminación.

La forma de proceder para la eliminación de solicitudes es incluir en el listado de solicitudes un botón de eliminar al lado de cada solicitud. El botón llamará a un método de eliminación al que pasaremos todos los datos de la solicitud que se vaya a eliminar. Con esos datos se podrá completar un cuadro de diálogo que permitirá la confirmación o la cancelación de la eliminación.

11.1. Creación del cuadro de diálogo de confirmación de eliminación.

Comencemos creando el componente del cuadro de diálogo de eliminación. Se trata de un componente que estará dentro de la carpeta de solicitudes.

Como convenio para nuestras aplicaciones de UAL-STIC, la eliminación tendrá su propio componente y se realizará mediante un cuadro de diálogo. Ese componente de eliminación se situará dentro de una carpeta components dentro del bloque funcional al que esté referido (p.e. solicitudes).

$ ng g component main/solicitudes/components/dialogoEliminar

Usaremos los módulos MatIconModule, MatDialogModule, que ya están incorporados en nuestro módulo Material

11.2. Creación del método de eliminación de solicitudes

A continuación debemos implementar en el servicio Solicitudes el método de eliminación de solicitudes añadiéndolo a services/solicitudes.service.ts:

import { environment } from './../../environments/environment';
import { Observable } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';

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

  findAll(): Observable<any> {
    return this.http.get(`${environment.urlEspacios}/solicitudes`);
  }

  save(data: any): Observable<any> {
    return this.http.post(`${environment.urlEspacios}/solicitudes`, data);
  }

  delete(id: any): Observable<any> { (1)
    return this.http.delete(`${environment.urlEspacios}/solicitudes/${id}`); (2)
  }
}
1 Método de eliminación de solicitudes
2 Llamada al método HTTP de eliminación componiendo la URL

11.3. Programación de la lógica del cuadro de diálogo de eliminación

El cuadro de diálogo de eliminación usará un handle para manejar (en nuestro caso cerrar) el cuadro de diálogo y se le inyectarán los datos que queremos mostrar (en nuestro caso los datos de la solicitud a eliminar).

También usará el servicio de solicitudes para proceder a la eliminación, una snackbar para la presentación de mensajes y un router para redirigir la aplicación a otra pantalla tras la eliminación.

Archivo main/solicitudes/components/dialogo-eliminar/dialogo-eliminar.component.ts:

import { Component, Inject, OnInit } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { SolicitudesService } from '../../../../services/solicitudes.service';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
  selector: 'app-dialogo-eliminar',
  templateUrl: './dialogo-eliminar.component.html',
  styleUrls: ['./dialogo-eliminar.component.css'],
})
export class DialogoEliminarComponent implements OnInit {
  constructor(
    public dialogRef: MatDialogRef<DialogoEliminarComponent>, (1)
    @Inject(MAT_DIALOG_DATA) public data: any, (2)
    private solicitudesService: SolicitudesService, (3)
    private router: Router, (4)
    private snackBar: MatSnackBar (5)
  ) {}

  ngOnInit(): void {}

  eliminar() { (6)
    this.solicitudesService.delete(this.data.id).subscribe(() => { (7)
      this.snackBar.open('Solicitud eliminada', '', { (8)
        duration: 1500,
      });

      this.dialogRef.close(); (9)
      this.router.navigate([this.router.url]).then(() => { (10)
        window.location.reload();
      });
    });
  }
}
1 Inyección del handle para manejar el cuadro de diálogo
2 Inyección de datos para el cuadro de diálogo
3 Servicio de operaciones con solicitudes
4 Router para desviar la aplicación a otra página tras la eliminación
5 Snackbar para la presentación de mensajes
6 Método para eliminación de solicitudes
7 Llamada al método eliminación del servicio
8 Presentación de la snackbar informativa
9 Cierre del cuadro de diálogo
10 Recargar la ruta de la página

11.4. Presentación de los datos en el cuadro de diálogo de eliminación

Tal y como hemos creado en el apartado anterior, hemos dejado preparada la parte TypeScript del cuadro de diálogo con una variable data que contiene los datos que se van a eliminar. Los mostraremos en el cuadro de diálogo para que los usuarios pueden comprobar lo que van a eliminar antes de proceder.

Archivo main/solicitudes/components/dialogo-eliminar/dialogo-eliminar.component.html:

<h1 mat-dialog-title>Confirmación de eliminación</h1>
<div mat-dialog-content class="mat-typography">
  <h3>Desea eliminar esto?</h3>
  <p>Reserva para : {{ data.nombre }} - {{ data.cargo }}</p> (1)
  <p>Actividad: {{ data.nombreActividad }}</p>
  <p>
    Desde {{ data.start | date: "dd/MM/yyyy" }} hasta
    {{ data.end | date: "dd/MM/yyyy" }}. Día: {{ data.dia }}. Hora:
    {{ data.horaInicio }} - {{ data.horaFin }}
  </p>
</div>
<div mat-dialog-actions>
  <button mat-button [mat-dialog-close] cdkFocusInitial>Cancelar</button> (2)
  <button mat-button mat-flat-button color="warn" (click)="eliminar()"> (3)
    Eliminar
  </button>
</div>
1 Presentación de elementos nombre y cargo del objeto data
2 Acción de cierre del cuadro proporionada por [mat-dialog-close]
3 Llamada al método eliminar() implementado en la parte TypeScript del cuadro de diálogo

11.5. Programación de la llamada a la eliminación desde el listado de solicitudes.

Ahora se tarta de programar la eliminación desde el listado. Se trata de implementar un método que abra el cuadro de diálogo y que le pase los datos de eliminación para poder mostrar a los usuarios lo que van a eliminar antes de proceder.

Archivo main/solicitudes/pages/consultar/consultar.component.ts:

import { Component, OnInit } from '@angular/core';
import { SolicitudesService } from '../../../../services/solicitudes.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { DialogoEliminarComponent } from '../../components/dialogo-eliminar/dialogo-eliminar.component';

@Component({
  selector: 'app-consultar',
  templateUrl: './consultar.component.html',
  styleUrls: ['./consultar.component.css'],
})
export class ConsultarComponent implements OnInit {
  solicitudes: any = [];

  dataSource: any = [];
  displayedColumns = ['nombre', 'cargo', 'unidad', 'telefono', 'acciones'];

  constructor(
    private solicitudesService: SolicitudesService, (1)
    private snackBar: MatSnackBar, (2)
    public dialog: MatDialog (3)
  ) {}
  ngOnInit(): void {
    this.solicitudesService.findAll().subscribe((res) => {
      this.dataSource = res;
      if (this.dataSource.length == 0) {
        this.snackBar.open('No hay solicitudes', '', {
          duration: 1500,
        });
      }
    });
  }

  eliminar(element: any) { (4)
    this.dialog.open(DialogoEliminarComponent, { (5)
      data: element, (6)
    });
  }
}
1 Servicio en el que está el método de eliminación de solicitudes
2 Snackbar para la presentación de mensajes de error
3 Inyección del cuadro de diálogo para poder abrirlo
4 Método eliminar() llamado desde la página del listado de solicitudes
5 Abrir el cuadro de diálogo pasándole datos en data
6 Configurar data con los datos que reciba el método

11.6. Presentación del botón de eliminación en el listado de solicitudes

Por último, ya sólo nos falta incluir un botón de eliminar en el componente del listado de solicitudes, y que llame al método que presenta el cuadro de diálogo de eliminación pasándole los datos de la solicitud a eliminar.

Archivo main/solicitudes/pages/consultar/consultar.component.html:

<div fxLayout="column" fxLayoutAlign="center center">
  <h1>Listado de solicitudes</h1>
  <div *ngIf="dataSource.length > 0">
    <hr />
    <mat-card>
      <table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
        <ng-container matColumnDef="nombre">
          <th mat-header-cell *matHeaderCellDef>Nombre</th>
          <td mat-cell *matCellDef="let element">{{ element.nombre }}</td>
        </ng-container>

        <ng-container matColumnDef="cargo">
          <th mat-header-cell *matHeaderCellDef>Cargo</th>
          <td mat-cell *matCellDef="let element">{{ element.cargo }}</td>
        </ng-container>

        <ng-container matColumnDef="unidad">
          <th mat-header-cell *matHeaderCellDef>Unidad</th>
          <td mat-cell *matCellDef="let element">{{ element.unidad }}</td>
        </ng-container>

        <ng-container matColumnDef="telefono">
          <th mat-header-cell *matHeaderCellDef>Teléfono</th>
          <td mat-cell *matCellDef="let element">{{ element.telefono }}</td>
        </ng-container>

        <!-- Columna de accciones -->
        <ng-container matColumnDef="acciones">  (2)
          <th mat-header-cell *matHeaderCellDef>Acciones</th>
          <td mat-cell *matCellDef="let element"> (3)
            <button
              mat-stroked-button
              color="primary"
              (click)="eliminar(element)" (4)
            >
              <mat-icon>delete</mat-icon> (5)
              Eliminar
            </button>
          </td>
        </ng-container>

        <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
        <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
      </table>
    </mat-card>
  </div>
</div>
1 Mostrar la tabla si hay datos
2 Bloque para la columna de acciones
3 Configuración del valor de la celda a todo el registro mostrado (element)
4 Llamada al método eliminar() de la parte TypeScript pasándole todo el registro
5 Incorporación al botón del icono de la papelera

Veamos el resultado en funcionamiento. Partimos de una pantalla con un conjunto de solicitudes. Al lado de cada solicitud aparecerá un botón para poder eliminarla.

PantallaParaEliminarSolicitudes

Si pulsamos Eliminar sobre una de ellas aparecerá el cuadro de diálogo de confirmación mostrando los datos para comprobar que es la solicitud a eliminar.

ConfirmarEliminarSolicitud

Si confirmamos la eliminación, se eliminará la solicitud, se informará en una snackbar y se devolverá la aplicación para que vuelva a mostrar la lista de solicitudes. La solicitud eliminada ya no aparecerá.

SolicitudesTrasEliminar

12. Actualización de solicitudes

Para finalizar con la parte de operaciones CRUD vamos a añadir a la aplicación la posibilidad de actualizar solicitudes. Tendremos que:

  • Implementar los métodos de búsqueda y modificación de una solicitud.

  • Crear de una nueva ruta en la aplicación para la operación de modificación.

  • Adaptar el componente de crear para que también valga para modificar.

  • Crear el método de editar solicitud en el componente de listado de solicitudes.

  • Añadir el botón de modificar solicitud y conectarlo al método de editar solicitud anterior.

12.1. Creación del método de actualización de una solicitud

La actualización de una solicitud realmente implica dos operaciones diferentes. Una de búsqueda de la solicitud para la recuperación de sus datos y presentarlos en el formulario de modificación. Otra que se encargue de la modificación en sí misma. De esto se van a encargar los métodos findOne y update, respectivamente, que se muestran a continuación.

Archivo services/solicitudes.service.ts:

import { environment } from './../../environments/environment';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';

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

  findAll(): Observable<any> {
    return this.http.get(`${environment.urlEspacios}/solicitudes`);
  }

  findOne(id: any): Observable<any> { (1)
    return this.http.get(`${environment.urlEspacios}/solicitudes/${id}`); (2)
  }

  save(data: any): Observable<any> {
    return this.http.post(`${environment.urlEspacios}/solicitudes`, data);
  }

  delete(id: any): Observable<any> {
    return this.http.delete(`${environment.urlEspacios}/solicitudes/${id}`);
  }

  update(data: any): Observable<any> { (3)
    return this.http.put(
      `${environment.urlEspacios}/solicitudes/${data.id}`, (4)
      data
    );
  }
}
1 Método para la búsqueda y recuperación de los datos de la solicitud a modificar
2 Llamada al endpoint de recuperación de la solicitud a modificar
3 Método de actualización
4 Llamada al endpoint de actualización de la solicitud pasándole los datos de la solicitud modificada

12.2. Creación de la ruta de modificación de solicitudes

Necesitamos una nueva ruta para editar una solicitud. A esa ruta asociaremos el componente que se mostrará en la aplicación. Realmente no es necesario un nuevo componente de Modificar solicitud. Se puede encargar el mismo componente de Crear solicitud. Eso sí, habrá que adaptarlo. Básicamente es lo mismo pero incluyendo el id de la solicitud a modificar. Además, tendrá que incorporarse un método PATCH para la modificación. Lo veremos más adelante. Por ahora, comencemos creando la nueva ruta para modificar una solicitud

Cuando creamos el módulo de rutas de solicitudes no añadimos una ruta específica para modificar. Esto se debió porque hasta ese momento cada ruta estaba asociada a un componente diferente. Para no provocar confusión en aquel momento llevando dos rutas a un mismo componente, se optó por no incluir la ruta de modificación de solicitudes en su módulo de rutas y posponer su creación hasta este momento.

Archivo main/solicitudes/solictudes.routing.ts:

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CrearComponent } from './pages/crear/crear.component';
import { ConsultarComponent } from './pages/consultar/consultar.component';

const routes: Routes = [
  {
    path: '',
    children: [
      { path: 'crear', component: CrearComponent },
      { path: 'consultar', component: ConsultarComponent },
      { path: 'editar/:id', component: CrearComponent }, (1)
      { path: '', redirectTo: 'consultar' },
    ],
  },
  {
    path: '**',
    redirectTo: 'crear',
  },
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule],
})
export class SolicitudesRoutingModule {}
1 Nueva ruta para modificación de solicitudes mostrando el componente de crear solicitudes

Si ahora escribimos la ruta http://localhost/solicitudes/editar/1 comprobamos que la ruta es aceptada. Además, se muestra el componente de Crear solicitud, tal y como hemos indicado en el archivo de rutas.

RutaEditarSolicitud

Sin embargo, lo esperado en la aplicación es que hubiese mostrado una pantalla de modificación con los datos de la solicitud a modificar. Obviamente esto aún no se puede mostrar porque aún está por implementar. Veamos cómo hacerlo en los apartados siguientes.

12.3. Un componente para crear y editar

Hasta ahora tenemos un componente para crear solicitudes. Lo inmediato sería crear uno nuevo para editar solicitudes. Sin embargo, si nos paramos a pensar, el componente de Editar es prácticamente igual al de Crear salvo que al editar se cuenta con el id de la solicitud que se está editando, y al crear, el id es creado a posteriori. Por tanto, podríamos plantearnos en tener un único componente que usaríamos para las dos operaciones. En ese caso, tendremos que usar una variable que indicará si estamos en modo de edición o en modo de creación. En función de su valor, se mostrará un texto u otro en la cabecera y en los botones (texto de creación o de modificación), y se llamará al método correspondiente (de creación o de eliminación). Veamos cómo hacerlo.

Comencemos con la adaptación de la parte TypeScript del componente de Crear solicitud para que también valga para editar.

Archivo main/solicitudes/pages/crear/crear.component.ts:

import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { lastValueFrom } from 'rxjs';
import { PersonaService } from '../../../../services/persona.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Solicitud } from '../../../../interfaces/solicitud';
import { SolicitudesService } from '../../../../services/solicitudes.service';
import { ActivatedRoute, Router } from '@angular/router';

@Component({
  selector: 'app-crear',
  templateUrl: './crear.component.html',
  styleUrls: ['./crear.component.css'],
})
export class CrearComponent implements OnInit {
  modoEditar: boolean = false; (1)
  id: any; (2)

  cargos: string[] = [];

  persona: any;

  formHorario: FormGroup = this.fb.group({
    nombre: [{ value: '', disabled: true }],
    cargo: [, [Validators.required]],
    unidad: [{ value: '', disabled: true }],
    telefono: [{ value: '', disabled: true }],
    email: [, [Validators.required, Validators.email]],
    tipo: [,],
    nombreActividad: [, [Validators.required, Validators.minLength(5)]],
    start: [,],
    end: [,],
    dia: [,],
    horaInicio: [,],
    horaFin: [,],
  });

  diasSemana: string[] = [
    'lunes',
    'martes',
    'miercoles',
    'jueves',
    'viernes',
    'sabado',
    'domingo',
  ];

  horas = Array.from(Array(24).keys());

  constructor(
    private fb: FormBuilder,
    private personaService: PersonaService,
    private solicitudesService: SolicitudesService,
    private snackBar: MatSnackBar,
    private router: Router,
    private route: ActivatedRoute (3)
  ) {}

  ngOnInit(): void {
    this.modoEditar = false; (4)
    this.id = this.route.snapshot.params['id']; (5)
    if (this.id) { (6)
      this.solicitudesService.findOne(this.id).subscribe((res) => { (7)
        this.modoEditar = true; (8)
        this.formHorario.patchValue({ ...res }); (9)
      });
    }
  }

  isNotValidField(field: string) {
    return (
      this.formHorario.controls[field].errors &&
      this.formHorario.controls[field].touched
    );
  }

  isValidForm() {
    return this.formHorario.valid;
  }

  buscarPersona(email: any) {
    return lastValueFrom(this.personaService.findOne(email));
  }

  async actualizarCamposPersona() {
    const email = this.formHorario.controls['email'].value;

    this.persona = (await this.buscarPersona(email))[0];

    if (this.persona) {
      this.cargos = [...this.persona.cargo];

      this.formHorario.patchValue(this.persona);

      this.persona.cargo = this.formHorario.controls['cargo'].value;

      return;
    }

    this.clearPersonalData();
  }

  clearPersonalData() {
    this.formHorario.reset();

    this.snackBar.open('Persona no disponible', '', {
      duration: 1500,
    });
  }

  save() {
    let solicitud = this.formHorario.getRawValue();

    if (this.modoEditar) { (10)
      (solicitud.id = this.id), (11)
        this.solicitudesService.update(solicitud).subscribe(() => { (12)
          this.snackBar.open('Solicitud actualizada', '', {
            duration: 1500,
          });

          this.router.navigate(['/solicitudes/consultar']);
        });
    } else { (13)
      this.solicitudesService.save(solicitud).subscribe((res) => {
        this.snackBar.open('Solicitud guardada', '', {
          duration: 1500,
        });

        this.router.navigate(['/solicitudes/consultar']);
      });
    }
  }
}
1 Variable para indicar que se está en modo Editar
2 Variable para almacenar el id de la solicitud
3 Permite acceder a componentes de la URL (necesitamos acceder al id de la solicitud a editar)
4 Configurar el modo Crear como el modo predeterminado (editar es falso)
5 Asignar a la variable id el componente id de la ruta de la URL. Las partes de la ruta están definidas en el módulo de rutas solicitudes-routing.module.ts
6 Si hay id en la URL (es decir, se está en modo Editar)
7 Buscar la solicitud mediante su id
8 Activar el modo Editar
9 Pegarle a cada elemento del formulario los elementos de la solicitud leída
10 Bloque a ejecutar cuando se está en modo Editar
11 ???
12 Llamada al método de actualizar pasándole la solicitud como parámetro
13 Bloque a ejecutar cuando se está en modo Crear

Para asignar cada elemento de una solicitud a su elemento correspondiente del formulario se puede hacer de uno en uno, pero resulta muy pesado y es propenso a errores. En cambio, si los nombres de los elementos del formulario coinciden con los nombre de los elementos de la solicitud leída de la base de datos, se puede hacer con una sola línea mediante patchValue (un motivo más para mantener la consistencia en la denominación de variables)

Ahora tocaría modificar la parte de la presentación del formulario de Crear solicitud para que sirva también para actualizar. Básicamente se trata de cambiar el texto del título de la página y el del botón. El método al que se llama para guardar sigue siendo el mismo, ya que el propio método actualizará o insertará en función del valor de modoEditar, que recordemos que se determinaba a partir de lo que indicase la ruta.

Archivo main/solicitudes/pages/crear/crear.component.html:

<div fxFlexAlign="center" fxLayoutAlign="center center">
  <form [formGroup]="formHorario" autocomplete="off">
    <h1>{{ modoEditar ? "Editar" : "Crear" }} solicitud</h1> (1)
    <hr />

....

        <div fxLayout="row" fxLayoutAlign="end">
          <button
            mat-stroked-button
            color="primary"
            (click)="save()"
            [disabled]="!isValidForm()"
          >
            {{ modoEditar ? "Actualizar" : "Crear" }} (2)
          </button>
        </div>
      </mat-card>
    </div>
  </form>
</div>
1 Añadir Editar o Crear a la cabecera en función del valor de modoEditar
2 Configurar el texto del botón con Actualizar o Crear en función del valor de modoEditar
La seguridad ha de estar en el backend

Para que filtre si alguien está intentando modificar una solicitud que no le corresponda …​.

12.4. Actualizar el componente del listado de solicitudes

Para terminar, tendremos que añadir el botón de modificar al listado de solicitudes y programar el método que se ejecutará al pulsar el botón. Comencemos programando la llamada al método de actualización. Básicamente, lo que hará será redirigir a la ruta donde está el componente de editar/crear.

Archivo main/pages/solicitudes/consultar/consultar.component.ts:

import { Component, OnInit } from '@angular/core';
import { SolicitudesService } from '../../../../services/solicitudes.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { DialogoEliminarComponent } from '../../components/dialogo-eliminar/dialogo-eliminar.component';
import { Router } from '@angular/router'; (1)

@Component({
  selector: 'app-consultar',
  templateUrl: './consultar.component.html',
  styleUrls: ['./consultar.component.css'],
})
export class ConsultarComponent implements OnInit {
  solicitudes: any = [];

  dataSource: any = [];
  displayedColumns = ['nombre', 'cargo', 'unidad', 'telefono', 'acciones'];

  constructor(
    private solicitudesService: SolicitudesService,
    private snackBar: MatSnackBar,
    public dialog: MatDialog,
    private router: Router (2)
  ) {}
  ngOnInit(): void {
    this.solicitudesService.findAll().subscribe((res) => {
      this.dataSource = res;
      if (this.dataSource.length == 0) {
        this.snackBar.open('No hay solicitudes', '', {
          duration: 1500,
        });
      }
    });
  }

  editar(element: any) { (3)
    this.router.navigate([`/solicitudes/editar/${element.id}`]); (4)
  }

  eliminar(element: any) {
    this.dialog.open(DialogoEliminarComponent, {
      data: element,
    });
  }
}
1 Importación de `Router
2 Inyección del Router
3 Método llamado al pulsar el botón de Editar
4 Redirigir a la ruta de editar solicitudes

Finalizaremos añadiendo un botón de Actualización de solicitudes en la pantalla de Consultar solicitudes. El botón llamará al método de actualización que acabamos de crear.

Archivo main/pages/solicitudes/consultar/consultar.component.html:

<div fxLayout="column" fxLayoutAlign="center center">
  <h1>Listado de solicitudes</h1>
  <div *ngIf="dataSource.length > 0">
    <hr />
    <mat-card>
      <table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
        <ng-container matColumnDef="nombre">
          <th mat-header-cell *matHeaderCellDef>Nombre</th>
          <td mat-cell *matCellDef="let element">{{ element.nombre }}</td>
        </ng-container>

        <ng-container matColumnDef="cargo">
          <th mat-header-cell *matHeaderCellDef>Cargo</th>
          <td mat-cell *matCellDef="let element">{{ element.cargo }}</td>
        </ng-container>

        <ng-container matColumnDef="unidad">
          <th mat-header-cell *matHeaderCellDef>Unidad</th>
          <td mat-cell *matCellDef="let element">{{ element.unidad }}</td>
        </ng-container>

        <ng-container matColumnDef="telefono">
          <th mat-header-cell *matHeaderCellDef>Teléfono</th>
          <td mat-cell *matCellDef="let element">{{ element.telefono }}</td>
        </ng-container>

        <!-- Columna de accciones -->
        <ng-container matColumnDef="acciones">
          <th mat-header-cell *matHeaderCellDef>Acciones</th>
          <td mat-cell *matCellDef="let element">
            <button (1)
              mat-stroked-button
              color="primary"
              (click)="editar(element)" (2)
            >
              <mat-icon>edit</mat-icon>
              Editar
            </button>
            <button
              mat-stroked-button
              color="primary"
              (click)="eliminar(element)"
            >
              <mat-icon>delete</mat-icon>
              Eliminar
            </button>
          </td>
        </ng-container>

        <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
        <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
      </table>
    </mat-card>
  </div>
</div>
1 Botón de modificación
2 Llamada al método de modificación pasándole la solicitud a modificar

Veamos el resultado en funcionamiento. Partimos de una pantalla con un conjunto de solicitudes. Al lado de cada solicitud aparecerá un botón para poder modificarla.

SolicitudesAntesDeModificar

Si pulsamos el botón Editar de la solicitud a modificar nos redirigirá a la pantalla de edición de solicitudes. En la cabecera aparecerá Editar solicitud ya que se ha entrado al componente en modo Editar. Recordemos que se trata del componente de Crear solicitud que hemos modificado para que sirva tanto para crear como para modificar solicitudes.

EditarSolicitud

Aprovecharemos y haremos una modificación en el cargo con el realiza la solicitud. Vemos que también aparece modificado el texto del botón, que ahora es Editar. Si pulsamos el botón nos llevará al listado de solicitudes donde aparecerá la solicitud modificada.

SolicitudModificada

Si ahora seleccionáramos la opción de crear solicitud veríamos que se muestra correctamente la pantalla de Crear. Ahora la cabecera y el botón son los correspondientes a la creación de solicitudes.

Anexo I. Creación de un servidor JSON para datos de prueba

Con el fin de poder simular el funcionamiento de servicios de backend sin necesidad de montar un backend y su complejidad asociada, para desarrollar la aplicación en Angular podemos usar algo con menor funcionalidad pero que nos permita realizar nuestras operaciones CRUD básicas.

JSON-server nos ofrece la posibilidad de tener de forma muy sencilla un prototipo de API REST totalmente funcional sin necesidad de programar nada.

Se instala de forma sencilla en nuestro equipo de trabajo con

$ npm install -g json-server

A continuación, hay que crear un archivo JSON con los datos que va a manejar la API. Después iniciamos JSON Server con

$ json-server --watch db.json

Incluiremos un archivo db.json en nuestro proyecto para que contemos con un conjunto inicial de datos.

{
  "personas": [
    {
      "email": "mtorres@ual.es",
      "nombre": "Manuel Torres Gil",
      "telefono": "84030",
      "unidad": "Departamento de Informática",
      "cargo": [
        "Profesor Titular de Universidad",
        "Director de Secretariado de Innovación Tecnológica"
      ],
      "docente": true
    },
    {
      "email": "ggf906@ual.es",
      "nombre": "Francisco José García García",
      "telefono": "N/D",
      "unidad": "STIC",
      "cargo": [
        "Gestor Informática"
      ],
      "docente": false
    }
  ],
  "espacios": [
    {
      "edificio": "Aulario I",
      "aula": "Aula 2",
      "fecha": "07/02/2022",
      "reservas": [
        {
          "hora": "09:00 - 12:00",
          "descripcion": "GRUPO A",
          "asignatura": "( 12103230 ) - Lengua Clásica: Latín",
          "profesor": ""
        }
      ]
    },
    {
      "edificio": "Aulario I",
      "aula": "Aula 1",
      "fecha": "07/02/2022",
      "reservas": [
        {
          "hora": "09:00 - 12:00",
          "descripcion": "GRUPO UNICO",
          "asignatura": "( 67104216 ) - Gestión Integral de la Imagen",
          "profesor": ""
        }
      ]
    },
    {
      "edificio": "Aulario II",
      "aula": "Aula 1",
      "fecha": "07/02/2022",
      "reservas": [
        {
          "hora": "09:00 - 12:00",
          "descripcion": "GRUPO A",
          "asignatura": "( 31103209 ) - Lexicología y Semántica Inglesas",
          "profesor": ""
        },
        {
          "hora": "12:00 - 15:00",
          "descripcion": "GRUPO A",
          "asignatura": "( 12104226 ) - Historia de la Lengua Española II",
          "profesor": ""
        },
        {
          "hora": "16:00 - 19:00",
          "descripcion": "GRUPO UNICO",
          "asignatura": "( 40153329 ) - Teoría de Códigos y Criptografía",
          "profesor": ""
        }
      ]
    }
  ],
  "solicitudes": []
}

El puerto predeterminado en que se ofrece la API REST es el 3000. Así, tendríamos una API REST funcional en:

http://localhost:3000/personas
http://localhost:3000/espacios
http://localhost:3000/solicitudes

Para iniciar JSON Server en otro puerto, pasamos al final el parámetro --port seguido de un número de puerto.

$ json-server --watch db.json --port 3002

En este caso tendríamos la API REST funcional en

http://localhost:3002/personas
http://localhost:3002/espacios
http://localhost:3002/solicitudes

Anexo II. Prototipo de la aplicación

A continuación se muestra el prototipo de la aplicación. Se muestran las pantallas, operaciones y comandos que se pueden realizar, así como la conexión entre pantallas.

espaciosApp