diff --git a/site/index.html b/site/index.html index c5498c3..647d169 100644 --- a/site/index.html +++ b/site/index.html @@ -1519,6 +1519,7 @@
Si est\u00e1s leyendo esto es porque tienes mucha fuerza de voluntad y unas enormes ganas de aprender a desarrollar con el stack tecnol\u00f3gico de CCA (Java Spring Boot, Nodejs, Angular, React) o porque te han mandando hacer este tutorial en tu etapa de formaci\u00f3n. En cualquier caso, te agradecemos el esfuerzo que est\u00e1s haciendo y te deseamos suerte .
Por favor, si detectas que hay algo incorrecto en el tutorial, que no funciona o que est\u00e1 mal escrito, contacta con nosotros para que podamos solventarlo para futuras lecturas. Escr\u00edbenos un issue aqu\u00ed.
"},{"location":"#que-vamos-a-hacer","title":"\u00bfQue vamos a hacer?","text":"Durante este tutorial, vamos a crear una aplicaci\u00f3n web paso a paso con Spring Boot o Nodejs para la parte servidora y con Angular o React para la parte frontal. Intentar\u00e9 comentar todo lo m\u00e1s detallado posible, pero si echas en falta alguna explicaci\u00f3n por favor, escr\u00edbenos un issue aqu\u00ed para que podamos a\u00f1adirla.
"},{"location":"#como-lo-vamos-a-hacer","title":"\u00bfComo lo vamos a hacer?","text":"En primer lugar te comentar\u00e9 brevemente las herramientas que usaremos en el tutorial y la forma de instalarlas (altamente recomendado). Luego veremos un vistazo general de lo que vamos a construir para que tengas un contexto general de la aplicaci\u00f3n. Y por \u00faltimo desarrollaremos paso a paso el backend y el frontend de la aplicaci\u00f3n.
Durante todo el tutorial intentar\u00e9 dar unas pautas y consejos de buenas pr\u00e1cticas que todos deber\u00edamos adoptar, en la medida de lo posible, para homogeneizar el desarrollo de todos los proyectos.
Adem\u00e1s para cada uno de los cap\u00edtulos que lo requieran, voy a desdoblar el tutorial por cada una de las tecnolog\u00edas disponibles para que vayas construyendo con la que m\u00e1s c\u00f3modo te sientas.
As\u00ed que antes de empezar debes elegir bien con que tecnolog\u00edas vas a comenzar de las que tengo disponibles. Puedes volver a este tutorial m\u00e1s adelante por si he ido a\u00f1adiendo nuevas tecnolog\u00edas.
Elige UNA tecnolog\u00eda de backend y UNA tecnolog\u00eda de frontend y completa el tutorial con esas dos tecnolog\u00edas. No mezcles ni hagas todas las tecnolog\u00edas a la vez ya que si no, te vas a volver loco.
"},{"location":"#hay-pre-requisitos","title":"\u00bfHay pre-requisitos?","text":"No es obligado tener ning\u00fan conocimiento previo, pero es altamente recomendable que al menos conozcas lo b\u00e1sico de las tecnolog\u00edas que vamos a ver en el tutorial. Si no tienes ni idea, ni has oido hablar de las tecnolog\u00edas que has seleccionado para el tutorial, te sugiero que visites los itinerarios formativos y realices los cursos de nivel Esencial
. De momento tenemos estos itinerarios:
Una vez hayas hecho los cursos esenciales, ya puedes volver y continuar con este tutorial. Repito que no es obligado, si ya tienes conocimientos previos de las tecnolog\u00edas no es necesario que hagas los cursos. Cuando termines el tutorial, ya puedes realizar el resto de cursos de otros niveles.
"},{"location":"#y-luego-que","title":"\u00bfY luego qu\u00e9?","text":"Pues al final del tutorial, expondremos unos ejercicios pr\u00e1cticos para que los resuelvas tu mismo, aplicando los conocimientos adquiridos en el tutorial. Para ver si has comprendido correctamente todo lo aqu\u00ed descrito.
No te preocupes, no es un examen
"},{"location":"#recomendaciones","title":"Recomendaciones","text":"Te recomiendo que leas todo el tutorial, que no te saltes ning\u00fan punto y si se hace referencia a los anexos, que los visites y los leas tambi\u00e9n. Si tan solo copias y pegas, no ser\u00e1s capaz de hacer el \u00faltimo ejercicio por ti mismo. Debes leer y comprender lo que se est\u00e1 haciendo.
Adem\u00e1s, los anexos est\u00e1n ah\u00ed por algo, sirven para completar informaci\u00f3n y para que conozcas los motivos por los que estamos programando as\u00ed el tutorial. Por favor, \u00e9chales un ojo tambi\u00e9n cuando te lo indique.
"},{"location":"#por-ultimo-no-te-olvides","title":"Por \u00faltimo, \u00a1no te olvides!","text":"Cuando lo tengas todo listo, por favor no te olvides de subir los proyectos a alg\u00fan repositorio Github propio y av\u00edsanos para que podamos echarle un ojo y darte sugerencias y feedback .
"},{"location":"exercise/","title":"Ahora hazlo tu!","text":"Ahora vamos a ver si has comprendido bien el tutorial. Voy a poner dos ejercicios uno m\u00e1s sencillo que el otro para ver si eres capaz de llevarlos a cabo. \u00a1Vamos alla, mucha suerte!
Nuestro amigo Ernesto Esvida ya tiene disponible su web para gestionar su cat\u00e1logo de juegos, autores y categor\u00edas, pero todav\u00eda le falta un poco m\u00e1s para poder hacer buen uso de su ludoteca. As\u00ed que nos ha pedido dos funcionalidades extra.
"},{"location":"exercise/#gestion-de-clientes","title":"Gesti\u00f3n de clientes","text":""},{"location":"exercise/#requisitos","title":"Requisitos","text":"Por un lado necesita poder tener una base de datos de sus clientes. Para ello nos ha pedido que si podemos crearle una pantalla de CRUD sencilla, al igual que hicimos con las categor\u00edas donde \u00e9l pueda dar de alta a sus clientes.
Nos ha pasado un esquema muy sencillo de lo que quiere, tan solo quiere guardar un listado de los nombres de sus clientes para tenerlos fichados, y nos ha hecho un par de pantallas sencillas muy similares a Categor\u00edas.
Un listado sin filtros de ning\u00fan tipo ni paginaci\u00f3n.
Un formulario de edici\u00f3n / alta, cuyo \u00fanico dato editable sea el nombre. Adem\u00e1s, la \u00fanica restricci\u00f3n que nos ha pedido es que NO podamos dar de alta a un cliente con el mismo nombre que otro existente. As\u00ed que deberemos comprobar el nombre, antes de guardar el cliente.
"},{"location":"exercise/#consejos","title":"Consejos","text":"Para empezar te dar\u00e9 unos consejos:
Por otro lado, quiere hacer uso de su cat\u00e1logo de juegos y de sus clientes, y quiere saber que juegos ha prestado a cada cliente. Para ello nos ha pedido una p\u00e1gina bastante compleja donde se podr\u00e1 consultar diferente informaci\u00f3n y se permitir\u00e1 realizar el pr\u00e9stamo de los juegos.
Nos ha pasado el siguiente boceto y requisitos:
La pantalla tendr\u00e1 dos zonas:
Al pulsar el bot\u00f3n de Nuevo pr\u00e9stamo
se abrir\u00e1 una pantalla donde se podr\u00e1 ingresar la siguiente informaci\u00f3n, toda ella obligatoria:
Las validaciones son sencillas aunque laboriosas:
Para empezar te dar\u00e9 unos consejos:
Page
.Specifications
son muy \u00fatiles, pero en este caso deber\u00e1s implementar otro tipo de operaciones, no te sirve solo con la operaci\u00f3n de igualdad :
, que ya vimos en el tutorial.Si has llegado a este punto es porque ya tienes terminado el tutorial. Por favor no te olvides de subir los proyectos a alg\u00fan repositorio Github propio (puedes revisar el anexo Tutorial b\u00e1sico de Git) y av\u00edsarnos para que podamos echarle un ojo y darte sugerencias y feedback .
"},{"location":"thanks/","title":"Agradecimientos!","text":"Antes de empezar quer\u00edamos dar las gracias a todos los que hab\u00e9is participado de manera directa o indirecta en la elaboraci\u00f3n de este tutorial, y a todos aquellos que lo hab\u00e9is sufrido haciendolo.
De verdad
G R A C I A S\n
"},{"location":"thanks/#colaboradores","title":"Colaboradores","text":"Menci\u00f3n especial a las personas que han participado en el tutorial ya sea como testers, como promotores o como desarrolladores, por orden temporal de colaboraci\u00f3n:
Nuestro amigo Ernesto Esvida es muy aficionado a los juegos de mesa y desde muy peque\u00f1o ha ido coleccionando muchos juegos. Hasta tal punto que ha decidido regentar una Ludoteca.
Como la colecci\u00f3n de juegos era suya personal, toda la informaci\u00f3n del cat\u00e1logo de juegos la ten\u00eda perfectamente clasificado en fichas de cart\u00f3n. Pero ahora que va abrir su propio negocio, necesita digitalizar esa informaci\u00f3n y hacerla m\u00e1s accesible.
Como es un buen amigo de la infancia, hemos decidido ayudar a Ernesto y colaborar haciendo una peque\u00f1a aplicaci\u00f3n web que le sirva de cat\u00e1logo de juegos. Es m\u00e1s o menos el mismo sistema que estaba utilizando, pero esta vez en digital.
Por cierto, la Ludoteca al final se va a llamar Ludoteca T\u00e1n.
Info
Las im\u00e1genes que aparecen a continuaci\u00f3n son mockups o dise\u00f1os de alambre de las pantallas que vamos a desarrollar durante el tutorial. No quiere decir que el estilo final de las pantallas deba ser as\u00ed, ni mucho menos. Es simplemente una forma sencilla de ejemplificar como debe quedar m\u00e1s o menos una pantalla.
"},{"location":"usecases/#estructura-de-un-proyecto-web","title":"Estructura de un proyecto Web","text":"En todas las aplicaciones web modernas y los proyectos en los que trabajamos se pueden diferenciar, de forma general, tres grandes bloques funcionales, como se muestra en la imagen inferior.
El funcionamiento es muy sencillo y difiere de las aplicaciones instalables que se ejecuta todo en una misma m\u00e1quina o servidor.
As\u00ed pues el flujo normal de una aplicaci\u00f3n ser\u00eda el siguiente:
Dicho esto, por lo general necesitaremos un m\u00ednimo de dos proyectos para desarrollar una aplicaci\u00f3n:
Por un lado tendremos un proyecto Frontend que se ejecutar\u00e1 en un servidor web de ficheros est\u00e1ticos, tipo Apache. Este proyecto ser\u00e1 c\u00f3digo javascript, css y html, que se renderizar\u00e1 en el navegador Web y que realizar\u00e1 ciertas operaciones sencillas y validaciones en local y llamadas a nuestro servidor backend para ejecutar las operaciones de negocio.
Por otro lado tendremos un proyecto Backend que se ejecutar\u00e1 en un servidor de aplicaciones, tipo Tomcat o Node. Este proyecto tendr\u00e1 la l\u00f3gica de negocio de las operaciones, el acceso a los datos de la BBDD y cualquier integraci\u00f3n con servicios de terceros. La forma de exponer estas operaciones de negocio ser\u00e1 mediante endpoints de acceso, en concreto llamadas tipo REST.
Pueden haber otros tipos de proyectos dentro de la aplicaci\u00f3n, sobretodo si est\u00e1n basados en microservicios o tienen componentes batch, pero estos proyectos no vamos a verlos en el tutorial.
A partir de ahora, para que sea m\u00e1s sencillo acceder al tutorial, diferenciaremos las tecnolog\u00edas en el men\u00fa mediante los siguientes colores:
Consejo
Como norma cada uno de los proyectos que componen la aplicaci\u00f3n, deber\u00eda estar conectado a un repositorio de c\u00f3digo diferente para poder evolucionar y trabajar con cada uno de ellos de forma aislada sin afectar a los dem\u00e1s. As\u00ed adem\u00e1s podemos tener equipos aislados que trabajen con cada uno de los proyectos por separado.
Info
Durante todo el tutorial, voy a intentar separar la construcci\u00f3n del proyecto Frontend de la construcci\u00f3n del proyecto Backend. Elige una tecnolog\u00eda para cada una de las capas y utiliza siempre la misma en todos los apartados del tutorial.
"},{"location":"usecases/#diseno-de-bd","title":"** Dise\u00f1o de BD **","text":"Para el proyecto que vamos a crear vamos a modelizar y gestionar 3 entidades: CATEGORY
, AUTHOR
y GAME
.
La entidad CATEGORY
estar\u00e1 compuesta por los siguientes campos:
GAME
)La entidad AUTHOR
estar\u00e1 compuesta por los siguientes campos:
GAME
)Para la entidad GAME
, Ernesto nos ha comentado que la informaci\u00f3n que est\u00e1 guardando en sus fichas es la siguiente:
Comenzaremos con un caso b\u00e1sico que cumpla las siguientes premisas: un juego pertenece a una categor\u00eda y ha sido creado por un \u00fanico autor.
Modelando este contexto quedar\u00eda algo similar a esto:
"},{"location":"usecases/#diseno-de-pantallas","title":"** Dise\u00f1o de pantallas **","text":"Deber\u00edamos construir tres pantallas de mantenimiento CRUD (Create, Read, Update, Delete) y una pantalla de Login general para activar las acciones de administrador. M\u00e1s o menos las pantallas deber\u00edan quedar as\u00ed:
"},{"location":"usecases/#listado-de-categorias","title":"Listado de categor\u00edas","text":""},{"location":"usecases/#edicion-de-categoria","title":"Edici\u00f3n de categor\u00eda","text":""},{"location":"usecases/#listado-de-autores","title":"Listado de autores","text":""},{"location":"usecases/#edicion-de-autor","title":"Edici\u00f3n de autor","text":""},{"location":"usecases/#listado-de-juegos","title":"Listado de juegos","text":""},{"location":"usecases/#edicion-de-juego","title":"Edici\u00f3n de juego","text":""},{"location":"usecases/#diseno-funcional","title":"** Dise\u00f1o funcional **","text":"Por \u00faltimo vamos a definir un poco la funcionalidad b\u00e1sica que Ernesto necesita para iniciar su negocio.
"},{"location":"usecases/#aspectos-generales","title":"Aspectos generales","text":"usuario b\u00e1sico
es el usuario an\u00f3nimo que accede a la web sin registrar. Solo tiene permisos para mostrar listados ** usuario administrador
es el usuario que se registra en la aplicaci\u00f3n. Puede realizar las operaciones de alta, edici\u00f3n y borradoPor defecto cuando entras en la aplicaci\u00f3n tendr\u00e1s los privilegios de un usuario b\u00e1sico
hasta que el usuario haga un login correcto con el usuario / password admin
/ admin
. En ese momento pasara a ser un usuario administrador
y podr\u00e1 realizar operaciones de alta, baja y modificaci\u00f3n.
La estructura general de la aplicaci\u00f3n ser\u00e1:
Sign in
Al pulsar sobre la funcionalidad de Sign in
aparecer\u00e1 una ventana modal que preguntar\u00e1 usuario y password. Esto realizar\u00e1 una llamada al backend, donde se validar\u00e1 si el usuario es correcto.
sessionStorage
para futuras peticionesTodas las operaciones del backend que permitan crear, modificar o borrar datos, deber\u00e1n estar securizadas para que no puedan ser accedidas sin haberse autenticado previamente.
"},{"location":"usecases/#crud-de-categorias","title":"CRUD de Categor\u00edas","text":"Al acceder a esta pantalla se mostrar\u00e1 un listado de las categor\u00edas que tenemos en la BD. La tabla no tiene filtros, puesto que tiene muy pocos registros. Tampoco estar\u00e1 paginada.
En la tabla debe aparecer:
Debajo de la tabla aparecer\u00e1 un bot\u00f3n para crear nuevas categor\u00edas (solo en el caso de que el usuario tenga permisos).
Crear
Al pulsar el bot\u00f3n de crear se deber\u00e1 abrir una ventana modal con dos inputs:
Identificador
Nombre
Todos los datos obligatorios se deber\u00e1n comprobar que son v\u00e1lidos antes de guardarlo en BD. Dos botones en la parte inferior de la ventana permitir\u00e1n al usuario cerrar la ventana o guardar los datos en la BD.
Editar
Al pulsar el icono de editar se deber\u00e1 abrir una ventana modal utilizando el mismo componente que la ventana de Crear
pero con los dos campos rellenados con los datos de BD.
Borrar
Si el usuario pulsa el bot\u00f3n de borrar, se deber\u00e1 comprobar si esa categor\u00eda tiene alg\u00fan Juego
asociado. En caso de tenerlo se le informar\u00e1 al usuario de que dicha categor\u00eda no se puede eliminar por tener asociado un juego. En caso de no estar asociada, se le preguntar\u00e1 al usuario mediante un mensaje de confirmaci\u00f3n si desea eliminar la categor\u00eda. Solo en caso de que la respuesta sea afirmativa, se lanzar\u00e1 el borrado f\u00edsico de la categor\u00eda en BD.
Al acceder a esta pantalla se mostrar\u00e1 un listado de los autores que tenemos en la BD. La tabla no tiene filtros pero deber\u00e1 estar paginada en servidor.
En la tabla debe aparecer:
Debajo de la tabla aparecer\u00e1 un bot\u00f3n para crear nuevos autores (solo en el caso de que el usuario tenga permisos).
Crear
Al pulsar el bot\u00f3n de crear se deber\u00e1 abrir una ventana modal con tres inputs:
Identificador
Nombre
Nacionalidad
Todos los datos obligatorios se deber\u00e1n comprobar que son v\u00e1lidos antes de guardarlo en BD. Dos botones en la parte inferior de la ventana permitir\u00e1n al usuario cerrar la ventana o guardar los datos en la BD.
Editar
Al pulsar el icono de editar se deber\u00e1 abrir una ventana modal utilizando el mismo componente que la ventana de Crear
pero con los tres campos rellenados con los datos de BD.
Borrar
Si el usuario pulsa el bot\u00f3n de borrar, se deber\u00e1 comprobar si ese autor tiene alg\u00fan Juego
asociado. En caso de tenerlo se le informar\u00e1 al usuario de que dicho autor no se puede eliminar por tener asociado un juego. En caso de no estar asociado, se le preguntar\u00e1 al usuario mediante un mensaje de confirmaci\u00f3n si desea eliminar el autor. Solo en caso de que la respuesta sea afirmativa, se lanzar\u00e1 el borrado f\u00edsico de la categor\u00eda en BD.
Al acceder a esta pantalla se mostrar\u00e1 un listado de los juegos disponibles en el cat\u00e1logo de la BD. Esta tabla debe contener filtros en la parte superior, pero no debe estar paginada.
Se debe poder filtrar por:
contengan
el texto buscadoDos botones permitir\u00e1n realizar el filtrado de juegos (lanzando una nueva consulta a BD) o limpiar los filtros seleccionados (lanzando una consulta con los filtros vac\u00edos).
En la tabla debe aparecer a modo de fichas. No hace falta que sea exactamente igual a la maqueta, no es un requisito determinar un ancho general de ficha por lo que pueden caber 2,3 o x fichas en una misma fila, depender\u00e1 del programador. Pero todas las fichas deben tener el mismo ancho:
Los juegos no se pueden eliminar, pero si se puede editar si el usuario pulsa en alguna de las fichas (solo en el caso de que el usuario tenga permisos).
Debajo de la tabla aparecer\u00e1 un bot\u00f3n para crear nuevos juegos (solo en el caso de que el usuario tenga permisos).
Crear
Al pulsar el bot\u00f3n de crear se deber\u00e1 abrir una ventana modal con cinco inputs:
Identificador
T\u00edtulo
Edad
Categor\u00eda
Autor
Todos los datos obligatorios se deber\u00e1n comprobar que son v\u00e1lidos antes de guardarlo en BD. Dos botones en la parte inferior de la ventana permitir\u00e1n al usuario cerrar la ventana o guardar los datos en la BD.
Editar
Al pulsar en una de las fichas con un click simple, se deber\u00e1 abrir una ventana modal utilizando el mismo componente que la ventana de Crear
pero con los cinco campos rellenados con los datos de BD.
Cada vez se tiende m\u00e1s a utilizar repositorios de c\u00f3digo Git y, aunque no sea objeto de este tutorial Springboot-Angular, queremos hacer un resumen muy b\u00e1sico y sencillo de como utilizar Git.
En el mercado existen multitud de herramientas para gestionar repositorios Git, podemos utilizar cualquiera de ellas, aunque desde devonfw se recomienda utilizar Git SCM. Adem\u00e1s, existen tambi\u00e9n multitud de servidores de c\u00f3digo que implementan repositorios Git, como podr\u00edan ser GitHub, GitLab, Bitbucket, etc. Todos ellos trabajan de la misma forma, as\u00ed que este resumen servir\u00e1 para todos ellos.
Info
Este anexo muestra un resumen muy sencillo y b\u00e1sico de los comandos m\u00e1s comunes que se utilizan en Git. Para ver detalles m\u00e1s avanzados o un tutorial completo te recomiendo que leas la guia de Atlassian.
"},{"location":"appendix/git/#funcionamiento-basico","title":"Funcionamiento b\u00e1sico","text":"Existen dos conceptos en Git que debes tener muy claros: las ramas y los repositorios. Vamos a ver como funciona cada uno de ellos.
"},{"location":"appendix/git/#ramas","title":"Ramas","text":"Por un lado tenemos las ramas
de Git. El repositorio puede tener tantas ramas como se quiera, pero por lo general debe existir una rama maestra a menudo llamada develop o master, y luego muchas ramas con cada una de las funcionalidades desarrolladas.
Las ramas siempre se deben crear a partir de una rama (en el ejemplo llamaremos develop), con una foto concreta y determinada de esa rama. Esta rama deber\u00e1 tener un nombre que describa lo que va a contener esa rama (en el ejemplo feature/xxx). Y por lo general, esa rama se mergear\u00e1
con otra rama del repositorio, que puede ser la rama de origen o cualquier otra (en el ejemplo ser\u00e1 con la rama origen develop).
As\u00ed pues, podemos tener algo as\u00ed:
Las acciones de crear ramas y mergear ramas est\u00e1n explicadas m\u00e1s abajo. En este punto solo es necesario que seas conocedor de:
El otro concepto que debe queda claro, es el de repositorios. Por defecto, en Git, se trabaja con el repositorio local, en el que puedes crear ramas, modificar c\u00f3digo, mergear, etc. pero todos esos cambios que se hagan, ser\u00e1n todos en local, nadie m\u00e1s tendr\u00e1 acceso.
Tambi\u00e9n existe el repositorio remoto, tambi\u00e9n llamado origin
. Este repositorio es el que todos los integrantes del equipo utilizan como referencia. Existen acciones de Git que permite sincronizar los repositorios.
En este punto solo es necesario que seas conocedor de:
pull request
o merge request
(depende de la aplicaci\u00f3n usada para Git).En la Gu\u00eda r\u00e1pida puedes ver m\u00e1s detalle de estas acciones pero por lo general:
pull request
o merge request
contra la rama maestra que quieras modificar.A continuaci\u00f3n vamos a describir estos mismos conceptos y acciones que hemos visto, pero m\u00e1s en profundidad para que veas como trabaja internamente Git. No es necesario que leas este punto, aunque es recomendable.
"},{"location":"appendix/git/#estructuras-y-flujo-de-trabajo","title":"Estructuras y flujo de trabajo","text":"Lo primero que debes conocer de Git es su funcionamiento b\u00e1sico de flujo de trabajo. Tu repositorio local est\u00e1 compuesto por tres \"estructuras\" que contienen los archivos y los cambios de los ficheros del repositorio.
Existen operaciones que nos permiten a\u00f1adir o borrar ficheros dentro de cada una de las estructuras desde otra estructura.
As\u00ed pues, los comandos b\u00e1sicos dentro de nuestro repositorio local son los siguientes.
"},{"location":"appendix/git/#add-y-commmit","title":"add y commmit","text":"Puedes registrar los cambios realizados en tu working directory
y a\u00f1adirlos al staging area
usando el comando
git add <filename>\n
o si quieres a\u00f1adir todos los ficheros modificados git add .\n
Este es el primer paso en el flujo de trabajo b\u00e1sico. Una vez tenemos los cambios registrados en el staging area
podemos hacer un commit y persistirlos dentro del local repository
mediante el comando
git commit -m \"<Commit message>\"\n
A partir de ese momento, los ficheros modificados y a\u00f1adidos al local repository
se han persistido y se han a\u00f1adido a tu HEAD
, aunque todav\u00eda siguen estando el local, no lo has enviado a ning\u00fan repositorio remoto.
De la misma manera que se han a\u00f1adido ficheros a staging area
o a local repository
, podemos retirarlos de estas estructuras y volver a recuperar los ficheros que ten\u00edamos anteriormente en el working directory
. Por ejemplo, si nos hemos equivocado al incluir ficheros en un commit o simplemente queremos deshacer los cambios que hemos realizado bastar\u00eda con lanzar el comando
git reset --hard\n
o si queremos volver a un commit concreto git reset <COMMIT>\n
"},{"location":"appendix/git/#trabajo-con-ramas","title":"Trabajo con ramas","text":"Para complicarlo todo un poco m\u00e1s, el trabajo con git siempre se realiza mediante ramas. Estas ramas nos sirven para desarrollar funcionalidades aisladas unas de otras y poder hacer mezclas de c\u00f3digo de unas ramas a otras. Las ramas m\u00e1s comunes dentro de git suelen ser:
producci\u00f3n
.master
.merge
a la rama develop
.Siempre que trabajes con ramas debes tener en cuenta que al empezar tu desarrollo debes partir de una versi\u00f3n actualizada de la rama develop
, y al terminar tu desarrollo debes solicitar un merge
contra develop
, para que tu funcionalidad est\u00e9 incorporada en la rama de desarrollo.
Crear ramas en local es tan sencillo como ejecutar este comando:
git checkout -b <NOMBRE_RAMA>\n
Eso nos crear\u00e1 una rama con el nombre que le hayamos dicho y mover\u00e1 el Working Directory
a dicha rama.
Para cambiar de una rama a otra en local tan solo debemos ejecutar el comando:
git checkout <NOMBRE_RAMA>\n
La rama debe existir, sino se quejar\u00e1 de que no encuentra la rama. Este comando nos mover\u00e1 el Working Directory
a la rama que le hayamos indicado. Si tenemos cambios en el Staging Area
que no hayan sido movidos al Local Repository
NO nos permitir\u00e1 movernos a la rama ya que perder\u00edamos los cambios. Antes de poder movernos debemos resetear
los cambios o bien commitearlos
.
Hasta aqu\u00ed es todo m\u00e1s o menos sencillo, trabajamos con nuestro repositorio local, creamos ramas, commiteamos o reseteamos cambios de c\u00f3digo, pero todo esto lo hacemos en local. Ahora necesitamos que esos cambios se distribuyan y puedan leerlos el resto de integrantes de nuestro equipo.
Aqu\u00ed es donde entra en juego los repositorios remotos.
Aqu\u00ed debemos tener MUY en cuenta que el c\u00f3digo que vamos a publicar en remoto SOLO es posible publicarlo desde el Local Repository
. Es decir que para poder subir c\u00f3digo a remote antes debemos a\u00f1adirlo a Staging Area
y hacer un commit para persistirlo en el Local Repository
.
Antes de empezar a tocar c\u00f3digo del proyecto podemos crear un Local Repository
vac\u00edo o bien bajarnos un proyecto que ya exista en un Remote Repository
. Esta \u00faltima opci\u00f3n es la m\u00e1s normal.
Para bajarnos un proyecto desde remoto tan solo hay que ejecutar el comando:
git clone <REMOTE_URL>\n
Esto te crear\u00e1 una carpeta con el nombre del proyecto y dentro se descargar\u00e1 la estructura completa del repositorio y te mover\u00e1 al Working Directory
todo el c\u00f3digo de la rama por defecto para ese repositorio.
El env\u00edo de datos a un Remote Repository
tan solo es posible realizarlo desde Local Repository
(por lo que antes deber\u00e1s commitear cambios all\u00ed), y se debe ejecutar el comando:
git push origin\n
"},{"location":"appendix/git/#actualizar-y-fusionar","title":"actualizar y fusionar","text":"En ocasiones (bastante habitual) ser\u00e1 necesario descargarse los cambios de un Remote Repository
para poder trabajar con la \u00faltima versi\u00f3n. Para ello debemos ejecutar el comando:
git pull\n
El propio git
realizar\u00e1 la fusi\u00f3n local del c\u00f3digo remoto con el c\u00f3digo de tu Working Directory
. Pero en ocasiones, si se ha modificado el mismo fichero en remoto y en local, se puede producir un Conflicto. No pasa nada, tan solo tendr\u00e1s que abrir dicho fichero en conflicto y resolverlo manualmente dejando el c\u00f3digo mezclado correcto.
Tambi\u00e9n es posible que el c\u00f3digo que queramos actualizar est\u00e9 en otra rama, si lo que necesitamos es fusionar el c\u00f3digo de otra rama con la rama actual, nos situaremos en la rama destino y ejecutaremos el comando:
git merge <RAMA_ORIGEN>\n
Esto har\u00e1 lo mismo que un pull en local y fusionar\u00e1 el c\u00f3digo de una rama en otra. Tambi\u00e9n es posible que se produzcan conflictos que deber\u00e1s resolver de forma manual.
"},{"location":"appendix/git/#merge-request","title":"Merge Request","text":"Ya por \u00faltimo, como estamos trabajando con ramas, lo \u00fanico que hacemos es subir y bajar ramas, pero en alg\u00fan momento alguien debe fusionar el contenido de una rama en la rama develop
, release
o master
, que son las ramas principales.
Se podr\u00eda directamente usar el comando merge para eso, pero en la mayor\u00eda de los repositorios no esta permitido subir el c\u00f3digo de una rama principal, por lo que no podr\u00e1s hacer un merge y subirlo. Para eso existe otra opci\u00f3n que es la de Merge Request
.
Esta opci\u00f3n permite a un usuario solicitar que otro usuario verifique y valide que el c\u00f3digo de su rama es correcto y lo puede fusionar en Remote Repository
con una rama principal. Al ser una operaci\u00f3n delicada, tan solo es posible ejecutarla a trav\u00e9s de la web del repositorio git.
Por lo general existir\u00e1 una opci\u00f3n / bot\u00f3n que permitir\u00e1 hacer un Merge Request
con una rama origen y una rama destino (generalmente una de las principales). A esa petici\u00f3n se le asignar\u00e1 un validador y se enviar\u00e1. El usuario validador verificar\u00e1 si es correcto o no y validar\u00e1 o rechazar\u00e1 la petici\u00f3n. En caso de validarla se fusionar\u00e1 autom\u00e1ticamente en remoto y todos los usuarios podr\u00e1n descargar los nuevos cambios desde la rama.
\u00a1Cuidado!
Siempre antes de solicitar un Merge Request
debes comprobar que tienes actualizada la rama comparandola con la rama remota que queremos mergear, en nuestro ejemplo ser\u00e1 develop
.
Para actualizarla tu rama hay que seguir tres pasos muy sencillos:
develop
y descargarnos los cambios del repositorio remoto (git pull)develop
hacia nuestra rama (git merge develop)Merge Request
Los pasos b\u00e1sicos de utilizaci\u00f3n de git son sencillos.
git clone\n o \ngit init\n
develop
) git checkout -b <rama>\n
git add .\ngit commit -m \"<Commit message>\"\n
develop
. Por tanto tenemos que cambiar a la rama develop
, descargarnos los cambios del repositorio remoto, volver a cambiar a nuestra rama y ejecutar un merge desde develop
hacia nuestra rama, ejecutando estos comandos git checkout develop\ngit pull\ngit checkout <rama>\ngit merge develop\n
git push --set-upstream origin <rama>\n
merge request
contra develop
. Para que sea validado y aprobado por otro compa\u00f1ero del equipo.merge request
antes de que haya sido aprobado, nos basta con repetir los pasos anteriores git add .\ngit commit -m \"<Commit message>\"\ngit push origin\n
develop
y adem\u00e1s debe estar actualizada git pull
.Este anexo no pretende explicar el funcionamiento interno de Spring Data, simplemente conocer un poco como utilizarlo y algunos peque\u00f1os tips que pueden ser interesantes.
"},{"location":"appendix/jpa/#funcionamiento-basico","title":"Funcionamiento b\u00e1sico","text":"Lo primero que deber\u00edas tener claro, es que hagas lo que hagas, al final todo termina lanzando una query nativa sobre la BBDD. Da igual que uses cualquier tipo de acelerador (luego veremos alguno), ya que al final Spring Data termina convirtiendo lo que hayas programado en una query nativa.
Cuanta m\u00e1s informaci\u00f3n le proporciones a Spring Data, tendr\u00e1s m\u00e1s control sobre la query final, pero m\u00e1s dificil ser\u00e1 de mantener. Lo mejor es utilizar, siempre que se pueda, todos los automatismos y automagias posibles y dejar que Spring haga su faena. Habr\u00e1 ocasiones en que esto no nos sirva, en ese momento tendremos que decidir si queremos bajar el nivel de implementaci\u00f3n o queremos utilizar otra alternativa como procesos por streams.
"},{"location":"appendix/jpa/#derived-query-methods","title":"Derived Query Methods","text":"Para la realizaci\u00f3n de consultas a la base de datos, Spring Data nos ofrece un sencillo mecanismo que consiste en crear definiciones de m\u00e9todos con una sintaxis especifica, para luego traducirlas autom\u00e1ticamente a consultas nativas, por parte de Spring Data.
Esto es muy \u00fatil, ya que convierte a la aplicaci\u00f3n en agn\u00f3sticos de la tecnolog\u00eda de BBDD utilizada y podemos migrar con facilidad entre las muchas soluciones disponibles en el mercado, delegando esta tarea en Spring.
Esta es la opci\u00f3n m\u00e1s indicada en la mayor\u00eda de los casos, siempre que puedas deber\u00edas utilizar esta forma de realizar las consultas. Como parte negativa, en algunos casos en consultas m\u00e1s complejas la definici\u00f3n de los m\u00e9todos puede extenderse demasiado dificultando la lectura del c\u00f3digo.
De esto tenemos alg\u00fan ejemplo por el tutorial, en el repositorio de GameRepository.
Siguiendo el ejemplo del tutorial, si tuvieramos que recuperar los Game
por el nombre del juego, se podr\u00eda crear un m\u00e9todo en el GameRepository
de esta forma:
List<Game> findByName(String name);\n
Spring Data entender\u00eda que quieres recuperar un listado de Game
que est\u00e1n filtrados por su propiedad Name
y generar\u00eda la consulta SQL de forma autom\u00e1tica, sin tener que implementar nada.
Se pueden contruir muchos m\u00e9todos diferentes, te recomiendo que leas un peque\u00f1o tutorial de Baeldung y profundices con la documentaci\u00f3n oficial donde podr\u00e1s ver todas las opciones.
"},{"location":"appendix/jpa/#anotacion-query","title":"Anotaci\u00f3n @Query","text":"Otra forma de realizar consultas, esta vez menos autom\u00e1tica y m\u00e1s cercana a SQL, es la anotaci\u00f3n @Query.
Existen dos opciones a la hora de usar la anotaci\u00f3n @Query
. Esta anotaci\u00f3n ya la hemos usado en el tutorial, dentro del GameRepository.
En primer lugar tenemos las consultas JPQL. Estas guardan un parecido con el lenguaje SQL pero al igual que en el caso anterior, son traducidas por Spring Data a la consulta final nativa. Su uso no est\u00e1 recomendado ya que estamos a\u00f1adiendo un nivel de concreci\u00f3n y por tanto estamos aumentando la complejidad del c\u00f3digo. Aun as\u00ed, es otra forma de generar consultas.
Por otra parte, tambi\u00e9n es posible generar consultas nativas directamente dentro de esta anotaci\u00f3n interactuando de forma directa con la base de datos. Esta pr\u00e1ctica es altamente desaconsejable ya que crea acoplamientos con la tecnolog\u00eda de la BBDD utilizada y es una fuente de errores.
Puedes ver m\u00e1s informaci\u00f3n de esta anotaci\u00f3n desde este peque\u00f1o tutorial de Baeldung.
"},{"location":"appendix/jpa/#acelerando-las-consultas","title":"Acelerando las consultas","text":"En muchas ocasiones necesitamos obtener informaci\u00f3n que no est\u00e1 en una \u00fanica tabla por motivos de dise\u00f1o de la base de datos. Debemos plasmar esta casu\u00edstica con cuidado a nuestro modelo relacional para obtener resultados \u00f3ptimos en cuanto al rendimiento.
Para ilustrar el caso vamos a recuperar los objetos utilizados en el tutorial Author
, Gategory
y Game
. Si recuerdas, tenemos que un Game
tiene asociado un Author
y tiene asociada una Gategory
.
Cuando utilizamos el m\u00e9todo de filtrado find
que construimos en el GameRepository
, vemos que Spring Data traduce la @Query
que hab\u00edamos dise\u00f1ado en una query SQL para recuperar los juegos.
@Query(\"select g from Game g where (:title is null or g.title like '%'||:title||'%') and (:category is null or g.category.id = :category)\")\nList<Game> find(@Param(\"title\") String title, @Param(\"category\") Long category);\n
Esta @Query
es la que utiliza Spring Data para traducir las propiedades a objetos de BBDD y mapear los resultados a objetos Java. Si tenemos activada la property spring.jpa.show-sql=true
podremos ver las queries que est\u00e1 generando Spring Data. El resultado es el siguiente.
Hibernate: select game0_.id as id1_2_, game0_.age as age2_2_, game0_.author_id as author_i4_2_, game0_.category_id as category5_2_, game0_.title as title3_2_ from game game0_ where (? is null or game0_.title like ('%'||?||'%')) and (? is null or game0_.category_id=?)\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\nHibernate: select category0_.id as id1_1_0_, category0_.name as name2_1_0_ from category category0_ where category0_.id=?\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\nHibernate: select category0_.id as id1_1_0_, category0_.name as name2_1_0_ from category category0_ where category0_.id=?\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\n
Si te fijas ha generado una query SQL para filtrar los Game
, pero luego cuando ha intentado construir los objetos Java, ha tenido que lanzar una serie de queries para recuperar los diferentes Author
y Category
a trav\u00e9s de sus id
. Obviamente Spring Data es muy lista y cachea los resultados obtenidos para no tener que recuperarlos n veces, pero aun as\u00ed, lanza unas cuantas consultas. Esto penaliza el rendimiento de nuestra operaci\u00f3n, ya que tiene que lanzar n queries a BBDD que, aunque son muy \u00f3ptimas, incrementan unos milisegundos el tiempo total.
Para evitar esta circunstancia, disponemos de la anotaci\u00f3n denominada @EnitityGraph
la cual proporciona directrices a Spring Data sobre la forma en la que deseamos realizar la consulta, permitiendo que realice agrupaciones y uniones de tablas en una \u00fanica query que, aun siendo mas compleja, en muchos casos el rendimiento es mucho mejor que realizar m\u00faltiples interacciones con la BBDD.
Siguiendo el ejemplo anterior podr\u00edamos utilizar la anotaci\u00f3n de esta forma:
@Query(\"select g from Game g where (:title is null or g.title like '%'||:title||'%') and (:category is null or g.category.id = :category)\")\n@EntityGraph(attributePaths = {\"category\", \"author\"})\nList<Game> find(@Param(\"title\") String title, @Param(\"category\") Long category);\n
Donde le estamos diciendo a Spring Data que cuando realice la query, haga el cruce con las propiedades category
y author
, que a su vez son entidades y por tanto mapean dos tablas de BBDD. El resultado es el siguiente:
Hibernate: select game0_.id as id1_2_0_, category1_.id as id1_1_1_, author2_.id as id1_0_2_, game0_.age as age2_2_0_, game0_.author_id as author_i4_2_0_, game0_.category_id as category5_2_0_, game0_.title as title3_2_0_, category1_.name as name2_1_1_, author2_.name as name2_0_2_, author2_.nationality as national3_0_2_ from game game0_ left outer join category category1_ on game0_.category_id=category1_.id left outer join author author2_ on game0_.author_id=author2_.id where (? is null or game0_.title like ('%'||?||'%')) and (? is null or game0_.category_id=?)\n
Una \u00fanica query, que es m\u00e1s compleja que la anterior, ya que hace dos cruces con tablas de BBDD, pero que nos evita tener que lanzar n queries diferentes para recuperar Author
y Category
.
Generalmente, el uso de @EntityGraph
acelera mucho los resultados y es muy recomendable utilizarlo para realizar los cruces inline. Se puede utilizar tanto con @Query
como con Derived Query Methods
. Puedes leer m\u00e1s informaci\u00f3n en este peque\u00f1o tutorial de Baeldung.
A partir de Java 8 disponemos de los Java Streams. Se trata de una herramienta que nos permite multitud de opciones relativas tratamiento y trasformaci\u00f3n de los datos manejados.
En este apartado \u00fanicamente se menciona debido a que en muchas ocasiones cuando nos enfrentamos a consultas complejas, puede ser beneficioso evitar ofuscar las consultas y realizar las trasformaciones necesarias mediante los Streams.
Un ejemplo de uso pr\u00e1ctico podr\u00eda ser, evitar usar la cl\u00e1usula IN
de SQL en una determinada consulta que podr\u00eda penalizar notablemente el rendimiento de las consultas. En vez de eso se podr\u00eda utilizar el m\u00e9todo de JAVA filter
sobre el conjunto de elementos para obtener el mismo resultado.
Puedes leer m\u00e1s informaci\u00f3n en el tutorial de Baeldung.
"},{"location":"appendix/jpa/#specifications","title":"Specifications","text":"En algunos casos puede ocurrir que con las herramientas descritas anteriormente no tengamos suficiente alcance, bien porque las definiciones de los m\u00e9todos se complican y alargan demasiado o debido a que la consulta es demasiado gen\u00e9rica como para realizarlo de este modo.
Para este caso se dispone de las Specifications que nos proveen de una forma de escribir consultas reutilizables mediante una API que ofrece una forma fluida de crear y combinar consultas complejas.
Un ejemplo de caso de uso podr\u00eda ser un CRUD de una determinada entidad que debe poder filtrar por todos los atributos de esta, donde el tipo de filtrado viene especificado en la propia consulta y no siempre es requerido. En este caso no podr\u00edamos construir una consulta basada en definir un determinado m\u00e9todo ya no conocemos de ante mano que filtros ni que atributos vamos a recibir y deberemos recurrir al uso de las Specifications.
Puedes leer m\u00e1s informaci\u00f3n en el tutorial de Baeldung.
"},{"location":"appendix/rest/","title":"Breve detalle sobre REST","text":"Antes de empezar vamos a hablar de operaciones REST. Estas operaciones son el punto de entrada a nuestra aplicaci\u00f3n y se pueden diferenciar dos claros elementos:
La ruta del recurso nos indica entre otras cosas, el endpoint y su posible jerarqu\u00eda sobre la que se va a realizar la operaci\u00f3n. Debe tener una ra\u00edz de recurso y si se requiere navegar por el recursos, la jerarqu\u00eda ir\u00e1 separada por barras. La URL nunca deber\u00eda tener verbos o acciones solamente recursos, identificadores o atributos. Por ejemplo en nuestro caso de Categor\u00edas
, ser\u00edan correctas las siguientes rutas:
Sin embargo, no ser\u00edan del todo correctas las rutas:
A menudo, se integran datos identificadores o atributos de b\u00fasqueda dentro de la propia ruta. Podr\u00edamos definir la operaci\u00f3n category/3
para referirse a la Categor\u00eda con ID = 3, o category/?name=Dados
para referirse a las categor\u00edas con nombre = Dados. A veces, estos datos tambi\u00e9n pueden ir como atributos en la URL o en el cuerpo de la petici\u00f3n, aunque se recomienda que siempre que sean identificadores vayan determinados en la propia URL.
Si el dominio categor\u00eda tuviera hijos o relaciones con alg\u00fan otro dominio se podr\u00eda a\u00f1adir esas jerarqu\u00eda a la URL. Por ejemplo podr\u00edamos tener category/3/child/2
para referirnos al hijo de ID = 2 que tiene la Categor\u00eda de ID = 3, y as\u00ed sucesivamente.
La acci\u00f3n sobre el recurso se determina mediante la operaci\u00f3n o verbo HTTP que se utiliza en el endpoint. Los verbos m\u00e1s usados ser\u00edan:
POST
.De esta forma tendr\u00edamos:
GET /category/3
. Realizar\u00eda un acceso para recuperar la categor\u00eda 3.POST o PUT /category/3
. Realizar\u00eda un acceso para crear o modificar la categor\u00eda 3. Los datos a modificar deber\u00edan ir en el body.DELETE /category/3
. Realizar\u00eda un acceso para borrar la categor\u00eda 3.GET /category/?name=Dados
. Realizar\u00eda un acceso para recuperar las categor\u00edas que tengan nombre = Dados.Excepciones a la regla
A veces hay que ejecutar una operaci\u00f3n que no es 'estandar' en cuanto a verbos HTTP. Para ese caso, deberemos clarificar en la URL la acci\u00f3n que se debe realizar y si vamos a enviar datos deber\u00eda ser de tipo POST
mientras que si simplemente se requiere una contestaci\u00f3n sin enviar datos ser\u00e1 de tipo GET
. Por ejemplo POST /category/3/validate
realizar\u00eda un acceso para ejecutar una validaci\u00f3n sobre los datos enviados en el body de la categor\u00eda 3.
Se trata de una pr\u00e1ctica de programaci\u00f3n que consiste en escribir primero las pruebas (generalmente unitarias), despu\u00e9s escribir el c\u00f3digo fuente que pase la prueba satisfactoriamente y, por \u00faltimo, refactorizar el c\u00f3digo escrito.
Este ciclo se suele representar con la siguiente imagen:
Con esta pr\u00e1ctica se consigue entre otras cosas: un c\u00f3digo m\u00e1s robusto, m\u00e1s seguro, m\u00e1s mantenible y una mayor rapidez en el desarrollo.
Los pasos que se siguen son:
Primero hay que escribir el test o los tests que cubran la funcionalidad que voy a implementar. Los test no solo deben probar los casos correctos, sino que deben probar los casos err\u00f3neos e incluso los casos en los que se provoca una excepci\u00f3n. Cuantos m\u00e1s test hagas, mejor probada y m\u00e1s robusta ser\u00e1 tu aplicaci\u00f3n.
Adem\u00e1s, como efecto colateral, al escribir el test est\u00e1s pensando el dise\u00f1o de c\u00f3mo va a funcionar la aplicaci\u00f3n. En vez de liarte a programar como loco, te est\u00e1s forzando a pensar primero y ver cual es la mejor soluci\u00f3n. Por ejemplo para implementar una operaci\u00f3n de calculadora primero piensas en qu\u00e9 es lo que necesitar\u00e1s: una clase Calculadora con un m\u00e9todo que se llame Suma y que tenga dos par\u00e1metros.
El segundo paso una vez tengo definido el test, que evidentemente fallar\u00e1 (e incluso a menudo ni siquiera compilar\u00e1), es implementar el c\u00f3digo necesario para que los tests funcionen. Aqu\u00ed muchas veces pecamos de querer implementar demasiadas cosas o pensando en que en un futuro necesitaremos modificar ciertas partes y lo dejamos ya preparado para ello. Hay que ir con mucho cuidado con las optimizaciones prematuras
, a menudo no son necesarias y solo hacen que dificultar nuestro c\u00f3digo.
Piensa en construir el m\u00ednimo c\u00f3digo que haga que tus tests funcionen correctamente. Adem\u00e1s, no es necesario que sea un c\u00f3digo demasiado purista y limpio.
El \u00faltimo paso y a menudo el m\u00e1s olvidado es el Refactor
. Una vez te has asegurado que tu c\u00f3digo funciona y que los tests funcionan correctamente (ojo no solo los tuyos sino todos los que ya existan en la aplicaci\u00f3n) llega el paso de sacarle brillo a tu c\u00f3digo.
En este paso tienes que intentar mejorar tu c\u00f3digo, evitar duplicidades, evitar malos olores de programaci\u00f3n, eliminar posibles malos usos del lenguaje, etc. En definitiva que tu c\u00f3digo se lea y se entienda mejor.
Si seguimos estos pasos a la hora de programar, nuestra aplicaci\u00f3n estar\u00e1 muy bien testada. Cada vez que hagamos un cambio tendremos una certeza muy elevada, de forma r\u00e1pida y sencilla, de si la aplicaci\u00f3n sigue funcionando o hemos roto algo. Y lo mejor de todo, las implementaciones que hagamos estar\u00e1n bien pensadas y dise\u00f1adas y acotadas realmente a lo que necesitamos.
"},{"location":"appendix/springcloud/basic/","title":"Listado simple - Spring Boot","text":"A diferencia del tutorial b\u00e1sico de Spring Boot, donde constru\u00edamos una aplicaci\u00f3n monol\u00edtica, ahora vamos a construir multiples servicios por lo que necesitamos crear proyectos separados.
Para la creaci\u00f3n de proyecto nos remitimos a la gu\u00eda de instalaci\u00f3n donde se detalla el proceso de creaci\u00f3n de nuevo proyecto Entorno de desarrollo
Todos los pasos son exactamente iguales, lo \u00fanico que va a variar es el nombre de nuestro proyecto, que en este caso se va a llamar tutorial-category
. El campo que debemos modificar es artifact
en Spring Initilizr, el resto de campos se cambiaran autom\u00e1ticamente.
Esta parte de tutorial es una ampliaci\u00f3n de la parte de backend con Spring Boot, por tanto no se ve a enfocar en las partes b\u00e1sicas aprendidas previamente, si no que se va a explicar el funcionamiento de los micro servicios aplicados al mismo caso de uso.
Para cualquier duda sobre la estructura del c\u00f3digo y buenas pr\u00e1cticas, consultar el apartado de Estructura y buenas pr\u00e1cticas, ya que aplican a este caso en el mismo modo.
"},{"location":"appendix/springcloud/basic/#codigo","title":"C\u00f3digo","text":"Dado de vamos a implementar el micro servicio Spring Boot de Categor\u00edas
, vamos a respetar la misma estructura del Listado simple de la version monol\u00edtica.
En primer lugar, vamos a crear la entidad y el DTO dentro del package com.ccsw.tutorialcategory.category.model
. Ojo al package que lo hemos renombrado con respecto al listado monol\u00edtico.
package com.ccsw.tutorialcategory.category.model;\n\nimport jakarta.persistence.*;\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"category\")\npublic class Category {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"name\", nullable = false)\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
package com.ccsw.tutorialcategory.category.model;\n\n/**\n * @author ccsw\n *\n */\npublic class CategoryDto {\n\nprivate Long id;\n\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
"},{"location":"appendix/springcloud/basic/#repository-service-y-controller","title":"Repository, Service y Controller","text":"Posteriormente, emplazamos el resto de clases dentro del package com.ccsw.tutorialcategory.category
.
package com.ccsw.tutorialcategory.category;\n\nimport com.ccsw.tutorialcategory.category.model.Category;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface CategoryRepository extends CrudRepository<Category, Long> {\n\n}\n
package com.ccsw.tutorialcategory.category;\n\n\nimport com.ccsw.tutorialcategory.category.model.Category;\nimport com.ccsw.tutorialcategory.category.model.CategoryDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface CategoryService {\n\n/**\n * Recupera una {@link Category} a partir de su ID\n *\n * @param id PK de la entidad\n * @return {@link Category}\n */\nCategory get(Long id);\n\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link Category}\n */\nList<Category> findAll();\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, CategoryDto dto);\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
package com.ccsw.tutorialcategory.category;\n\nimport com.ccsw.tutorialcategory.category.model.Category;\nimport com.ccsw.tutorialcategory.category.model.CategoryDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class CategoryServiceImpl implements CategoryService {\n\n@Autowired\nCategoryRepository categoryRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic Category get(Long id) {\n\nreturn this.categoryRepository.findById(id).orElse(null);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Category> findAll() {\n\nreturn (List<Category>) this.categoryRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, CategoryDto dto) {\n\nCategory category;\n\nif (id == null) {\ncategory = new Category();\n} else {\ncategory = this.get(id);\n}\n\ncategory.setName(dto.getName());\n\nthis.categoryRepository.save(category);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.get(id) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.categoryRepository.deleteById(id);\n}\n\n}\n
package com.ccsw.tutorialcategory.category;\n\nimport com.ccsw.tutorialcategory.category.model.Category;\nimport com.ccsw.tutorialcategory.category.model.CategoryDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\n@Autowired\nCategoryService categoryService;\n\n@Autowired\nModelMapper mapper;\n\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link CategoryDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Categories\"\n)\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<CategoryDto> findAll() {\n\nList<Category> categories = this.categoryService.findAll();\n\nreturn categories.stream().map(e -> mapper.map(e, CategoryDto.class)).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Category\"\n)\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody CategoryDto dto) {\n\nthis.categoryService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Category\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\nthis.categoryService.delete(id);\n}\n\n}\n
"},{"location":"appendix/springcloud/basic/#sql-y-configuracion","title":"SQL y Configuraci\u00f3n","text":"Finalmente, debemos crear el mismo fichero de inicializaci\u00f3n de base de datos con solo los datos de categor\u00edas y modificar ligeramente la configuraci\u00f3n inicial para a\u00f1adir un puerto manualmente. Esto es necesario ya que vamos a levantar varios servicios simult\u00e1neamente y necesitaremos levantarlos en puertos diferentes para que no colisionen entre ellos.
data.sqlapplication.propertiesINSERT INTO category(name) VALUES ('Eurogames');\nINSERT INTO category(name) VALUES ('Ameritrash');\nINSERT INTO category(name) VALUES ('Familiar');\n
server.port=8091\n#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n
"},{"location":"appendix/springcloud/basic/#pruebas","title":"Pruebas","text":"Ahora si arrancamos la aplicaci\u00f3n server y abrimos el Postman podemos realizar las mismas pruebas del apartado de Listado simple pero esta vez apuntado al puerto 8091
.
Con esto ya tendr\u00edamos nuestro primer servicio separado. Podr\u00edamos conectar el frontend a este servicio, pero a medida que nuestra aplicaci\u00f3n creciera en n\u00famero de servicios ser\u00eda un poco engorroso todo, as\u00ed que todav\u00eda no lo vamos a conectar hasta que no tengamos toda la infraestructura.
Vamos a convertir en micro servicio el siguiente listado.
"},{"location":"appendix/springcloud/filtered/","title":"Listado filtrado - Spring Boot","text":"Al igual que en los caos anteriores vamos a crear un nuevo proyecto que contendr\u00e1 un nuevo micro servicio.
Para la creaci\u00f3n de proyecto nos remitimos a la gu\u00eda de instalaci\u00f3n donde se detalla el proceso de creaci\u00f3n de nuevo proyecto Entorno de desarrollo
Todos los pasos son exactamente iguales, lo \u00fanico que va a variar, es el nombre de nuestro proyecto, que en este caso se va a llamar tutorial-game
. El campo que debemos modificar es artifact
en Spring Initilizr, el resto de campos se cambiaran autom\u00e1ticamente.
Dado de vamos a implementar el micro servicio Spring Boot de Juegos
, vamos a respetar la misma estructura del Listado filtrado de la version monol\u00edtica.
En primer lugar, vamos a a\u00f1adir la clase que necesitamos para realizar el filtrado y vimos en la version monol\u00edtica del tutorial en el package com.ccsw.tutorialgame.common.criteria
.
package com.ccsw.tutorialgame.common.criteria;\n\npublic class SearchCriteria {\n\nprivate String key;\nprivate String operation;\nprivate Object value;\n\npublic SearchCriteria(String key, String operation, Object value) {\n\nthis.key = key;\nthis.operation = operation;\nthis.value = value;\n}\n\npublic String getKey() {\nreturn key;\n}\n\npublic void setKey(String key) {\nthis.key = key;\n}\n\npublic String getOperation() {\nreturn operation;\n}\n\npublic void setOperation(String operation) {\nthis.operation = operation;\n}\n\npublic Object getValue() {\nreturn value;\n}\n\npublic void setValue(Object value) {\nthis.value = value;\n}\n\n}\n
"},{"location":"appendix/springcloud/filtered/#entity-y-dto","title":"Entity y Dto","text":"Seguimos con la entidad y el DTO dentro del package com.ccsw.tutorialgame.game.model
. En este punto, f\u00edjate que nuestro modelo de Entity
no tiene relaci\u00f3n con la tabla Author
ni Category
ya que estos dos objetos no pertenecen a nuestro dominio y se gestionan desde otro micro servicio. Lo que tendremos ahora ser\u00e1 el identificador del registro que hace referencia a esos objetos. Ya no usaremos @JoinColumn
porque en nuestro modelo no existen esas tablas relacionadas.
Sin embargo el Dto si que utiliza relaciones, ya que son relaciones de negocio (en el Service
) y no son relaciones de dominio (en BBDD o Repository
)
package com.ccsw.tutorialgame.game.model;\n\nimport jakarta.persistence.*;\n\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"game\")\npublic class Game {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"title\", nullable = false)\nprivate String title;\n\n@Column(name = \"age\", nullable = false)\nprivate String age;\n\n@Column(name = \"category_id\", nullable = false)\nprivate Long idCategory;\n\n@Column(name = \"author_id\", nullable = false)\nprivate Long idAuthor;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return idCategory\n */\npublic Long getIdCategory() {\n\nreturn this.idCategory;\n}\n\n/**\n * @param idCategory new value of {@link #getIdCategory}.\n */\npublic void setIdCategory(Long idCategory) {\n\nthis.idCategory = idCategory;\n}\n\n/**\n * @return idAuthor\n */\npublic Long getIdAuthor() {\n\nreturn this.idAuthor;\n}\n\n/**\n * @param idAuthor new value of {@link #getIdAuthor}.\n */\npublic void setIdAuthor(Long idAuthor) {\n\nthis.idAuthor = idAuthor;\n}\n\n}\n
package com.ccsw.tutorialgame.game.model;\n\n\nimport com.ccsw.tutorialgame.author.model.AuthorDto;\nimport com.ccsw.tutorialgame.category.model.CategoryDto;\n\n/**\n * @author ccsw\n *\n */\npublic class GameDto {\n\nprivate Long id;\n\nprivate String title;\n\nprivate String age;\n\nprivate Long idCategory;\n\nprivate Long idAuthor;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return idCategory\n */\npublic Long getIdCategory() {\n\nreturn this.idCategory;\n}\n\n/**\n * @param idCategory new value of {@link #getIdCategory}.\n */\npublic void setIdCategory(Long idCategory) {\n\nthis.idCategory = idCategory;\n}\n\n/**\n * @return idAuthor\n */\npublic Long getIdAuthor() {\n\nreturn this.idAuthor;\n}\n\n/**\n * @param idAuthor new value of {@link #getIdAuthor}.\n */\npublic void setIdAuthor(Long idAuthor) {\n\nthis.idAuthor = idAuthor;\n}\n\n}\n
"},{"location":"appendix/springcloud/filtered/#repository-service-controller","title":"Repository, Service, Controller","text":"Posteriormente, emplazamos el resto de clases dentro del package com.ccsw.tutorialgame.game
.
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.game.model.Game;\nimport org.springframework.data.jpa.repository.JpaSpecificationExecutor;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameRepository extends CrudRepository<Game, Long>, JpaSpecificationExecutor<Game> {\n\n}\n
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.game.model.Game;\nimport com.ccsw.tutorialgame.game.model.GameDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameService {\n\n/**\n * Recupera los juegos filtrando opcionalmente por t\u00edtulo y/o categor\u00eda\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link Game}\n */\nList<Game> find(String title, Long idCategory);\n\n/**\n * Guarda o modifica un juego, dependiendo de si el identificador est\u00e1 o no informado\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, GameDto dto);\n\n}\n
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.common.criteria.SearchCriteria;\nimport com.ccsw.tutorialgame.game.model.Game;\nimport jakarta.persistence.criteria.*;\nimport org.springframework.data.jpa.domain.Specification;\n\n\npublic class GameSpecification implements Specification<Game> {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate final SearchCriteria criteria;\n\npublic GameSpecification(SearchCriteria criteria) {\n\nthis.criteria = criteria;\n}\n\n@Override\npublic Predicate toPredicate(Root<Game> root, CriteriaQuery<?> query, CriteriaBuilder builder) {\nif (criteria.getOperation().equalsIgnoreCase(\":\") && criteria.getValue() != null) {\nPath<String> path = getPath(root);\nif (path.getJavaType() == String.class) {\nreturn builder.like(path, \"%\" + criteria.getValue() + \"%\");\n} else {\nreturn builder.equal(path, criteria.getValue());\n}\n}\nreturn null;\n}\n\nprivate Path<String> getPath(Root<Game> root) {\nString key = criteria.getKey();\nString[] split = key.split(\"[.]\", 0);\n\nPath<String> expression = root.get(split[0]);\nfor (int i = 1; i < split.length; i++) {\nexpression = expression.get(split[i]);\n}\n\nreturn expression;\n}\n\n}\n
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.common.criteria.SearchCriteria;\nimport com.ccsw.tutorialgame.game.model.Game;\nimport com.ccsw.tutorialgame.game.model.GameDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.jpa.domain.Specification;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class GameServiceImpl implements GameService {\n\n@Autowired\nGameRepository gameRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Game> find(String title, Long idCategory) {\n\nGameSpecification titleSpec = new GameSpecification(new SearchCriteria(\"title\", \":\", title));\nGameSpecification categorySpec = new GameSpecification(new SearchCriteria(\"idCategory\", \":\", idCategory));\n\nSpecification<Game> spec = Specification.where(titleSpec).and(categorySpec);\n\nreturn this.gameRepository.findAll(spec);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, GameDto dto) {\n\nGame game;\n\nif (id == null) {\ngame = new Game();\n} else {\ngame = this.gameRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(dto, game, \"id\");\n\ngame.setIdAuthor(dto.getIdAuthor());\ngame.setIdCategory(dto.getIdCategory());\n\nthis.gameRepository.save(game);\n}\n\n}\n
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.game.model.Game;\nimport com.ccsw.tutorialgame.game.model.GameDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Game\", description = \"API of Game\")\n@RequestMapping(value = \"/game\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class GameController {\n\n@Autowired\nGameService gameService;\n\n@Autowired\nModelMapper mapper;\n\n/**\n * M\u00e9todo para recuperar una lista de {@link Game}\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link GameDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a filtered list of Games\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<GameDto> find(@RequestParam(value = \"title\", required = false) String title,\n@RequestParam(value = \"idCategory\", required = false) Long idCategory) {\n\nList<Game> game = this.gameService.find(title, idCategory);\n\nreturn game.stream().map(e -> mapper.map(e, GameDto.class)).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Game}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Game\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody GameDto dto) {\n\ngameService.save(id, dto);\n}\n\n}\n
"},{"location":"appendix/springcloud/filtered/#sql-y-configuracion","title":"SQL y Configuraci\u00f3n","text":"Finalmente, debemos crear el script de inicializaci\u00f3n de base de datos con solo los datos de juegos y modificar ligeramente la configuraci\u00f3n inicial para a\u00f1adir un puerto manualmente para poder tener multiples micro servicios funcionando simult\u00e1neamente.
data.sqlapplication.propertiesINSERT INTO game(title, age, category_id, author_id) VALUES ('On Mars', '14', 1, 2);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Aventureros al tren', '8', 3, 1);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('1920: Wall Street', '12', 1, 4);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Barrage', '14', 1, 3);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Los viajes de Marco Polo', '12', 1, 3);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Azul', '8', 3, 5);\n
server.port=8093\n#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n
"},{"location":"appendix/springcloud/filtered/#pruebas","title":"Pruebas","text":"Ahora si arrancamos la aplicaci\u00f3n server y abrimos el Postman podemos realizar las mismas pruebas del apartado de Listado filtrado pero esta vez apuntado al puerto 8093
.
F\u00edjate que cuando probemos el listado de juegos, devolver\u00e1 identificadores en idAuthor
y idCategory
, y no objetos como funcionaba hasta ahora en la aplicaci\u00f3n monol\u00edtica. As\u00ed que las pruebas que realices para insertar tambi\u00e9n deben utilizar esas propiedades y NO objetos.
En este punto ya tenemos un micro servicio de categor\u00edas en el puerto 8091
, un micro servicio de autores en el puerto 8092
y un \u00faltimo micro servicio de juegos en el puerto 8093
.
Si ahora fueramos a conectarlo con el frontend tendr\u00edamos dos problemas:
author
y category
sino que devuelve su ID. Esto obliga al frontend a tener que hacer dos llamadas extra para completar la informaci\u00f3n. Estar\u00edamos llevando l\u00f3gica de negocio al frontend y esto no nos convence.Para poder solverntar ambos problemas, necesitamos conectar todos nuestros micro servicios con una infraestructura que nos ayudar\u00e1 a gestionar todo el ecosistema de micro servicios. Vamos all\u00e1 con el \u00faltimo punto.
"},{"location":"appendix/springcloud/infra/","title":"Infraestructura - Spring Cloud","text":"Creados los tres micro servicios que compondr\u00e1n nuestro aplicativo, ya podemos empezar con la creaci\u00f3n de las piezas de infraestructura que ser\u00e1n las encargadas de realizar la orquestaci\u00f3n.
"},{"location":"appendix/springcloud/infra/#service-discovery-eureka","title":"Service Discovery - Eureka","text":"Para esta pieza hay muchas aplicaciones de mercado, incluso los propios proveedores de cloud tiene la suya propia, pero en este caso, vamos a utilizar la que ofrece Spring Cloud, as\u00ed que vamos a crear un proyecto de una forma similar a la que estamos acostumbrados.
"},{"location":"appendix/springcloud/infra/#crear-el-servicio","title":"Crear el servicio","text":"Volviendo una vez m\u00e1s a Spring Initializr seleccionaremos los siguientes datos:
Es importante que a\u00f1adamos la dependencia de Eureka Server
para que sea capaz de ejecutar el proyecto como si fuera un servidor Eureka.
Importamos el proyecto dentro del IDE y ya solo nos queda activar el servidor y configurarlo.
En primer lugar, a\u00f1adimos la anotaci\u00f3n que habilita el servidor de Eureka.
TutorialEurekaApplication.javapackage com.ccsw.tutorialeureka;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;\n\n@SpringBootApplication\n@EnableEurekaServer\npublic class TutorialEurekaApplication {\n\npublic static void main(String[] args) {\nSpringApplication.run(TutorialEurekaApplication.class, args);\n}\n\n}\n
Ahora debemos a\u00f1adir las configuraciones necesarias. En primer lugar para facilitar la visualizaci\u00f3n de las propiedades vamos a renombrar nuestro fichero application.properties
a application.yml
. Hecho esto, a\u00f1adimos la configuraci\u00f3n de puerto que ya conocemos y a\u00f1adimos directivas sobre que Eureka no se registre a s\u00ed mismo dentro del cat\u00e1logo de servicios.
server:\n port: 8761\neureka:\n client:\n registerWithEureka: false\n fetchRegistry: false\n
"},{"location":"appendix/springcloud/infra/#probar-el-servicio","title":"Probar el servicio","text":"Hechas estas sencillas configuraciones y arrancando el proyecto, nos dirigimos a la http://localhost/8761
donde podemos ver la interfaz de Eureka y si miramos con detenimiento, vemos que el cat\u00e1logo de servicios aparece vac\u00edo, ya que a\u00fan no se ha registrado ninguno de ellos.
Ahora que ya tenemos disponible Eureka, ya podemos proceder a registrar nuestros micro servicios dentro del cat\u00e1logo. Para ello vamos a realizar las mismas modificaciones sobre los tres micro servicios. Recuerda que hay que realizarlo sobre los tres para que se registren todos.
"},{"location":"appendix/springcloud/infra/#configurar-micro-servicios","title":"Configurar micro servicios","text":"Para este fin debemos a\u00f1adir una nueva dependencia dentro del pom.xml
y modificar la configuraci\u00f3n del proyecto.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\nxsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n<modelVersion>4.0.0</modelVersion>\n<parent>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-parent</artifactId>\n<version>3.0.4</version>\n<relativePath/> <!-- lookup parent from repository -->\n</parent>\n<groupId>com.ccsw</groupId>\n<artifactId>tutorial-XXX</artifactId> <!-- Cada proyecto tiene su configaci\u00f3n propia, NO modificar -->\n<version>0.0.1-SNAPSHOT</version>\n<name>tutorial-XXX</name> <!-- Cada proyecto tiene su configaci\u00f3n propia, NO modificar -->\n<description>Demo project for Spring Boot</description>\n<properties>\n<java.version>19</java.version>\n<spring-cloud.version>2022.0.1</spring-cloud.version>\n</properties>\n<dependencies>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-data-jpa</artifactId>\n</dependency>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-web</artifactId>\n</dependency>\n\n<dependency>\n<groupId>org.springdoc</groupId>\n<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>\n<version>2.0.3</version>\n</dependency>\n\n<dependency>\n<groupId>org.hibernate</groupId>\n<artifactId>hibernate-validator</artifactId>\n<version>8.0.0.Final</version>\n</dependency>\n\n<dependency>\n<groupId>net.sf.dozer</groupId>\n<artifactId>dozer</artifactId>\n<version>5.5.1</version>\n</dependency>\n\n<dependency>\n<groupId>org.springframework.cloud</groupId>\n<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>\n</dependency>\n<dependency>\n<groupId>com.h2database</groupId>\n<artifactId>h2</artifactId>\n<scope>runtime</scope>\n</dependency>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-test</artifactId>\n<scope>test</scope>\n</dependency>\n</dependencies>\n<dependencyManagement>\n<dependencies>\n<dependency>\n<groupId>org.springframework.cloud</groupId>\n<artifactId>spring-cloud-dependencies</artifactId>\n<version>${spring-cloud.version}</version>\n<type>pom</type>\n<scope>import</scope>\n</dependency>\n</dependencies>\n</dependencyManagement>\n<build>\n<plugins>\n<plugin>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-maven-plugin</artifactId>\n</plugin>\n</plugins>\n</build>\n\n</project>\n
spring.application.name=spring-cloud-eureka-client-XXX\nserver.port=809X\n\n#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n\n#Eureka\neureka.client.serviceUrl.defaultZone=${EUREKA_URI:http://localhost:8761/eureka}\neureka.instance.preferIpAddress=true\n
Como podemos observar, lo que hemos hecho, es a\u00f1adir la dependencia de Eureka Client y le hemos comunicado a cada micro servicio donde tenemos arrancado Eureka. De este modo al arrancar cada micro servicio, este se registrar\u00e1 autom\u00e1ticamente dentro de Eureka.
Para poder diferenciar cada micro servicio, estos tienen su configuraci\u00f3n de nombre y puerto (mantenemos el puerto que hab\u00edamos configurado en pasos previos):
spring.application.name=spring-cloud-eureka-client-category
spring.application.name=spring-cloud-eureka-client-author
spring.application.name=spring-cloud-eureka-client-game
Nombres en vez de rutas
Estos nombres ser\u00e1n por los que vamos a identificar cada micro servicio dentro de Eureka que ser\u00e1 quien conozca las rutas de los mismos, asi cuando queramos realizar redirecciones a estos no necesitaremos conocerlas rutas ni los puertos de los mismos, con proporcionar los nombres tendremos la informaci\u00f3n completa de como llegar a ellos.
"},{"location":"appendix/springcloud/infra/#probar-micro-servicios","title":"Probar micro servicios","text":"Hechas estas configuraciones y arrancados los micro servicios, volvemos a dirigirnos a Eureka en http://localhost/8761
donde podemos ver que estos aparecen en el listado de servicios registrados.
Para esta pieza, de nuevo, hay muchas implementaciones y aplicaciones de mercado, pero nosotros vamos a utilizar la de Spring Cloud, as\u00ed que vamos a crear un nuevo proyecto de una forma similar a la de Eureka.
"},{"location":"appendix/springcloud/infra/#crear-el-servicio_1","title":"Crear el servicio","text":"Volviendo una vez m\u00e1s a Spring Initializr seleccionaremos los siguientes datos:
Ojo con las dependencias de Gateway
y de Eureka Client
que debemos a\u00f1adir.
De nuevo lo importamos en nuestro IDE y pasamos a a\u00f1adir las configuraciones pertinentes.
Al igual que en el caso de Eureka vamos a renombrar nuestro fichero application.properties
a application.yml
.
server:\n port: 8080\neureka:\n client:\n serviceUrl:\n defaultZone: http://localhost:8761/eureka\nspring:\n application:\n name: spring-cloud-eureka-client-gateway\n cloud:\n gateway:\n default-filters:\n - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin\n globalcors:\n corsConfigurations:\n '[/**]':\n allowedOrigins: \"*\"\n allowedMethods: \"*\"\n allowedHeaders: \"*\"\n routes:\n - id: category\n uri: lb://SPRING-CLOUD-EUREKA-CLIENT-CATEGORY\n predicates:\n - Path=/category/**\n - id: author\n uri: lb://SPRING-CLOUD-EUREKA-CLIENT-AUTHOR\n predicates:\n - Path=/author/**\n - id: game\n uri: lb://SPRING-CLOUD-EUREKA-CLIENT-GAME\n predicates:\n - Path=/game/**\n
Lo que hemos hecho aqu\u00ed es configurar el puerto como 8080
ya que el Gateway
va a ser nuestro punto de acceso y el encargado de redirigir cada petici\u00f3n al micro servicio correcto.
Posteriormente hemos configurado el cliente de Eureka para que el Gateway establezca comunicaci\u00f3n con Eureka que hemos configurado previamente para, en primer lugar, registrarse como un cliente y seguidamente obtener informaci\u00f3n del cat\u00e1logo de servicios existentes.
El paso siguiente es darle un nombre a la aplicaci\u00f3n para que se registre en Eureka y a\u00f1adir configuraci\u00f3n de CORS para que cuando realicemos las llamadas desde navegador pueda realizar la redirecci\u00f3n correctamente.
Finalmente a\u00f1adimos las directrices de redirecci\u00f3n al Gateway indic\u00e1ndole los nombres de los micro servicios con los que estos se han registrado en Eureka junto a los predicados que incluyen las rutas parciales que queremos que sean redirigidas a cada micro servicio.
Con esto nos queda la siguiente configuraci\u00f3n:
category
redirigir\u00e1n al micro servicio de Categorias
author
redirigir\u00e1n al micro servicio de Autores
game
redirigir\u00e1n al micro servicio de Juegos
Hechas esto y arrancado el proyecto, volvemos a dirigirnos a Eureka en http://localhost/8761
donde podemos ver que el Gateway se ha registrado correctamente junto al resto de clientes.
El \u00faltimo paso es la implementaci\u00f3n de la comunicaci\u00f3n entre los micro servicios, en este caso necesitamos que nuestro micro servicio de Game
obtenga datos de Category
y Author
para poder servir informaci\u00f3n completa de los Game
ya que en su modelo solo posee los identificadores. Si record\u00e1is, est\u00e1bamos respondiendo solamente con los id
.
Para la comunicaci\u00f3n entre los distintos servicios, Spring Cloud nos prove de Feign Clients
que ofrecen una interfaz muy sencilla de comunicaci\u00f3n y que utiliza a la perfecci\u00f3n la infraestructura que ya hemos construido.
En primer lugar debemos a\u00f1adir la dependencia necesaria dentro de nuestro pom.xml del micro servicio de Game
.
...\n <dependency>\n<groupId>org.springframework.cloud</groupId>\n<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>\n</dependency>\n\n<dependency>\n<groupId>org.springframework.cloud</groupId>\n<artifactId>spring-cloud-starter-openfeign</artifactId>\n</dependency>\n<dependency>\n<groupId>com.h2database</groupId>\n<artifactId>h2</artifactId>\n<scope>runtime</scope>\n</dependency>\n...\n
El siguiente paso es habilitar el uso de los Feign Clients
mediante la anotaci\u00f3n de SpringCloud.
package com.ccsw.tutorialgame;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.cloud.openfeign.EnableFeignClients;\n\n@SpringBootApplication\n@EnableFeignClients\npublic class TutorialGameApplication {\n\npublic static void main(String[] args) {\nSpringApplication.run(TutorialGameApplication.class, args);\n}\n\n}\n
"},{"location":"appendix/springcloud/infra/#configurar-los-clientes","title":"Configurar los clientes","text":"Realizadas las configuraciones ya podemos realizar los cambios necesarios en nuestro c\u00f3digo para implementar la comunicaci\u00f3n. En primer lugar vamos a crear los clientes de Categor\u00edas
y Autores
.
package com.ccsw.tutorialgame.category;\n\nimport com.ccsw.tutorialgame.category.model.CategoryDto;\nimport org.springframework.cloud.openfeign.FeignClient;\nimport org.springframework.web.bind.annotation.GetMapping;\n\nimport java.util.List;\n\n@FeignClient(value = \"SPRING-CLOUD-EUREKA-CLIENT-CATEGORY\", url = \"http://localhost:8080\")\npublic interface CategoryClient {\n\n@GetMapping(value = \"/category\")\nList<CategoryDto> findAll();\n}\n
package com.ccsw.tutorialgame.author;\n\nimport com.ccsw.tutorialgame.author.model.AuthorDto;\nimport org.springframework.cloud.openfeign.FeignClient;\nimport org.springframework.web.bind.annotation.GetMapping;\n\nimport java.util.List;\n\n@FeignClient(value = \"SPRING-CLOUD-EUREKA-CLIENT-AUTHOR\", url = \"http://localhost:8080\")\npublic interface AuthorClient {\n\n@GetMapping(value = \"/author\")\nList<AuthorDto> findAll();\n}\n
Lo que hacemos aqu\u00ed es crear una simple interfaz donde a\u00f1adimos la configuraci\u00f3n del Feign Client
con la url del Gateway a trav\u00e9s del cual vamos a realizar todas las comunicaciones y creamos un m\u00e9todo abstracto con la anotaci\u00f3n pertinente para hacer referencia al endpoint de obtenci\u00f3n del listado.
Con esto ya podemos inyectar estas interfaces dentro de nuestro controlador para obtener todos los datos necesarios que completaran la informaci\u00f3n de la Category
y Author
de cada Game
.
Adem\u00e1s, vamos a cambiar el Dto de respuesta, para que en vez de devolver ids, devuelva los objetos correspondientes, que son los que est\u00e1 esperando nuestro frontend. Para ello, primero crearemos los Dtos que necesitamos. Los crearemos en:
com.ccsw.tutorialgame.category.model
com.ccsw.tutorialgame.author.model
package com.ccsw.tutorialgame.category.model;\n\n/**\n * @author ccsw\n *\n */\npublic class CategoryDto {\n\nprivate Long id;\n\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
package com.ccsw.tutorialgame.author.model;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorDto {\n\nprivate Long id;\n\nprivate String name;\n\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
Adem\u00e1s, modificaremos nuestro GameDto
para hacer uso de esos objetos.
package com.ccsw.tutorialgame.game.model;\n\n\nimport com.ccsw.tutorialgame.author.model.AuthorDto;\nimport com.ccsw.tutorialgame.category.model.CategoryDto;\n\n/**\n * @author ccsw\n *\n */\npublic class GameDto {\n\nprivate Long id;\n\nprivate String title;\n\nprivate String age;\n\nprivate CategoryDto category;\n\nprivate AuthorDto author;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return category\n */\npublic CategoryDto getCategory() {\n\nreturn this.category;\n}\n\n/**\n * @param category new value of {@link #getCategory}.\n */\npublic void setCategory(CategoryDto category) {\n\nthis.category = category;\n}\n\n/**\n * @return author\n */\npublic AuthorDto getAuthor() {\n\nreturn this.author;\n}\n\n/**\n * @param author new value of {@link #getAuthor}.\n */\npublic void setAuthor(AuthorDto author) {\n\nthis.author = author;\n}\n\n}\n
Y por \u00faltimo implementaremos el c\u00f3digo necesario para transformar los ids
en objetos dto. Aqu\u00ed lo que haremos ser\u00e1 recuperar todos los autores y categor\u00edas, haciendo uso de los Feign Client
, y cuando ejecutemos el mapeo de los juegos, ir sustituyendo sus valores por los dtos correspondientes.
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.author.AuthorClient;\nimport com.ccsw.tutorialgame.author.model.AuthorDto;\nimport com.ccsw.tutorialgame.category.CategoryClient;\nimport com.ccsw.tutorialgame.category.model.CategoryDto;\nimport com.ccsw.tutorialgame.game.model.Game;\nimport com.ccsw.tutorialgame.game.model.GameDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Game\", description = \"API of Game\")\n@RequestMapping(value = \"/game\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class GameController {\n\n@Autowired\nGameService gameService;\n\n@Autowired\nCategoryClient categoryClient;\n@Autowired\nAuthorClient authorClient;\n/**\n * M\u00e9todo para recuperar una lista de {@link Game}\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link GameDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a filtered list of Games\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<GameDto> find(@RequestParam(value = \"title\", required = false) String title,\n@RequestParam(value = \"idCategory\", required = false) Long idCategory) {\n\nList<CategoryDto> categories = categoryClient.findAll();\nList<AuthorDto> authors = authorClient.findAll();\nreturn gameService.find(title, idCategory).stream().map(game -> {\nGameDto gameDto = new GameDto();\ngameDto.setId(game.getId());\ngameDto.setTitle(game.getTitle());\ngameDto.setAge(game.getAge());\ngameDto.setCategory(categories.stream().filter(category -> category.getId().equals(game.getIdCategory())).findFirst().orElse(null));\ngameDto.setAuthor(authors.stream().filter(author -> author.getId().equals(game.getIdAuthor())).findFirst().orElse(null));\nreturn gameDto;\n}).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Game}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Game\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody GameDto dto) {\n\ngameService.save(id, dto);\n}\n\n}\n
Con todo esto, ya tenemos construido nuestro aplicativo de micro servicios con la arquitectura Spring Cloud. Podemos proceder a realizar las mismas pruebas tanto manuales como a trav\u00e9s de los frontales.
Escalado
Una de las principales ventajas de las arquitecturas de micro servicios, es la posibilidad de escalar partes de los aplicativos sin tener que escalar el sistema completo. Para confirmar que esto es asi, podemos levantar multiples instancias de cada servicio en puertos diferentes y veremos que esto se refleja en Eureka y el Gateway balancear\u00e1 autom\u00e1ticamente entre las distintas instancias.
"},{"location":"appendix/springcloud/intro/","title":"Introducci\u00f3n Micro Servicios - Spring Cloud","text":""},{"location":"appendix/springcloud/intro/#que-son-los-micro-servicios","title":"Que son los micro servicios?","text":"Pues como su nombre indica, son servicios peque\u00f1itos
Aunque si nos vamos a una definici\u00f3n m\u00e1s t\u00e9cnica (seg\u00fan ChatGPT):
Los micro servicios son una arquitectura de software en la que una aplicaci\u00f3n est\u00e1 compuesta por peque\u00f1os servicios independientes que se comunican entre s\u00ed a trav\u00e9s de interfaces bien definidas. Cada servicio se enfoca en realizar una tarea espec\u00edfica dentro de la aplicaci\u00f3n y se ejecuta de manera aut\u00f3noma.
Cada micro servicio es responsable de un dominio del negocio y puede ser desarrollado, probado, implementado y escalado de manera independiente. Esto permite una mayor flexibilidad y agilidad en el desarrollo y la implementaci\u00f3n de aplicaciones, ya que los cambios en un servicio no afectan a otros servicios.
Adem\u00e1s, los micro servicios son escalables y resistentes a fallos, ya que si un servicio falla, los dem\u00e1s servicios pueden seguir funcionando. Tambi\u00e9n permiten la utilizaci\u00f3n de diferentes tecnolog\u00edas para cada servicio, lo que ayuda a optimizar el rendimiento y la eficiencia en la aplicaci\u00f3n en general.
"},{"location":"appendix/springcloud/intro/#spring-cloud","title":"Spring Cloud","text":"Existente multiples soluciones para implementar micro servicios, en nuestro caso vamos a utilizar la soluci\u00f3n que nos ofrece Spring Framework y que est\u00e1 incluido dentro del m\u00f3dulo Spring Cloud.
Esta soluci\u00f3n nace hace ya varios a\u00f1os como parte de la infraestructura de Netflix para dar soluci\u00f3n a sus propias necesidades. Con el tiempo este c\u00f3digo opensource ha sido adquirido por Spring Framework y se ha incluido dentro de su ecosistema, evolucionandolo con nuevas funcionalidades. Todo ello ha sido publicado bajo el m\u00f3dulo de Spring Cloud.
"},{"location":"appendix/springcloud/intro/#contexto-de-la-aplicacion","title":"Contexto de la aplicaci\u00f3n","text":"Llegados a este punto, \u00bfqu\u00e9 es lo que vamos a hacer en los siguientes puntos?. Pues vamos a coger nuestra aplicaci\u00f3n monol\u00edtica que ya tenemos implementada durante todo el tutorial, y vamos a proceder a trocearla e implementarla con una metodolog\u00eda de micro servicios.
Pero, adem\u00e1s de trocear la aplicaci\u00f3n en peque\u00f1os servicios, nos va a hacer falta una serie de servicios / utilidades para conectar todo el ecosistema. Nos har\u00e1 falta una infraestructura.
"},{"location":"appendix/springcloud/intro/#infraestructura","title":"Infraestructura","text":"A diferencia de una aplicaci\u00f3n monol\u00edtica, en un enfoque de micro servicios, ya no basta \u00fanicamente con la aplicaci\u00f3n desplegada en su servidor, sino que ser\u00e1n necesarios varios actores que se responsabilizar\u00e1n de darle consistencia al sistema, permitir la comunicaci\u00f3n entre ellos, y ayudar\u00e1n a solventar ciertos problemas que nos surgir\u00e1n al trocear nuestras aplicaciones.
Las principales piezas que vamos a utilizar para la implementaci\u00f3n de nuestra infraestructura, ser\u00e1n:
Service Discovery
que no es m\u00e1s que un cat\u00e1logo de todos los servicios que componen el ecosistema al cual cada servicio debe informar de forma proactiva, de su localizaci\u00f3n y disponibilidad.Service Discovery
e informar peri\u00f3dicamente a este cat\u00e1logo de su estado y sus m\u00e9tricas para que en caso de perdida de servicio, el resto de elementos lo sepan y puedan tomar decisiones al respecto. Tambi\u00e9n nos servir\u00e1 para que cada elemento pueda guardar en local una cach\u00e9 del cat\u00e1logo publicado, que se ir\u00e1 refrescando cada vez que lance un health check
.Service Discovery
. Es altamente configurable (rutas, redirecciones, carga, etc.) y es una pieza fundamental para unificar todas las llamadas en un \u00fanico punto del ecosistema.Con las piezas identificadas anteriormente y con el Contexto de la aplicaci\u00f3n en mente, lo que vamos a hacer en los siguientes puntos es trocear el sistema y generar la siguiente arquitectura:
Ya deber\u00edamos tener claros los conceptos y los actores que compondr\u00e1n nuestro sistema, as\u00ed que, all\u00e1 vamos!!!
"},{"location":"appendix/springcloud/paginated/","title":"Listado paginado - Spring Boot","text":"Al igual que en el caso anterior vamos a crear un nuevo proyecto que contendr\u00e1 un nuevo micro servicio.
Para la creaci\u00f3n de proyecto nos remitimos a la gu\u00eda de instalaci\u00f3n donde se detalla el proceso de creaci\u00f3n de nuevo proyecto Entorno de desarrollo
Todos los pasos son exactamente iguales, lo \u00fanico que va a variar, es el nombre de nuestro proyecto, que en este caso se va a llamar tutorial-author
. El campo que debemos modificar es artifact
en Spring Initilizr, el resto de campos se cambiaran autom\u00e1ticamente.
Dado de vamos a implementar el micro servicio Spring Boot de Autores
, vamos a respetar la misma estructura del Listado paginado de la version monol\u00edtica.
En primer lugar, vamos a a\u00f1adir la clase que necesitamos para realizar la paginaci\u00f3n y vimos en la version monol\u00edtica del tutorial en el package com.ccsw.tutorialauthor.common.pagination
. Ojo al package que lo hemos renombrado con respecto al listado monol\u00edtico.
package com.ccsw.tutorialauthor.common.pagination;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport org.springframework.data.domain.*;\n\nimport java.io.Serializable;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class PageableRequest implements Serializable {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate int pageNumber;\n\nprivate int pageSize;\n\nprivate List<SortRequest> sort;\n\npublic PageableRequest() {\n\nsort = new ArrayList<>();\n}\n\npublic PageableRequest(int pageNumber, int pageSize) {\n\nthis();\nthis.pageNumber = pageNumber;\nthis.pageSize = pageSize;\n}\n\npublic PageableRequest(int pageNumber, int pageSize, List<SortRequest> sort) {\n\nthis();\nthis.pageNumber = pageNumber;\nthis.pageSize = pageSize;\nthis.sort = sort;\n}\n\npublic int getPageNumber() {\nreturn pageNumber;\n}\n\npublic void setPageNumber(int pageNumber) {\nthis.pageNumber = pageNumber;\n}\n\npublic int getPageSize() {\nreturn pageSize;\n}\n\npublic void setPageSize(int pageSize) {\nthis.pageSize = pageSize;\n}\n\npublic List<SortRequest> getSort() {\nreturn sort;\n}\n\npublic void setSort(List<SortRequest> sort) {\nthis.sort = sort;\n}\n\n@JsonIgnore\npublic Pageable getPageable() {\n\nreturn PageRequest.of(this.pageNumber, this.pageSize, Sort.by(sort.stream().map(e -> new Sort.Order(e.getDirection(), e.getProperty())).collect(Collectors.toList())));\n}\n\npublic static class SortRequest implements Serializable {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate String property;\n\nprivate Sort.Direction direction;\n\nprotected String getProperty() {\nreturn property;\n}\n\nprotected void setProperty(String property) {\nthis.property = property;\n}\n\nprotected Sort.Direction getDirection() {\nreturn direction;\n}\n\nprotected void setDirection(Sort.Direction direction) {\nthis.direction = direction;\n}\n}\n\n}\n
"},{"location":"appendix/springcloud/paginated/#entity-y-dto","title":"Entity y Dto","text":"Seguimos con la entidad y los DTOs dentro del package com.ccsw.tutorialauthor.author.model
.
package com.ccsw.tutorialauthor.author.model;\n\nimport jakarta.persistence.*;\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"author\")\npublic class Author {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"name\", nullable = false)\nprivate String name;\n\n@Column(name = \"nationality\")\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
package com.ccsw.tutorialauthor.author.model;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorDto {\n\nprivate Long id;\n\nprivate String name;\n\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
package com.ccsw.tutorialauthor.author.model;\n\nimport com.ccsw.tutorialauthor.common.pagination.PageableRequest;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorSearchDto {\n\nprivate PageableRequest pageable;\n\npublic PageableRequest getPageable() {\nreturn pageable;\n}\n\npublic void setPageable(PageableRequest pageable) {\nthis.pageable = pageable;\n}\n}\n
"},{"location":"appendix/springcloud/paginated/#repository-service-y-controller","title":"Repository, Service y Controller","text":"Posteriormente, emplazamos el resto de clases dentro del package com.ccsw.tutorialauthor.author
.
package com.ccsw.tutorialauthor.author;\n\nimport com.ccsw.tutorialauthor.author.model.Author;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorRepository extends CrudRepository<Author, Long> {\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param pageable pageable\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findAll(Pageable pageable);\n\n}\n
package com.ccsw.tutorialauthor.author;\n\nimport com.ccsw.tutorialauthor.author.model.Author;\nimport com.ccsw.tutorialauthor.author.model.AuthorDto;\nimport com.ccsw.tutorialauthor.author.model.AuthorSearchDto;\nimport org.springframework.data.domain.Page;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorService {\n\n/**\n * Recupera un {@link Author} a trav\u00e9s de su ID\n *\n * @param id PK de la entidad\n * @return {@link Author}\n */\nAuthor get(Long id);\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findPage(AuthorSearchDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, AuthorDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n/**\n * Recupera un listado de autores {@link Author}\n *\n * @return {@link List} de {@link Author}\n */\nList<Author> findAll();\n\n}\n
package com.ccsw.tutorialauthor.author;\n\nimport com.ccsw.tutorialauthor.author.model.Author;\nimport com.ccsw.tutorialauthor.author.model.AuthorDto;\nimport com.ccsw.tutorialauthor.author.model.AuthorSearchDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class AuthorServiceImpl implements AuthorService {\n\n@Autowired\nAuthorRepository authorRepository;\n\n/**\n * {@inheritDoc}\n * @return\n */\n@Override\npublic Author get(Long id) {\n\nreturn this.authorRepository.findById(id).orElse(null);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic Page<Author> findPage(AuthorSearchDto dto) {\n\nreturn this.authorRepository.findAll(dto.getPageable().getPageable());\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, AuthorDto data) {\n\nAuthor author;\n\nif (id == null) {\nauthor = new Author();\n} else {\nauthor = this.get(id);\n}\n\nBeanUtils.copyProperties(data, author, \"id\");\n\nthis.authorRepository.save(author);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.get(id) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.authorRepository.deleteById(id);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Author> findAll() {\n\nreturn (List<Author>) this.authorRepository.findAll();\n}\n\n}\n
package com.ccsw.tutorialauthor.author;\n\nimport com.ccsw.tutorialauthor.author.model.Author;\nimport com.ccsw.tutorialauthor.author.model.AuthorDto;\nimport com.ccsw.tutorialauthor.author.model.AuthorSearchDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Author\", description = \"API of Author\")\n@RequestMapping(value = \"/author\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class AuthorController {\n\n@Autowired\nAuthorService authorService;\n\n@Autowired\nModelMapper mapper;\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link AuthorDto}\n */\n@Operation(summary = \"Find Page\", description = \"Method that return a page of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.POST)\npublic Page<AuthorDto> findPage(@RequestBody AuthorSearchDto dto) {\n\nPage<Author> page = this.authorService.findPage(dto);\n\nreturn new PageImpl<>(page.getContent().stream().map(e -> mapper.map(e, AuthorDto.class)).collect(Collectors.toList()), page.getPageable(), page.getTotalElements());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Author\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody AuthorDto dto) {\n\nthis.authorService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Author\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\nthis.authorService.delete(id);\n}\n\n/**\n * Recupera un listado de autores {@link Author}\n *\n * @return {@link List} de {@link AuthorDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<AuthorDto> findAll() {\n\nList<Author> authors = this.authorService.findAll();\n\nreturn authors.stream().map(e -> mapper.map(e, AuthorDto.class)).collect(Collectors.toList());\n}\n\n}\n
"},{"location":"appendix/springcloud/paginated/#sql-y-configuracion","title":"SQL y Configuraci\u00f3n","text":"Finalmente, debemos crear el script de inicializaci\u00f3n de base de datos con solo los datos de author y modificar ligeramente la configuraci\u00f3n inicial para a\u00f1adir un puerto manualmente para poder tener multiples micro servicios funcionando simult\u00e1neamente.
data.sqlapplication.propertiesINSERT INTO author(name, nationality) VALUES ('Alan R. Moon', 'US');\nINSERT INTO author(name, nationality) VALUES ('Vital Lacerda', 'PT');\nINSERT INTO author(name, nationality) VALUES ('Simone Luciani', 'IT');\nINSERT INTO author(name, nationality) VALUES ('Perepau Llistosella', 'ES');\nINSERT INTO author(name, nationality) VALUES ('Michael Kiesling', 'DE');\nINSERT INTO author(name, nationality) VALUES ('Phil Walker-Harding', 'US');\n
server.port=8092\n#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n
"},{"location":"appendix/springcloud/paginated/#pruebas","title":"Pruebas","text":"Ahora si arrancamos la aplicaci\u00f3n server y abrimos el Postman podemos realizar las mismas pruebas del apartado de Listado paginado pero esta vez apuntado al puerto 8092
.
En este punto ya tenemos un micro servicio de categor\u00edas en el puerto 8091
y un micro servicio de autores en el puerto 8092
. Al igual que antes, con estos datos ya podr\u00edamos conectar el frontend a estos servicios, pero vamos a esperar un poquito m\u00e1s a tener toda la infraestructura, para que sea m\u00e1s sencillo.
Vamos a convertir en micro servicio el \u00faltimo listado.
"},{"location":"appendix/springcloud/summary/","title":"Resumen Micro Servicios - Spring Cloud","text":""},{"location":"appendix/springcloud/summary/#que-hemos-hecho","title":"\u00bfQu\u00e9 hemos hecho?","text":"Llegados a este punto, ya has podido comprobar que implementar una aplicaci\u00f3n orientada a micro servicios es bastante similar a una aplicaci\u00f3n monol\u00edtica, con la salvedad de que tienes que tener en cuenta la distribuci\u00f3n de estos, y por tanto su gesti\u00f3n y coordinaci\u00f3n.
En definitiva, lo que hemos implementado ha sido:
Service Discovery: Que ayudar\u00e1 a tener un cat\u00e1logo de todos las piezas de mi infraestructura, su IP, su puerto y ciertas m\u00e9tricas que ayuden luego en la elecci\u00f3n de servicio.
Gateway: Que centraliza las peticiones en un \u00fanico punto y permite hacer de balanceo de carga, seguridad, etc. Ser\u00e1 el punto de entrada a nuestro ecosistema.
Micro servicio Category: Contiene las operaciones sobre el \u00e1mbito funcional de categor\u00edas, guarda y recupera informaci\u00f3n de ese \u00e1mbito funcional.
Micro servicio Author: Contiene las operaciones sobre el \u00e1mbito funcional de autores, guarda y recupera informaci\u00f3n de ese \u00e1mbito funcional.
Micro servicio Game: Contiene las operaciones sobre el \u00e1mbito funcional de autores, guarda y recupera informaci\u00f3n de ese \u00e1mbito funcional. Adem\u00e1s, realiza llamadas entre los otros dos micro servicios para nutrir de m\u00e1s informaci\u00f3n sus endpoints.
El diagrama de nuestra aplicaci\u00f3n ahora es as\u00ed:
"},{"location":"appendix/springcloud/summary/#siguientes-pasos","title":"Siguientes pasos","text":"Bueno, el siguiente paso m\u00e1s evidente, ser\u00e1 ver que si conectas el frontend sigue funcionando exactamente igual que lo estaba haciendo antes.
Ahora te propongo hacer el mismo ejercicio con los otros dos m\u00f3dulos Cliente
y Pr\u00e9stamo
que has tenido que implementar en el punto Ahora hazlo tu!.
Ten en cuenta que Cliente
no depende de nadie, pero Pr\u00e9stamo
si que depende de Cliente
y de Game
. A ver como solucionas los cruces y sobre todo los filtros
Pues ya estar\u00eda todo, ahora solo te puedo dar la enhorabuena y pasar algo de informaci\u00f3n extra / cursos / formaciones por si quieres seguir aprendiendo.
Por un lado tienes el itinerario avanzado de Springboot donde se puede m\u00e1s detalle de micro servicios.
Por otro lado tambi\u00e9n tienes los itinerarios de Cloud ya que no todo va a ser micro servicios con Spring Cloud, tambi\u00e9n existen micro servicios con otras tecnolog\u00edas, aunque el concepto es muy similar.
"},{"location":"cleancode/angular/","title":"Estructura y Buenas pr\u00e1cticas - Angular","text":"Nota
Antes de empezar y para puntualizar, Angular se considera un framework SPA Single-page application.
En esta parte vamos a explicar los fundamentos de un proyecto en Angular y las recomendaciones existentes.
"},{"location":"cleancode/angular/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":""},{"location":"cleancode/angular/#ciclo-de-vida-de-angular","title":"Ciclo de vida de Angular","text":"El comportamiento de ciclo de vida de un componente Angular pasa por diferentes etapas que podemos ver en el esquema que mostramos a continuaci\u00f3n:
Es importante tenerlo claro para saber que m\u00e9todos podemos utilizar para realizar operaciones con el componente.
"},{"location":"cleancode/angular/#carpetas-creadas-por-angular","title":"Carpetas creadas por Angular","text":"Al crear una aplicaci\u00f3n Angular, tendremos los siguientes directorios:
Otros ficheros importantes de un proyecto de Angular
Otros archivos que debemos tener en cuenta dentro del proyecto son:
Existe m\u00faltiples consensos al respecto de como estructurar un proyecto en Angular, pero al final, depende de los requisitos del proyecto. Una sugerencia de como hacerlo es la siguiente:
- src\\app\n - core /* Componentes y utilidades comunes */ \n - header /* Estructura del header */ \n - footer /* Estructura del footer */ \n - domain1 /* M\u00f3dulo con los componentes del dominio1 */\n - services /* Servicios con operaciones del dominio1 */ \n - models /* Modelos de datos del dominio1 */ \n - component1 /* Componente1 del dominio1 */ \n - componentX /* ComponenteX del dominio1 */ \n - domainX /* As\u00ed para el resto de dominios de la aplicaci\u00f3n */\n
Recordar, que esto es una sugerencia para una estructura de carpetas y componentes. No existe un estandar.
ATENCI\u00d3N: Componentes gen\u00e9ricos
Debemos tener en cuenta que a la hora de programar un componente core
, lo ideal es pensar que sea un componente plug & play, es decir que si lo copias y lo llevas a otro proyecto funcione sin la necesidad de adaptarlo.
A continuaci\u00f3n veremos un listado de buenas pr\u00e1cticas de Angular y de c\u00f3digo limpio que deber\u00edamos intentar seguir en nuestro desarrollo.
"},{"location":"cleancode/angular/#estructura-de-archivos","title":"Estructura de archivos","text":"Antes de empezar con un proyecto lo ideal, es pararse y pensar en los requerimientos de una buena estructura, en un futuro lo agradecer\u00e1s.
"},{"location":"cleancode/angular/#nombres-claros","title":"Nombres claros","text":"Utilizar la S de los principios S.O.L.I.D para los nombres de variables, m\u00e9todos y dem\u00e1s c\u00f3digo.
El efecto que produce este principio son clases con nombres muy descriptivos y por tanto largos.
Tambi\u00e9n se recomienta utilizar kebab-case
para los nombres de ficheros. Ej. hero-button.component.ts
Intenta organizar tu c\u00f3digo fuente:
Un linter es una herramienta que nos ayuda a seguir las buenas pr\u00e1cticas o gu\u00edas de estilo de nuestro c\u00f3digo fuente. En este caso, para JavaScript, proveeremos de unos muy famosos. Una de las m\u00e1s famosas es la combinaci\u00f3n de Angular app to ESLint with Prettier, AirBnB Styleguide Recordar que a\u00f1adir este tipo de configuraci\u00f3n es opcional, pero necesaria para tener un buen c\u00f3digo de calidad.
"},{"location":"cleancode/angular/#git-hooks","title":"Git Hooks","text":"Los Git Hooks son scripts de shell que se ejecutan autom\u00e1ticamente antes o despu\u00e9s de que Git ejecute un comando importante como Commit o Push. Para hacer uso de el es tan sencillo como:
npm install husky --save-dev
Y a\u00f1adir en el fichero lo siguiente:
// package.json\n{\n\"husky\": {\n\"hooks\": {\n\"pre-commit\": \"npm test\",\n\"pre-push\": \"npm test\",\n\"...\": \"...\"\n}\n}\n}\n
Usar husky para el preformateo de c\u00f3digo antes de subirlo
Es una buena pr\u00e1ctica que todo el equipo use el mismo est\u00e1ndar de formateo de codigo, con husky se puede solucionar.
"},{"location":"cleancode/angular/#utilizar-banana-in-the-box","title":"Utilizar Banana in the Box","text":"Como el nombre sugiere banana in the box se debe a la forma que tiene lo siguiente: [{}] Esto es una forma muy sencilla de trabajar los cambios en la forma de Two ways binding. Es decir, el padre informa de un valor u objeto y el hijo lo manipula y actualiza el estado/valor al padre inmediatamente. La forma de implementarlo es sencillo
Padre: HTML:
<my-input [(text)]=\"text\"></my-input>
Hijo
@Input() value: string;\n@Output() valueChange = new EventEmitter<string>();\nupdateValue(value){\nthis.value = value;\nthis.valueChange.emit(value);\n}\n
Prefijo Change
Destacar que el prefijo 'Change' es necesario incluirlo en el Hijo para que funcione
"},{"location":"cleancode/angular/#correcto-uso-de-los-servicios","title":"Correcto uso de los servicios","text":"Una buena practica es aconsejable no declarar los servicios en el provides, sino usar un decorador que forma parte de las ultimas versiones de Angular
@Injectable({\nprovidedIn: 'root',\n})\nexport class HeroService {\nconstructor() { }\n}\n
"},{"location":"cleancode/angular/#lazy-load","title":"Lazy Load","text":"Lazy Load es un patr\u00f3n de dise\u00f1o que consiste en retrasar la carga o inicializaci\u00f3n
desde el app-routing.module.ts
A\u00f1adiremos un codigo parecido a este
{\npath: 'customers',\nloadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule)\n},\n
Con esto veremos que el m\u00f3dulo se cargar\u00e1 seg\u00fan se necesite.
"},{"location":"cleancode/nodejs/","title":"Estructura y Buenas pr\u00e1cticas - Nodejs","text":""},{"location":"cleancode/nodejs/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":"En los proyectos Nodejs no existe nada estandarizado y oficial que hable sobre estructura de proyectos y nomenclatura de Nodejs. Tan solo existen algunas sugerencias y buenas pr\u00e1cticas a la hora de desarrollar que te recomiendo que utilices en la medida de lo posible.
Tip
Piensa que el c\u00f3digo fuente que escribes hoy, es como un libro que se leer\u00e1 durante a\u00f1os. Alguien tendr\u00e1 que coger tu c\u00f3digo y leerlo en unos meses o a\u00f1os para hacer alguna modificaci\u00f3n y, como buenos desarrolladores que somos, tenemos la obligaci\u00f3n de facilitarle en todo lo posible la comprensi\u00f3n de ese c\u00f3digo fuente. Quiz\u00e1 esa persona futura podr\u00edas ser tu en unos meses y quedar\u00eda muy mal que no entendieras ni tu propio c\u00f3digo
"},{"location":"cleancode/nodejs/#estructura-en-capas","title":"Estructura en capas","text":"Todos los proyectos para crear una Rest API con node y express est\u00e1n divididos en capas. Como m\u00ednimo estar\u00e1 la capa de rutas, controlador y modelo. En nuestro caso vamos a a\u00f1adir una capa mas de servicios para quitarle trabajo al controlador y desacoplarlo de la capa de datos. As\u00ed si en el futuro queremos cambiar nuestra base de datos no romperemos tanto \ud83d\ude0a
Rutas
En nuestro proyecto una ruta ser\u00e1 una secci\u00f3n de c\u00f3digo express que asociar\u00e1 un verbo http, una ruta o patr\u00f3n de url y una funci\u00f3n perteneciente al controlador para manejar esa petici\u00f3n.
Controladores
En nuestros controladores tendremos los m\u00e9todos que obtendr\u00e1n las solicitudes de las rutas, se comunicar\u00e1n con la capa de servicio y convertir\u00e1n estas solicitudes en respuestas http.
Servicio
Nuestra capa de servicio incluir\u00e1 toda la l\u00f3gica de negocio de nuestra aplicaci\u00f3n. Para realizar sus operaciones puede realizar llamadas tanto a otras clases dentro de esta capa, como a clases de la capa inferior.
Modelo
Como su nombre indica esta capa representa los modelos de datos de nuestra aplicaci\u00f3n. En nuestro caso, al usar un ODM, solo tendremos modelos de datos definidos seg\u00fan sus requisitos.
"},{"location":"cleancode/nodejs/#buenas-practicas","title":"Buenas pr\u00e1cticas","text":""},{"location":"cleancode/nodejs/#accesos-entre-capas","title":"Accesos entre capas","text":"En base a la divisi\u00f3n por capas que hemos comentado arriba, y el resto de entidades implicadas, hay una serie de reglas important\u00edsimas que debes seguir muy de cerca:
Un Controlador
Un Servicio
Un linter es una herramienta que nos ayuda a seguir las buenas pr\u00e1cticas o gu\u00edas de estilo de nuestro c\u00f3digo fuente. En este caso, para JavaScript, proveeremos de unos muy famosos. Una de las m\u00e1s famosas es la combinaci\u00f3n de Angular app to ESLint with Prettier, AirBnB Styleguide Recordar que a\u00f1adir este tipo de configuraci\u00f3n es opcional, pero necesaria para tener un buen c\u00f3digo de calidad.
"},{"location":"cleancode/react/","title":"Estructura y Buenas pr\u00e1cticas - React","text":"Nota
Antes de empezar y para puntualizar, React se considera un framework SPA Single-page application.
Aqu\u00ed tenemos que puntualizar que React por s\u00ed mismo es una librer\u00eda y no un framework, puesto que se ocupa de las interfaces de usuario. Sin embargo, diversos a\u00f1adidos pueden convertir a React en un producto equiparable en caracter\u00edsticas a un framework.
En esta parte vamos a explicar los fundamentos de un proyecto en React y las recomendaciones existentes.
"},{"location":"cleancode/react/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":""},{"location":"cleancode/react/#como-funciona-react","title":"Como funciona React","text":"React es una herramienta para crear interfaces de usuario de una manera \u00e1gil y vers\u00e1til, en lugar de manipular el DOM del navegador directamente, React crea un DOM virtual en la memoria, d\u00f3nde realiza toda la manipulaci\u00f3n necesaria antes de realizar los cambios en el DOM del navegador. Estas interfaces de usuario denominadas componentes pueden definirse como clases o funciones independiente y reutilizables con unos par\u00e1metros de entrada que devuelven elementos de react. En ese tutorial solo utilizaremos componentes de tipo funci\u00f3n.
Por si no te suena, un componente web es una forma de crear un bloque de c\u00f3digo encapsulado y de responsabilidad \u00fanica que puede reutilizarse en cualquier pagina mediante nuevas etiquetas html.
Nota
Desde la versi\u00f3n 16.8 se introdujo en React el concepto de hooks. Esto permiti\u00f3 usar el estado y otras caracter\u00edsticas de React sin necesidad de escribir una clase.
"},{"location":"cleancode/react/#ciclo-de-vida-de-un-componente-en-react","title":"Ciclo de vida de un componente en React","text":"El comportamiento de ciclo de vida de un componente React pasa por diferentes etapas que podemos ver en el esquema que mostramos a continuaci\u00f3n:
Es importante tenerlo claro para saber que m\u00e9todos podemos utilizar para realizar operaciones con el componente.
"},{"location":"cleancode/react/#carpetas-creadas-por-react","title":"Carpetas creadas por React","text":"Al crear una aplicaci\u00f3n React, tendremos los siguientes directorios:
Otros ficheros importantes de un proyecto de React
Otros archivos que debemos tener en cuenta dentro del proyecto son:
Existe m\u00faltiples consensos al respecto de c\u00f3mo estructurar un proyecto en React, pero al final, depende de los requisitos del proyecto. Una sugerencia de c\u00f3mo hacerlo es la siguiente:
- src\\\n - components /* Componentes comunes */ \n - context /* Carpeta para almacenar el contexto de la aplicaci\u00f3n */ \n - pages /* Carpeta para componentes asociados a rutas del navegador */\n - components /* Componentes propios de cada p\u00e1gina */ \n - redux /* Para todo aquello relacionado con el estado de nuestra aplicaci\u00f3n */\n - types /* Carpeta para los tipos de datos de typescript */\n
Recordad, que \u00e9sto es una sugerencia para una estructura de carpetas y componentes. No existe un est\u00e1ndar.
"},{"location":"cleancode/react/#buenas-practicas","title":"Buenas pr\u00e1cticas","text":"A continuaci\u00f3n, veremos un listado de buenas pr\u00e1cticas de React y de c\u00f3digo limpio que deber\u00edamos intentar seguir en nuestro desarrollo.
"},{"location":"cleancode/react/#estructura-de-archivos","title":"Estructura de archivos","text":"Antes de empezar con un proyecto lo ideal, es pararse y pensar en los requerimientos de una buena estructura, en un futuro lo agradecer\u00e1s.
"},{"location":"cleancode/react/#nombres-claros","title":"Nombres claros","text":"Utilizar la S de los principios S.O.L.I.D para los nombres de variables, m\u00e9todos y dem\u00e1s c\u00f3digo.
El efecto que produce este principio son clases con nombres muy descriptivos y por tanto largos.
"},{"location":"cleancode/react/#organiza-tu-codigo","title":"Organiza tu c\u00f3digo","text":"Intenta organizar tu c\u00f3digo fuente:
Un linter es una herramienta que nos ayuda a seguir las buenas pr\u00e1cticas o gu\u00edas de estilo de nuestro c\u00f3digo fuente. En este caso, para JavaScript, proveeremos de unos muy famosos. Recordar que a\u00f1adir este tipo de configuraci\u00f3n es opcional, pero necesaria para tener un buen c\u00f3digo de calidad.
"},{"location":"cleancode/react/#usa-el-estado-correctamente","title":"Usa el estado correctamente","text":"La primera regla del hook useState es usarlo solo localmente. El estado global de nuestra aplicaci\u00f3n debe de entrar a nuestro componente a trav\u00e9s de las props as\u00ed como las mutaciones de este solo deben realizarse mediante alguna herramienta de gesti\u00f3n de estados como redux. Por otro lado, es preferible no abusar de los hooks y solo usarlos cuando sea realmente necesario ya que pueden reducir el rendimiento de nuestra aplicaci\u00f3n.
"},{"location":"cleancode/react/#reutiliza-codigo-y-componentes","title":"Reutiliza c\u00f3digo y componentes","text":"Siempre que sea posible deberemos de reutilizar c\u00f3digo mediante funciones compartidas o bien si este c\u00f3digo implica almacenamiento de estado u otras caracter\u00edsticas similares mediante custom Hooks.
"},{"location":"cleancode/react/#usa-ts-en-lugar-de-js","title":"Usa TS en lugar de JS","text":"Ya hemos creado nuestro proyecto incluyendo typescript pero esto no viene por defecto en un proyecto React como si pasa con Angular. Nuestra recomendaci\u00f3n es que siempre que puedas a\u00f1adas typescript a tus proyectos React, no solo se gana calidad en el c\u00f3digo, sino que eliminamos la probabilidad de usar un componente incorrectamente y ganamos tiempo de desarrollo.
"},{"location":"cleancode/springboot/","title":"Estructura y Buenas pr\u00e1cticas - Spring Boot","text":""},{"location":"cleancode/springboot/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":"En Springboot no existe nada estandarizado y oficial que hable sobre estructura de proyectos y nomenclatura. Tan solo existen algunas sugerencias y buenas pr\u00e1cticas a la hora de desarrollar que te recomiendo que utilices en la medida de lo posible.
Tip
Piensa que el c\u00f3digo fuente que escribes hoy, es como un libro que se leer\u00e1 durante a\u00f1os. Alguien tendr\u00e1 que coger tu c\u00f3digo y leerlo en unos meses o a\u00f1os para hacer alguna modificaci\u00f3n y, como buenos desarrolladores que somos, tenemos la obligaci\u00f3n de facilitarle en todo lo posible la comprensi\u00f3n de ese c\u00f3digo fuente. Quiz\u00e1 esa persona futura podr\u00edas ser tu en unos meses y quedar\u00eda muy mal que no entendieras ni tu propio c\u00f3digo
"},{"location":"cleancode/springboot/#estructura-en-capas","title":"Estructura en capas","text":"Todos los proyectos web que construimos basados en Springboot se caracterizan por estar divididos en tres capas (a menos que utilicemos DDD para desarrollar que entonces existen infinitas capas ).
Servicios
. Es la capa intermedia que da soporte a las operaciones que est\u00e1n expuestas y ejecutan toda la l\u00f3gica de negocio de la aplicaci\u00f3n. Para realizar sus operaciones puede realizar llamadas tanto a otras clases dentro de esta capa, como a clases de la capa inferior.finales
, no pueden llamar a ninguna otra clase para ejecutar sus operaciones, ni siquiera de su misma capa.En proyectos medianos o grandes, estructurar los directorios del proyecto en base a la estructura anteriormente descrita ser\u00eda muy complejo, ya que en cada uno de los niveles tendr\u00edamos muchas clases. As\u00ed que lo normal es diferenciar por \u00e1mbito funcional y dentro de cada package
realizar la separaci\u00f3n en Controlador
, L\u00f3gica
y Acceso a datos
.
Tened en cuenta en un mismo \u00e1mbito funcional puede tener varios controladores o varios servicios de l\u00f3gica uno por cada entidad que estemos tratando. Siempre que se pueda, agruparemos entidades que intervengan dentro de una misma funcionalidad.
En nuestro caso del tutorial, tendremos tres \u00e1mbitos funcionales Categor\u00eda
, Autor
, y Juego
que diferenciaremos cada uno con su propia estructura.
@TODO: En construcci\u00f3n
En base a la divisi\u00f3n por capas que hemos comentado arriba, y el resto de entidades implicadas, hay una serie de reglas important\u00edsimas que debes seguir muy de cerca:
Controlador
L\u00f3gica
.Acceso a Datos
, siempre debe pasar por la capa L\u00f3gica
.Entity
.Entity
y Dto
.save
para guardar, usemos esa palabra en todas las operaciones que sean de ese tipo. Evitad utilizar diferentes palabras save
, guardar
, persistir
, actualizar
para la misma acci\u00f3n.Servicio
Controlador
.Acceso a Datos
.Acceso a Datos
que NO sean de su \u00e1mbito / competencia.Servicios
para recuperar cierta informaci\u00f3n que no sea de su \u00e1mbito / competencia.Entity
.Acceso a Datos
Controlador
, ni Servicios
, ni Acceso a Datos
.Servicios
.Nota
Antes de empezar y para puntualizar, Vue.js es un framework progresivo para construir interfaces de usuario. A diferencia de otros frameworks monol\u00edticos, Vue.js est\u00e1 dise\u00f1ado desde cero para ser utilizado incrementalmente. La librer\u00eda central est\u00e1 enfocada solo en la capa de visualizaci\u00f3n, y es f\u00e1cil de utilizar e integrar con otras librer\u00edas o proyectos existentes. Por otro lado, Vue.js tambi\u00e9n es perfectamente capaz de impulsar sofisticadas Single-Page Applications cuando se utiliza en combinaci\u00f3n con herramientas modernas y librer\u00edas de apoyo.
En esta parte vamos a explicar los fundamentos de un proyecto en Vue.js y las recomendaciones existentes.
"},{"location":"cleancode/vuejs/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":""},{"location":"cleancode/vuejs/#ciclos-de-vida-de-un-componente","title":"Ciclos de vida de un componente","text":"Vue.js cuenta con un conjunto de ciclos de vida que permiten a los desarrolladores controlar y personalizar el comportamiento de sus componentes en diferentes momentos. Estos ciclos de vida se pueden agrupar en tres fases principales: creaci\u00f3n, actualizaci\u00f3n y eliminaci\u00f3n.
A continuaci\u00f3n, te explicar\u00e9 cada uno de los ciclos de vida disponibles en Vue.js junto con la Options API:
beforeCreate: Este ciclo de vida se ejecuta inmediatamente despu\u00e9s de que se haya creado una instancia de componente, pero antes de que se haya creado su DOM. En este punto, a\u00fan no es posible acceder a las propiedades del componente y a\u00fan no se han establecido las observaciones reactivas.
created: Este ciclo de vida se ejecuta despu\u00e9s de que se haya creado una instancia de componente y se hayan establecido las observaciones reactivas. En este punto, el componente ya puede acceder a sus propiedades y m\u00e9todos.
beforeMount: Este ciclo de vida se ejecuta justo antes de que el componente se monte en el DOM. En este punto, el componente ya est\u00e1 preparado para ser renderizado, pero a\u00fan no se ha agregado al \u00e1rbol de elementos del DOM.
mounted: Este ciclo de vida se ejecuta despu\u00e9s de que el componente se ha montado en el DOM. En este punto, el componente ya est\u00e1 en el \u00e1rbol de elementos del DOM y se puede acceder a sus elementos hijos y a los elementos del DOM que lo rodean.
beforeUpdate: Este ciclo de vida se ejecuta justo antes de que el componente se actualice en respuesta a un cambio en sus propiedades o estado. En este punto, el componente a\u00fan no se ha actualizado en el DOM.
updated: Este ciclo de vida se ejecuta despu\u00e9s de que el componente se haya actualizado en el DOM en respuesta a un cambio en sus propiedades o estado. En este punto, el componente ya se ha actualizado en el DOM y se puede acceder a sus elementos hijos y a los elementos del DOM que lo rodean.
beforeUnmount: Este ciclo de vida se ejecuta justo antes de que el componente se elimine del DOM. En este punto, el componente a\u00fan est\u00e1 en el \u00e1rbol de elementos del DOM.
unmounted: Este ciclo de vida se ejecuta despu\u00e9s de que el componente se haya eliminado del DOM. En este punto, el componente ya no est\u00e1 en el \u00e1rbol de elementos del DOM y no se puede acceder a sus elementos hijos.
errorCaptured: Este ciclo de vida se ejecuta cuando se produce un error en cualquier descendiente del componente y se captura en el componente actual. Esto permite que el componente maneje el error de forma personalizada en lugar de propagarse hacia arriba en la cadena de componentes.
activated: Este ciclo de vida se ejecuta cuando un componente que se encuentra en un \u00e1rbol de componentes inactivo (por ejemplo, un componente en una pesta\u00f1a inactiva) se activa.
deactivated: Este ciclo de vida se ejecuta cuando un componente que se encuentra en un \u00e1rbol de componentes activo (por ejemplo, un componente en una pesta\u00f1a activa) se desactiva y se vuelve inactivo.
renderTracked: Este ciclo de vida se ejecuta cuando se observa una dependencia en el proceso de renderizado del componente. Esto se utiliza principalmente para fines de depuraci\u00f3n.
renderTriggered: Este ciclo de vida se ejecuta cuando se desencadena un nuevo renderizado del componente. Esto se utiliza principalmente para fines de depuraci\u00f3n.
serverPrefetch: Este ciclo de vida se utiliza en el contexto de renderizado del lado del servidor (SSR). Se ejecuta cuando el componente se preprocesa en el servidor antes de enviarse al cliente. En este punto, el componente a\u00fan no se ha montado en el DOM y no se pueden realizar operaciones que dependan del DOM. Esto se utiliza principalmente para cargar datos de forma as\u00edncrona antes de que se renderice el componente en el servidor.
Os dejo un peque\u00f1o esquema de los ciclos de vida mas importantes y en que momento se ejecutan:
Es importante tenerlo claro para saber que m\u00e9todos podemos utilizar para realizar operaciones con el componente.
"},{"location":"cleancode/vuejs/#carpetas-creadas-por-vuejs","title":"Carpetas creadas por Vue.js","text":"Otros ficheros importantes de un proyecto de Vue.js
Otros archivos que debemos tener en cuenta dentro del proyecto son:
A continuaci\u00f3n veremos un listado de buenas pr\u00e1cticas de Vue.js y de c\u00f3digo limpio que deber\u00edamos intentar seguir en nuestro desarrollo.
"},{"location":"cleancode/vuejs/#estructura-de-archivos","title":"Estructura de archivos","text":"Antes de empezar con un proyecto lo ideal, es pararse y pensar en los requerimientos de una buena estructura, en un futuro lo agradecer\u00e1s.
"},{"location":"cleancode/vuejs/#nombres-claros","title":"Nombres claros","text":"Determinar una manera de nombrar a los componentes (UpperCamelCase, lowerCamelCase, kebab-case, snake_case, ...) y continuarla para todos los archivos, nombres descriptivos de los componentes y en una ruta acorde (si es un componente que forma parte de una pantalla, se ubicar\u00e1 dentro de la carpeta de esa pantalla pero si se usa en m\u00e1s de una pantalla, se ubicar\u00e1 en una carpeta externa a cualquier pantalla llamada common), componentes de m\u00e1ximo 350 l\u00edneas y componentes con finalidad \u00fanica (recibe los datos necesarios para realizar las tareas b\u00e1sicas de ese componente).
"},{"location":"cleancode/vuejs/#organiza-tu-codigo","title":"Organiza tu c\u00f3digo","text":"El c\u00f3digo debe estar ordenado dentro de los componente siguiendo un orden de importancia similar a este:
Modificaciones entre componentes
A la hora de crear un componente b\u00e1sico (como un input propio) que necesite modificar su propio valor (algo que un componente hijo no debe hacer, ya que la variable estar\u00e1 en el padre), saber diferenciar entre v-model y modelValue (esta \u00faltima s\u00ed que permite modificar el valor en el padre mediante el evento update:modelValue sin tener que hacer nada m\u00e1s en el padre que pasarle el valor).
Utiliza formateo y correcci\u00f3n de c\u00f3digo
Si has seguido nuestro tutorial se habr\u00e1 instalado ESLint y Prettier. Si no, deber\u00edas instalarlo para generar c\u00f3digo de buena calidad. Adem\u00e1s de instalar alguna extensi\u00f3n en Visual Studio Code que te ayude a gestionar esas herramientas.
Nomenclatura de funciones y variables
El nombre de las funciones, al igual que los path de una API, deber\u00edan ser autoexplicativos y no tener que seguir la traza del c\u00f3digo para saber qu\u00e9 hace. Con un buen nombre para cada funci\u00f3n o variables de estado, evitas tener que a\u00f1adir comentarios para explicar qu\u00e9 hace o qu\u00e9 almacena cada una de ellas.
"},{"location":"develop/basic/angular/","title":"Listado simple - Angular","text":"Ahora que ya tenemos listo el proyecto frontend de Angular (en el puerto 4200), ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/angular/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 que Angular tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. Tanto en la propia web de documentaci\u00f3n de Angular como en la web de componentes Angular Material puedes buscar casi cualquier ejemplo que necesites.
Si abrimos el proyecto con el IDE que tengamos (Visual Studio Code en el caso del tutorial) podemos ver que en la carpeta src/app
existen unos ficheros ya creados por defecto. Estos ficheros son:
app.component.ts
\u2192 contiene el c\u00f3digo inicial del proyecto escrito en TypeScript.app.component.html
\u2192 contiene la plantilla inicial del proyecto escrita en HTML.app.component.scss
\u2192 contiene los estilos CSS privados de la plantilla inicial.Vamos a modificar este c\u00f3digo inicial para ver como funciona. Abrimos el fichero app.component.ts
y modificamos la l\u00ednea donde se asigna un valor a la variable title
.
...\ntitle = 'Tutorial de Angular';\n...\n
Ahora abrimos el fichero app.component.html
, borramos todo el c\u00f3digo de la plantilla y a\u00f1adimos el siguiente c\u00f3digo:
<h1>{{title}}</h1>\n
Las llaves dobles permiten hacen un binding entre el c\u00f3digo del componente y la plantilla. Es decir, en este caso ir\u00e1 al c\u00f3digo TypeScript y buscar\u00e1 el valor de la variable title
.
Consejo
El binding tambi\u00e9n nos sirve para ejecutar los m\u00e9todos de TypeScript desde el c\u00f3digo HTML. Adem\u00e1s si el valor que contiene la variable se modificara durante la ejecuci\u00f3n de alg\u00fan m\u00e9todo, autom\u00e1ticamente el c\u00f3digo HTML refrescar\u00eda el nuevo valor de la variable title
Si abrimos el navegador y accedemos a http://localhost:4200/
podremos ver el resultado del c\u00f3digo.
Lo primero que vamos a hacer es escoger un tema y una paleta de componentes para trabajar. Lo m\u00e1s c\u00f3modo es trabajar con Material
que ya viene perfectamente integrado en Angular. Ejecutamos el comando y elegimos la paleta de colores que m\u00e1s nos guste o bien creamos una custom:
ng add @angular/material\n
Recuerda
Al a\u00f1adir una nueva librer\u00eda tenemos que parar el servidor y volver a arrancarlo para que compile y precargue las nuevas dependencias.
Una vez a\u00f1adida la dependencia, lo que queremos es crear una primera estructura inicial a la p\u00e1gina. Si te acuerdas cual era la estructura (y si no te acuerdas, vuelve a la secci\u00f3n Contexto de la aplicaci\u00f3n
y lo revisas), ten\u00edamos una cabecera superior con un logo y t\u00edtulo y unas opciones de men\u00fa.
Pues vamos a ello, crearemos esa estructura com\u00fan para toda la aplicaci\u00f3n. Este componente al ser algo core para toda la aplicaci\u00f3n deber\u00edamos crearlo dentro del m\u00f3dulo core
como ya vimos anteriormente.
Pero antes de todo, vamos a crear los m\u00f3dulos generales de la aplicaci\u00f3n, as\u00ed que ejecutamos en consola el comando que nos permite crear un m\u00f3dulo nuevo:
ng generate module core\n
Y a\u00f1adimos esos m\u00f3dulos al m\u00f3dulo padre de la aplicaci\u00f3n:
app.module.tsimport { BrowserModule } from '@angular/platform-browser';\nimport { NgModule } from '@angular/core';\n\nimport { AppRoutingModule } from './app-routing.module';\nimport { AppComponent } from './app.component';\nimport { BrowserAnimationsModule } from '@angular/platform-browser/animations';\nimport { CoreModule } from './core/core.module';\n@NgModule({\ndeclarations: [\nAppComponent\n],\nimports: [\nBrowserModule,\nAppRoutingModule,\nCoreModule,\nBrowserAnimationsModule,\n],\nproviders: [],\nbootstrap: [AppComponent]\n})\nexport class AppModule { }\n
Y despu\u00e9s crearemos el componente header, dentro del m\u00f3dulo core. Para eso ejecutaremos el comando:
ng generate component core/header\n
"},{"location":"develop/basic/angular/#codigo-de-la-pantalla","title":"C\u00f3digo de la pantalla","text":"Esto nos crear\u00e1 una carpeta con los ficheros del componente, donde tendremos que copiar el siguiente contenido:
header.component.htmlheader.component.scss<mat-toolbar>\n <mat-toolbar-row>\n <div class=\"header_container\">\n <div class=\"header_title\"> \n <mat-icon>storefront</mat-icon> Ludoteca Tan\n </div>\n\n <div class=\"header_separator\"> | </div>\n\n <div class=\"header_menu\">\n <div class=\"header_button\">\n <a routerLink=\"/games\" routerLinkActive=\"active\">Cat\u00e1logo</a>\n </div>\n <div class=\"header_button\">\n <a routerLink=\"/categories\" routerLinkActive=\"active\">Categor\u00edas</a>\n </div>\n <div class=\"header_button\">\n <a routerLink=\"/authors\" routerLinkActive=\"active\">Autores</a>\n </div>\n </div>\n\n <div class=\"header_login\">\n <mat-icon>account_circle</mat-icon> Sign in\n </div>\n </div>\n </mat-toolbar-row>\n</mat-toolbar>\n
.mat-toolbar {\nbackground-color: blue;\ncolor: white;\n}\n\n.header_container {\ndisplay: flex;\nwidth: 100%;\n.header_title {\n.mat-icon {\nvertical-align: sub;\n}\n}\n\n.header_separator {\nmargin-left: 30px;\nmargin-right: 30px;\n}\n\n.header_menu {\nflex-grow: 4;\ndisplay: flex;\nflex-direction: row;\n\n.header_button {\nmargin-left: 1em;\nmargin-right: 1em;\nfont-size: 16px;\n\na {\nfont-weight: lighter;\ntext-decoration: none;\ncursor: pointer;\ncolor: white;\n}\n\na:hover {\ncolor: grey;\n}\n\na.active {\nfont-weight: normal;\ntext-decoration: underline;\ncolor: lightyellow;\n}\n\n}\n}\n\n.header_login {\nfont-size: 16px;\ncursor: pointer;\n.mat-icon {\nvertical-align: sub;\n}\n}\n}\n
Al utilizar etiquetas de material como mat-toolbar
o mat-icon
y routerLink
necesitaremos importar las dependencias. Esto lo podemos hacer directamente en el m\u00f3dulo del que depende, es decir en el fichero core.module.ts
import { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatToolbarModule } from '@angular/material/toolbar';\nimport { HeaderComponent } from './header/header.component';\nimport { RouterModule } from '@angular/router';\n@NgModule({\ndeclarations: [HeaderComponent],\nimports: [\nCommonModule,\nRouterModule,\nMatIconModule, MatToolbarModule,\n],\nexports: [\nHeaderComponent\n]\n})\nexport class CoreModule { }\n
Adem\u00e1s de a\u00f1adir las dependencias, diremos que este m\u00f3dulo va a exportar el componente HeaderComponent
para poder utilizarlo desde otras p\u00e1ginas.
Ya por \u00faltimo solo nos queda modificar la p\u00e1gina general de la aplicaci\u00f3n app.component.html
para a\u00f1adirle el componente HeaderComponent
.
<div>\n<app-header></app-header>\n<div>\n <router-outlet></router-outlet>\n </div>\n</div>\n
Vamos al navegador y refrescamos la p\u00e1gina, deber\u00eda aparecer una barra superior (Header) con las opciones de men\u00fa. Algo similar a esto:
Recuerda
Cuando se a\u00f1aden componentes a los ficheros html
, siempre se deben utilizar los selectores definidos para el componente. En el caso anterior hemos a\u00f1adido app-header
que es el mismo nombre selector que tiene el componente en el fichero header.component.ts
. Adem\u00e1s, recuerda que para poder utilizar componentes de otros m\u00f3dulos, los debes exportar ya que de lo contrario tan solo podr\u00e1n utilizarse dentro del m\u00f3dulo donde se declaran.
Ya tenemos la estructura principal, ahora vamos a crear nuestra primera pantalla. Vamos a empezar por la de Categor\u00edas
que es la m\u00e1s sencilla, ya que se trata de un listado, que muestra datos sin filtrar ni paginar.
Como categor\u00edas es un dominio funcional de la aplicaci\u00f3n, vamos a crear un m\u00f3dulo que contenga toda la funcionalidad de ese dominio. Ejecutamos en consola:
ng generate module category\n
Y por tanto, al igual que hicimos anteriormente, hay que a\u00f1adir el m\u00f3dulo al fichero app.module.ts
import { BrowserModule } from '@angular/platform-browser';\nimport { NgModule } from '@angular/core';\n\nimport { AppRoutingModule } from './app-routing.module';\nimport { AppComponent } from './app.component';\nimport { BrowserAnimationsModule } from '@angular/platform-browser/animations';\nimport { CoreModule } from './core/core.module';\nimport { CategoryModule } from './category/category.module';\n@NgModule({\ndeclarations: [\nAppComponent\n],\nimports: [\nBrowserModule,\nAppRoutingModule,\nCoreModule,\nCategoryModule,\nBrowserAnimationsModule,\n],\nproviders: [],\nbootstrap: [AppComponent]\n})\nexport class AppModule { }\n
Ahora todas las pantallas, componentes y servicios que creemos, referidos a este dominio funcional, deber\u00e1n ir dentro del modulo cagegory
.
Vamos a crear un primer componente que ser\u00e1 un listado de categor\u00edas. Para ello vamos a ejecutar el siguiente comando:
ng generate component category/category-list\n
Para terminar de configurar la aplicaci\u00f3n, vamos a a\u00f1adir la ruta del componente dentro del componente routing de Angular, para poder acceder a \u00e9l, para ello modificamos el fichero app-routing.module.ts
import { NgModule } from '@angular/core';\nimport { Routes, RouterModule } from '@angular/router';\nimport { CategoryListComponent } from './category/category-list/category-list.component';\nconst routes: Routes = [\n{ path: 'categories', component: CategoryListComponent },\n];\n\n@NgModule({\nimports: [RouterModule.forRoot(routes)],\nexports: [RouterModule]\n})\nexport class AppRoutingModule { }\n
Si abrimos el navegador y accedemos a http://localhost:4200/
podremos navegar mediante el men\u00fa Categor\u00edas
el cual abrir\u00e1 el componente que acabamos de crear.
Ahora vamos a construir la pantalla. Para manejar la informaci\u00f3n del listado, necesitamos almacenar los datos en un objeto de tipo model
. Para ello crearemos un fichero en category\\model\\Category.ts
donde implementaremos la clase necesaria. Esta clase ser\u00e1 la que utilizaremos en el c\u00f3digo html y ts de nuestro componente.
export class Category {\nid: number;\nname: string;\n}\n
Tambi\u00e9n, escribiremos el c\u00f3digo de la pantalla de listado.
category-list.component.htmlcategory-list.component.scsscategory-list.component.ts<div class=\"container\">\n <h1>Listado de Categor\u00edas</h1>\n\n<mat-table [dataSource]=\"dataSource\">\n<ng-container matColumnDef=\"id\">\n<mat-header-cell *matHeaderCellDef> Identificador </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.id}} </mat-cell>\n </ng-container>\n\n<ng-container matColumnDef=\"name\">\n<mat-header-cell *matHeaderCellDef> Nombre categor\u00eda </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.name}} </mat-cell>\n </ng-container>\n\n<ng-container matColumnDef=\"action\">\n<mat-header-cell *matHeaderCellDef></mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n <button mat-icon-button color=\"primary\"><mat-icon>edit</mat-icon></button>\n <button mat-icon-button color=\"accent\"><mat-icon>clear</mat-icon></button>\n </mat-cell>\n </ng-container>\n\n<mat-header-row *matHeaderRowDef=\"displayedColumns; sticky: true\"></mat-header-row>\n<mat-row *matRowDef=\"let row; columns: displayedColumns;\"></mat-row>\n</mat-table>\n\n <div class=\"buttons\">\n <button mat-flat-button color=\"primary\">Nueva categor\u00eda</button>\n </div> \n</div>\n
.container {\nmargin: 20px;\n\nmat-table {\nmargin-top: 10px;\nmargin-bottom: 20px;\n\n.mat-header-row {\nbackground-color:#f5f5f5;\n\n.mat-header-cell {\ntext-transform: uppercase;\nfont-weight: bold;\ncolor: #838383;\n} }\n\n.mat-column-id {\nflex: 0 0 20%;\njustify-content: center;\n}\n\n.mat-column-action {\nflex: 0 0 10%;\njustify-content: center;\n}\n}\n\n.buttons {\ntext-align: right;\n}\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatTableDataSource } from '@angular/material/table';\nimport { Category } from '../model/Category';\n@Component({\nselector: 'app-category-list',\ntemplateUrl: './category-list.component.html',\nstyleUrls: ['./category-list.component.scss']\n})\nexport class CategoryListComponent implements OnInit {\n\ndataSource = new MatTableDataSource<Category>();\ndisplayedColumns: string[] = ['id', 'name', 'action'];\nconstructor() { }\n\nngOnInit(): void {\n}\n\n}\n
El c\u00f3digo HTML es f\u00e1cil de seguir pero por si acaso:
dataSource
definida en el fichero .tsY ya por \u00faltimo, a\u00f1adimos los componentes que se han utilizado de Angular Material a las dependencias del m\u00f3dulo donde est\u00e1 definido el componente en este caso category\\category.module.ts
:
import { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { MatTableModule } from '@angular/material/table';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatButtonModule } from '@angular/material/button';\nimport { CategoryListComponent } from './category-list/category-list.component';\n\n@NgModule({\ndeclarations: [CategoryListComponent],\nimports: [\nCommonModule,\nMatTableModule,\nMatIconModule, MatButtonModule\n],\n})\nexport class CategoryModule { }\n
Si abrimos el navegador y accedemos a http://localhost:4200/
y pulsamos en el men\u00fa de Categor\u00edas
obtendremos una pantalla con un listado vac\u00edo (solo con cabeceras) y un bot\u00f3n de crear Nueva Categor\u00eda que aun no hace nada.
En este punto y para ver como responde el listado, vamos a a\u00f1adir datos. Si tuvieramos el backend implementado podr\u00edamos consultar los datos directamente de una operaci\u00f3n de negocio de backend, pero ahora mismo no lo tenemos implementado as\u00ed que para no bloquear el desarrollo vamos a mockear los datos.
"},{"location":"develop/basic/angular/#creando-un-servicio","title":"Creando un servicio","text":"En angular, cualquier acceso a datos debe pasar por un service
, as\u00ed que vamos a crearnos uno para todas las operaciones de categor\u00edas. Vamos a la consola y ejecutamos:
ng generate service category/category\n
Esto nos crear\u00e1 un servicio, que adem\u00e1s podemos utilizarlo inyect\u00e1ndolo en cualquier componente que lo necesite.
"},{"location":"develop/basic/angular/#implementando-un-servicio","title":"Implementando un servicio","text":"Vamos a implementar una operaci\u00f3n de negocio que recupere el listado de categor\u00edas y lo vamos a hacer de forma reactiva (as\u00edncrona) para simular una petici\u00f3n a backend. Modificamos los siguientes ficheros:
category.service.tscategory-list.component.tsimport { Injectable } from '@angular/core';\nimport { Observable } from 'rxjs';\nimport { Category } from './model/Category';\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService {\n\nconstructor() { }\n\ngetCategories(): Observable<Category[]> {\nreturn new Observable();\n}\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatTableDataSource } from '@angular/material/table';\nimport { Category } from '../model/Category';\nimport { CategoryService } from '../category.service';\n@Component({\nselector: 'app-category-list',\ntemplateUrl: './category-list.component.html',\nstyleUrls: ['./category-list.component.scss']\n})\nexport class CategoryListComponent implements OnInit {\n\ndataSource = new MatTableDataSource<Category>();\ndisplayedColumns: string[] = ['id', 'name', 'action'];\n\nconstructor(\nprivate categoryService: CategoryService,\n) { }\n\nngOnInit(): void {\nthis.categoryService.getCategories().subscribe(\ncategories => this.dataSource.data = categories\n);\n}\n}\n
"},{"location":"develop/basic/angular/#mockeando-datos","title":"Mockeando datos","text":"Como hemos comentado anteriormente, el backend todav\u00eda no est\u00e1 implementado as\u00ed que vamos a mockear datos. Nos crearemos un fichero mock-categories.ts
dentro de model, con datos ficticios y modificaremos el servicio para que devuelva esos datos. De esta forma, cuando tengamos implementada la operaci\u00f3n de negocio en backend, tan solo tenemos que sustuir el c\u00f3digo que devuelve datos est\u00e1ticos por una llamada http.
import { Category } from \"./Category\";\n\nexport const CATEGORY_DATA: Category[] = [\n{ id: 1, name: 'Dados' },\n{ id: 2, name: 'Fichas' },\n{ id: 3, name: 'Cartas' },\n{ id: 4, name: 'Rol' },\n{ id: 5, name: 'Tableros' },\n{ id: 6, name: 'Tem\u00e1ticos' },\n{ id: 7, name: 'Europeos' },\n{ id: 8, name: 'Guerra' },\n{ id: 9, name: 'Abstractos' },\n]
import { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\nimport { CATEGORY_DATA } from './model/mock-categories';\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService {\n\nconstructor() { }\n\ngetCategories(): Observable<Category[]> {\nreturn of(CATEGORY_DATA);\n}\n}\n
Si ahora refrescamos la p\u00e1gina web, veremos que el listado ya tiene datos con los que vamos a interactuar.
"},{"location":"develop/basic/angular/#simulando-las-otras-peticiones","title":"Simulando las otras peticiones","text":"Para terminar, vamos a simular las otras dos peticiones, la de editar y la de borrar para cuando tengamos que utilizarlas. El servicio debe quedar m\u00e1s o menos as\u00ed:
category.service.tsimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\nimport { CATEGORY_DATA } from './model/mock-categories';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService {\n\nconstructor() { }\n\ngetCategories(): Observable<Category[]> {\nreturn of(CATEGORY_DATA);\n}\n\nsaveCategory(category: Category): Observable<Category> {\nreturn of(null);\n}\ndeleteCategory(idCategory : number): Observable<any> {\nreturn of(null);\n} }\n
"},{"location":"develop/basic/angular/#anadiendo-acciones-al-listado","title":"A\u00f1adiendo acciones al listado","text":""},{"location":"develop/basic/angular/#crear-componente_2","title":"Crear componente","text":"Ahora nos queda a\u00f1adir las acciones al listado: crear, editar y eliminar. Empezaremos primero por las acciones de crear y editar, que ambas deber\u00edan abrir una ventana modal con un formulario para poder modificar datos de la entidad Categor\u00eda
. Como siempre, para crear un componente usamos el asistente de Angular, esta vez al tratarse de una pantalla que solo vamos a utilizar dentro del dominio de categor\u00edas, tiene sentido que lo creemos dentro de ese m\u00f3dulo:
ng generate component category/category-edit\n
Ahora vamos a hacer que se abra al pulsar el bot\u00f3n Nueva categor\u00eda
. Para eso, vamos al fichero category-list.component.ts
y a\u00f1adimos un nuevo m\u00e9todo:
...\nimport { MatDialog } from '@angular/material/dialog';\nimport { CategoryEditComponent } from '../category-edit/category-edit.component';\n...\nconstructor(\nprivate categoryService: CategoryService,\npublic dialog: MatDialog,\n) { }\n...\ncreateCategory() { const dialogRef = this.dialog.open(CategoryEditComponent, {\ndata: {}\n});\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); } ...\n
Para poder abrir un componente dentro de un dialogo necesitamos obtener en el constructor un MatDialog. De ah\u00ed que hayamos tenido que a\u00f1adirlo como import y en el constructor.
Dentro del m\u00e9todo createCategory
lo que hacemos es crear un dialogo con el componente CategoryEditComponent
en su interior, pasarle unos datos de creaci\u00f3n, donde podemos poner estilos del dialog y un objeto data
donde pondremos los datos que queremos pasar entre los componentes. Por \u00faltimo, nos suscribimos al evento afterClosed
para ejecutar las acciones que creamos oportunas, en nuestro caso volveremos a cargar el listado inicial.
Como hemos utilizado un MatDialog
en el componente, necesitamos a\u00f1adirlo tambi\u00e9n al m\u00f3dulo, as\u00ed que abrimos el fichero category.module.ts
y a\u00f1adimos:
...\nimport { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';\n@NgModule({\ndeclarations: [CategoryListComponent, CategoryEditComponent],\nimports: [\n...\nMatDialogModule\n],\nproviders: [\n{\nprovide: MAT_DIALOG_DATA,\nuseValue: {},\n},\n]\n})\nexport class CategoryModule { }\n
Y ya por \u00faltimo enlazamos el click en el bot\u00f3n con el m\u00e9todo que acabamos de crear para abrir el dialogo. Modificamos el fichero category-list.component.html
y a\u00f1adimos el evento click:
...\n<div class=\"buttons\">\n<button mat-flat-button color=\"primary\" (click)=\"createCategory()\">Nueva categor\u00eda</button> \n</div> \n</div>\n
Si refrescamos el navegador y pulsamos el bot\u00f3n Nueva categor\u00eda
veremos como se abre una ventana modal de tipo Dialog con el componente nuevo que hemos creado, aunque solo se leer\u00e1 category-edit works!
que es el contenido por defecto del componente.
Ahora vamos a darle forma al formulario de editar y crear. Para ello vamos al html, ts y css del componente y pegamos el siguiente contenido:
category-edit.component.htmlcategory-edit.component.scsscategory-edit.component.ts<div class=\"container\">\n <h1>Crear categor\u00eda</h1>\n\n <form>\n <mat-form-field>\n <mat-label>Identificador</mat-label>\n <input type=\"text\" matInput placeholder=\"Identificador\" [(ngModel)]=\"category.id\" name=\"id\" disabled>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Nombre</mat-label>\n <input type=\"text\" matInput placeholder=\"Nombre de categor\u00eda\" [(ngModel)]=\"category.name\" name=\"name\" required>\n <mat-error>El nombre no puede estar vac\u00edo</mat-error>\n </mat-form-field>\n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onClose()\">Cerrar</button>\n <button mat-flat-button color=\"primary\" (click)=\"onSave()\">Guardar</button>\n </div>\n</div>\n
.container {\nmin-width: 350px;\nmax-width: 500px;\npadding: 20px;\n\nform {\ndisplay: flex;\nflex-direction: column;\nmargin-bottom:20px;\n}\n\n.buttons {\ntext-align: right;\n\nbutton {\nmargin-left: 10px;\n}\n}\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatDialogRef } from '@angular/material/dialog';\nimport { CategoryService } from '../category.service';\nimport { Category } from '../model/Category';\n\n@Component({\nselector: 'app-category-edit',\ntemplateUrl: './category-edit.component.html',\nstyleUrls: ['./category-edit.component.scss']\n})\nexport class CategoryEditComponent implements OnInit {\n\ncategory : Category;\n\nconstructor(\npublic dialogRef: MatDialogRef<CategoryEditComponent>,\nprivate categoryService: CategoryService\n) { }\n\nngOnInit(): void {\nthis.category = new Category();\n}\n\nonSave() {\nthis.categoryService.saveCategory(this.category).subscribe(result => {\nthis.dialogRef.close();\n}); } onClose() {\nthis.dialogRef.close();\n}\n\n}\n
Si te fijas en el c\u00f3digo TypeScript, hemos a\u00f1adido en el m\u00e9todo onSave
una llamada al servicio de CategoryService
que aunque no realice ninguna operaci\u00f3n de momento, por lo menos lo dejamos preparado para conectar con el servidor.
Adem\u00e1s, como siempre, al utilizar componentes matInput
, matForm
, matError
hay que a\u00f1adirlos como dependencias en el m\u00f3dulo category.module.ts
:
...\nimport { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';\nimport { MatFormFieldModule } from '@angular/material/form-field';\nimport { MatInputModule } from '@angular/material/input';\nimport { FormsModule, ReactiveFormsModule } from '@angular/forms';\n@NgModule({\ndeclarations: [CategoryListComponent, CategoryEditComponent],\nimports: [\n...\nMatDialogModule,\nMatFormFieldModule,\nMatInputModule,\nFormsModule,\nReactiveFormsModule,\n],\nproviders: [\n{\nprovide: MAT_DIALOG_DATA,\nuseValue: {},\n},\n]\n})\nexport class CategoryModule { }\n
Ahora podemos navegar y abrir el cuadro de dialogo mediante el bot\u00f3n Nueva categor\u00eda
para ver como queda nuestro formulario.
El mismo componente que hemos utilizado para crear una nueva categor\u00eda, nos sirve tambi\u00e9n para editar una categor\u00eda existente. Tan solo tenemos que utilizar la funcionalidad que Angular nos proporciona y pasarle los datos a editar en la llamada de apertura del Dialog. Vamos a implementar funcionalidad sobre el icono editar
, tendremos que modificar unos cuantos ficheros:
<div class=\"container\">\n <h1>Listado de Categor\u00edas</h1>\n\n <mat-table [dataSource]=\"dataSource\"> \n <ng-container matColumnDef=\"id\">\n <mat-header-cell *matHeaderCellDef> Identificador </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.id}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"name\">\n <mat-header-cell *matHeaderCellDef> Nombre categor\u00eda </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.name}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <mat-header-cell *matHeaderCellDef></mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n<button mat-icon-button color=\"primary\" (click)=\"editCategory(element)\">\n<mat-icon>edit</mat-icon>\n</button>\n<button mat-icon-button color=\"accent\"><mat-icon>clear</mat-icon></button>\n </mat-cell>\n </ng-container>\n\n <mat-header-row *matHeaderRowDef=\"displayedColumns; sticky: true\"></mat-header-row>\n <mat-row *matRowDef=\"let row; columns: displayedColumns;\"></mat-row>\n </mat-table>\n\n <div class=\"buttons\">\n <button mat-flat-button color=\"primary\" (click)=\"createCategory()\">Nueva categor\u00eda</button> \n </div> \n</div>\n
export class CategoryListComponent implements OnInit {\n\ndataSource = new MatTableDataSource<Category>();\ndisplayedColumns: string[] = ['id', 'name', 'action'];\n\nconstructor(\nprivate categoryService: CategoryService,\npublic dialog: MatDialog,\n) { }\n\nngOnInit(): void {\nthis.categoryService.getCategories().subscribe(\ncategories => this.dataSource.data = categories\n);\n}\n\ncreateCategory() { const dialogRef = this.dialog.open(CategoryEditComponent, {\ndata: {}\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); } editCategory(category: Category) {\nconst dialogRef = this.dialog.open(CategoryEditComponent, {\ndata: { category: category }\n});\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n});\n}\n}\n
Y los Dialog:
category-edit.component.htmlcategory-edit.component.ts<div class=\"container\">\n<h1 *ngIf=\"category.id == null\">Crear categor\u00eda</h1>\n<h1 *ngIf=\"category.id != null\">Modificar categor\u00eda</h1>\n<form>\n<mat-form-field>\n...\n
import { Component, OnInit, Inject } from '@angular/core';\nimport { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';\nimport { CategoryService } from '../category.service';\nimport { Category } from '../model/Category';\n\n@Component({\nselector: 'app-category-edit',\ntemplateUrl: './category-edit.component.html',\nstyleUrls: ['./category-edit.component.scss']\n})\nexport class CategoryEditComponent implements OnInit {\n\ncategory : Category;\n\nconstructor(\npublic dialogRef: MatDialogRef<CategoryEditComponent>,\n@Inject(MAT_DIALOG_DATA) public data: any,\nprivate categoryService: CategoryService\n) { }\n\nngOnInit(): void {\nif (this.data.category != null) {\nthis.category = this.data.category;\n}\nelse {\nthis.category = new Category();\n}\n}\n\nonSave() {\nthis.categoryService.saveCategory(this.category).subscribe(result => {\nthis.dialogRef.close();\n}); } onClose() {\nthis.dialogRef.close();\n}\n\n}\n
Navegando ahora por la p\u00e1gina y pulsando en el icono de editar, se deber\u00eda abrir una ventana con los datos que hemos seleccionado, similar a esta imagen:
Si te fijas, al modificar los datos dentro de la ventana de di\u00e1logo se modifica tambi\u00e9n en el listado. Esto es porque estamos pasando el mismo objeto desde el listado a la ventana dialogo y al ser el listado y el formulario reactivos los dos, cualquier cambio sobre los datos se refresca directamente en la pantalla.
Hay veces en la que este comportamiento nos interesa, pero en este caso no queremos que se modifique el listado. Para solucionarlo debemos hacer una copia del objeto, para que ambos modelos (formulario y listado) utilicen objetos diferentes. Es tan sencillo como modificar category-edit.component.ts
y a\u00f1adirle una copia del dato
...\nngOnInit(): void {\nif (this.data.category != null) {\nthis.category = Object.assign({}, this.data.category);\n}\nelse {\nthis.category = new Category();\n}\n}\n...\n
Cuidado
Hay que tener mucho cuidado con el binding de los objetos. Hay veces que al modificar un objeto NO queremos que se modifique en todas sus instancias y tenemos que poner especial cuidado en esos aspectos.
"},{"location":"develop/basic/angular/#accion-de-borrado","title":"Acci\u00f3n de borrado","text":"Por norma general, toda acci\u00f3n de borrado de un dato de pantalla requiere una confirmaci\u00f3n previa por parte del usuario. Es decir, para evitar que el dato se borre accidentalmente el usuario tendr\u00e1 que confirmar su acci\u00f3n. Por tanto vamos a crear un componente que nos permita pedir una confirmaci\u00f3n al usuario.
Como esta pantalla de confirmaci\u00f3n va a ser algo com\u00fan a muchas acciones de borrado de nuestra aplicaci\u00f3n, vamos a crearla dentro del m\u00f3dulo core
. Como siempre, ejecutamos el comando en consola:
ng generate component core/dialog-confirmation\n
E implementamos el c\u00f3digo que queremos que tenga el componente. Al ser un componente gen\u00e9rico vamos a aprovechar y leeremos las variables que le pasemos en data
.
<div class=\"container\">\n <h1>{{title}}</h1>\n <div [innerHTML]=\"description\" class=\"description\"></div>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onNo()\">No</button>\n <button mat-flat-button color=\"primary\" (click)=\"onYes()\">S\u00ed</button>\n </div>\n</div> \n
.container {\nmin-width: 350px;\nmax-width: 500px;\npadding: 20px;\n\n.description {\nmargin-bottom: 20px;\n}\n\n.buttons {\ntext-align: right;\n\nbutton {\nmargin-left: 10px;\n}\n}\n}
import { Component, OnInit, Inject } from '@angular/core';\nimport { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';\n\n@Component({\nselector: 'app-dialog-confirmation',\ntemplateUrl: './dialog-confirmation.component.html',\nstyleUrls: ['./dialog-confirmation.component.scss']\n})\nexport class DialogConfirmationComponent implements OnInit {\n\ntitle : string;\ndescription : string;\n\nconstructor(\npublic dialogRef: MatDialogRef<DialogConfirmationComponent>,\n@Inject(MAT_DIALOG_DATA) public data: any\n) { }\n\nngOnInit(): void {\nthis.title = this.data.title;\nthis.description = this.data.description;\n}\n\nonYes() {\nthis.dialogRef.close(true);\n}\n\nonNo() {\nthis.dialogRef.close(false);\n}\n}\n
Recuerda
Recuerda que los componentes utilizados en el di\u00e1logo de confirmaci\u00f3n se deben a\u00f1adir al m\u00f3dulo padre al que pertenecen, en este caso a core.module.ts
imports: [\n CommonModule,\n RouterModule,\n MatIconModule, \n MatToolbarModule,\n MatDialogModule,\n MatButtonModule,\n],\nproviders: [\n {\n provide: MAT_DIALOG_DATA,\n useValue: {},\n },\n],\n
Ya por \u00faltimo, una vez tenemos el componente gen\u00e9rico de dialogo, vamos a utilizarlo en nuestro listado al pulsar el bot\u00f3n eliminar:
category-list.component.htmlcategory-list.component.ts ...\n <ng-container matColumnDef=\"action\">\n <mat-header-cell *matHeaderCellDef></mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n <button mat-icon-button color=\"primary\" (click)=\"editCategory(element)\">\n <mat-icon>edit</mat-icon>\n </button>\n <button mat-icon-button color=\"accent\" (click)=\"deleteCategory(element)\">\n<mat-icon>clear</mat-icon>\n</button>\n </mat-cell>\n </ng-container>\n ...\n
...\ndeleteCategory(category: Category) { const dialogRef = this.dialog.open(DialogConfirmationComponent, {\ndata: { title: \"Eliminar categor\u00eda\", description: \"Atenci\u00f3n si borra la categor\u00eda se perder\u00e1n sus datos.<br> \u00bfDesea eliminar la categor\u00eda?\" }\n});\ndialogRef.afterClosed().subscribe(result => {\nif (result) {\nthis.categoryService.deleteCategory(category.id).subscribe(result => {\nthis.ngOnInit();\n}); }\n});\n} ...
Aqu\u00ed tambi\u00e9n hemos realizado la llamada a categoryService
, aunque no se realice ninguna acci\u00f3n, pero as\u00ed lo dejamos listo para enlazarlo.
Llegados a este punto, ya solo nos queda enlazar las acciones de la pantalla con las operaciones de negocio del backend.
"},{"location":"develop/basic/angular/#conectar-con-backend","title":"Conectar con Backend","text":"Antes de seguir
Antes de seguir con este punto, debes implementar el c\u00f3digo de backend en la tecnolog\u00eda que quieras (Springboot o Nodejs). Si has empezado este cap\u00edtulo implementando el frontend, por favor accede a la secci\u00f3n correspondiente de backend para poder continuar con el tutorial. Una vez tengas implementadas todas las operaciones para este listado, puedes volver a este punto y continuar con Angular.
El siguiente paso, como es obvio ser\u00e1 hacer que Angular llame directamente al servidor backend para leer y escribir datos y eliminar los datos mockeados en Angular.
Manos a la obra!
"},{"location":"develop/basic/angular/#llamada-del-listado","title":"Llamada del listado","text":"La idea es que el m\u00e9todo getCategories()
de category.service.ts
en lugar de devolver datos est\u00e1ticos, realice una llamada al servidor a la ruta http://localhost:8080/category
.
Abrimos el fichero y susituimos la l\u00ednea que antes devolv\u00eda los datos est\u00e1ticos por esto:
category.service.tsimport { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService { constructor(\nprivate http: HttpClient\n) { }\n\ngetCategories(): Observable<Category[]> {\nreturn this.http.get<Category[]>('http://localhost:8080/category');\n}\n\nsaveCategory(category: Category): Observable<Category> {\nreturn of(null);\n}\n\ndeleteCategory(idCategory : number): Observable<any> {\nreturn of(null);\n} }\n
Como hemos a\u00f1adido un componente nuevo HttpClient
tenemos que a\u00f1adir la dependencia al m\u00f3dulo padre.
import { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { MatTableModule } from '@angular/material/table';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatButtonModule } from '@angular/material/button';\nimport { CategoryListComponent } from './category-list/category-list.component';\nimport { CategoryEditComponent } from './category-edit/category-edit.component';\nimport { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';\nimport { MatFormFieldModule } from '@angular/material/form-field';\nimport { MatInputModule } from '@angular/material/input';\nimport { FormsModule, ReactiveFormsModule } from '@angular/forms';\nimport { HttpClientModule } from '@angular/common/http';\n@NgModule({\ndeclarations: [CategoryListComponent, CategoryEditComponent],\nimports: [\nCommonModule,\nMatTableModule,\nMatIconModule, MatButtonModule,\nMatDialogModule,\nMatFormFieldModule,\nMatInputModule,\nFormsModule,\nReactiveFormsModule,\nHttpClientModule,\n],\nproviders: [\n{\nprovide: MAT_DIALOG_DATA,\nuseValue: {},\n},\n]\n})\nexport class CategoryModule { }\n
Si ahora refrescas el navegador (recuerda tener arrancado tambi\u00e9n el servidor) y accedes a la pantalla de Categor\u00edas
deber\u00eda aparecer el listado con los datos que vienen del servidor.
Para la llamada de guardado har\u00edamos lo mismo, pero invocando la operaci\u00f3n de negocio put
.
import { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService { constructor(\nprivate http: HttpClient\n) { }\n\ngetCategories(): Observable<Category[]> {\nreturn this.http.get<Category[]>('http://localhost:8080/category');\n}\n\nsaveCategory(category: Category): Observable<Category> {\n\nlet url = 'http://localhost:8080/category';\nif (category.id != null) url += '/'+category.id;\nreturn this.http.put<Category>(url, category);\n}\n\ndeleteCategory(idCategory : number): Observable<any> {\nreturn of(null);\n} }
Ahora podemos probar a modificar o a\u00f1adir una nueva categor\u00eda desde la pantalla y deber\u00eda aparecer los nuevos datos en el listado.
"},{"location":"develop/basic/angular/#llamada-de-borrado","title":"Llamada de borrado","text":"Y ya por \u00faltimo, la llamada de borrado, deber\u00edamos cambiarla e invocar a la operaci\u00f3n de negocio delete
.
import { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService { constructor(\nprivate http: HttpClient\n) { }\n\ngetCategories(): Observable<Category[]> {\nreturn this.http.get<Category[]>('http://localhost:8080/category');\n}\n\nsaveCategory(category: Category): Observable<Category> {\n\nlet url = 'http://localhost:8080/category';\nif (category.id != null) url += '/'+category.id;\n\nreturn this.http.put<Category>(url, category);\n}\n\ndeleteCategory(idCategory : number): Observable<any> {\nreturn this.http.delete('http://localhost:8080/category/'+idCategory);\n} }
Ahora podemos probar a modificar o a\u00f1adir una nueva categor\u00eda desde la pantalla y deber\u00eda aparecer los nuevos datos en el listado.
Como ves, es bastante sencillo conectar server y client.
"},{"location":"develop/basic/angular/#depuracion","title":"Depuraci\u00f3n","text":"Una parte muy importante del desarrollo es tener la capacidad de depurar nuestro c\u00f3digo, en este apartado vamos a explicar como se realiza debug
en Front.
Esta parte se puede realizar con nuestro navegador favorito, en este caso vamos a utilizar Chrome.
El primer paso es abrir las herramientas del desarrollador del navegador presionando F12
.
En esta herramienta tenemos varias partes importantes:
Identificados los elementos importantes, vamos a depurar la operaci\u00f3n de crear categor\u00eda.
Para ello nos dirigimos a la pesta\u00f1a de Source
, en el \u00e1rbol de carpetas nos dirigimos a la ruta donde est\u00e1 localizado el c\u00f3digo de nuestra aplicaci\u00f3n webpack://src/app
.
Dentro de esta carpeta est\u00e9 localizado todo el c\u00f3digo fuente de la aplicaci\u00f3n, en nuestro caso vamos a localizar componente category-edit.component
que crea una nueva categor\u00eda.
Dentro del fichero ya podemos a\u00f1adir puntos de ruptura (breakpoint), en nuestro caso queremos comprobar que el nombre introducido se captura bien y se env\u00eda al service correctamente.
Colocamos el breakpoint en la l\u00ednea de invocaci\u00f3n del service (click sobre el n\u00famero de la l\u00ednea) y desde la interfaz creamos una nueva categor\u00eda.
Hecho esto, podemos observar que a nivel de interfaz, la aplicaci\u00f3n se detiene y aparece un panel de manejo de los puntos de interrupci\u00f3n:
En cuanto a la herramienta del desarrollador nos lleva al punto exacto donde hemos a\u00f1adido el breakpoint y se para en este punto ofreci\u00e9ndonos la posibilidad de explorar el contenido de las variables del c\u00f3digo:
Aqu\u00ed podemos comprobar que efectivamente la variable category
tiene el valor que hemos introducido por pantalla y se propaga correctamente hacia el service.
Para continuar con la ejecuci\u00f3n basta con darle al bot\u00f3n de play
del panel de manejo de interrupci\u00f3n o al que aparece dentro de la herramienta de desarrollo (parte superior derecha).
Por \u00faltimo, vamos a revisar que la petici\u00f3n REST se ha realizado correctamente al backend, para ello nos dirigimos a la pesta\u00f1a Network
y comprobamos las peticiones realizadas:
Aqu\u00ed podemos observar el registro de todas las peticiones y haciendo click sobre una de ellas, obtenemos el detalle de esta.
Ahora que ya tenemos listo el proyecto backend de nodejs (en el puerto 8080) ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/nodejs/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 en Node tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. Tanto en la web de node como de express encontrar\u00e1s informaci\u00f3n detallada del proceso que vamos a seguir.
"},{"location":"develop/basic/nodejs/#estructurar-el-codigo","title":"Estructurar el c\u00f3digo","text":"La estructura de nuestro proyecto ser\u00e1 la siguiente:
Vamos a aplicar una separaci\u00f3n por capas. En primer lugar, tendremos una capa de rutas para reenviar las solicitudes admitidas y cualquier informaci\u00f3n codificada en las urls de solicitud a la siguiente capa de controladores. La capa de control procesar\u00e1 las peticiones de las rutas y se comunicar\u00e1 con la capa de servicios devolviendo la respuesta de esta mediante respuestas http. En la capa de servicio se ejecutar\u00e1 toda la l\u00f3gica de la petici\u00f3n y se comunicar\u00e1 con los modelos de base de datos
En nuestro caso una ruta es una secci\u00f3n de c\u00f3digo Express que asocia un verbo HTTP (GET, POST, PUT, DELETE, etc.), una ruta/patr\u00f3n de URL y una funci\u00f3n que se llama para manejar ese patr\u00f3n.
\u00a1Ahora s\u00ed, vamos a programar!
"},{"location":"develop/basic/nodejs/#capa-de-routes","title":"Capa de Routes","text":"Lo primero de vamos a crear es la carpeta principal de nuestra aplicaci\u00f3n donde estar\u00e1n contenidos los distintos elementos de la misma. Para ello creamos una carpeta llamada src
en la ra\u00edz de nuestra aplicaci\u00f3n.
El primero elemento que vamos a crear va a ser el fichero de rutas para la categor\u00eda. Para ello creamos una carpeta llamada routes
en la carpeta src
y dentro de esta carpeta crearemos un archivo llamado category.routes.js
:
import { Router } from 'express';\nimport { createCategory } from '../controllers/category.controller.js';\n\nconst categoryRouter = Router();\ncategoryRouter.put('/', createCategory);\n\nexport default categoryRouter;\n
En este archivo estamos creando una ruta de tipo PUT que llamara al m\u00e9todo createCategory
de nuestro futuro controlador de categor\u00edas (aunque todav\u00eda no lo hemos creado y por tanto fallar\u00e1).
Ahora en nuestro archivo index.js
vamos a a\u00f1adir lo siguiente justo despu\u00e9s de declarar la constante app:
...\nimport categoryRouter from './src/routes/category.routes.js';\n...\n\n...\napp.use(cors({\norigin: '*'\n}));\n\napp.use(express.json());\napp.use('/category', categoryRouter);\n\n...\n
De este modo estamos asociando la url http://localhost:8080/category
a nuestro router. Tambi\u00e9n usaremos express.json()
para parsear las peticiones entrantes a formato json.
Lo siguiente ser\u00e1 crear el m\u00e9todo createCategory en nuestro controller. Para ello lo primero ser\u00e1 crear una carpeta controllers
en la carpeta src
de nuestro proyecto y dentro de esta un archivo llamado category.controller.js
:
export const createCategory = async (req, res) => {\nconsole.log(req.body);\nres.status(200).json(1);\n}\n
Hemos creado la funci\u00f3n createCategory
que recibir\u00e1 una request y una response. Estos par\u00e1metros vienen de la ruta de express y son la request y response de la petici\u00f3n HTTP. De momento simplemente vamos a hacer un console.log
de req.body
para ver el body de la petici\u00f3n y vamos a hacer una response 200 para indicar que todo ha ido correctamente.
Si arrancamos el servidor y hacemos una petici\u00f3n PUT
con Postman a http://localhost:8080/category
con un body que pongamos formado correctamente podremos ver la salida que hemos programado en nuestro controller y en la consola de node podemos ver el contenido de req.body
.
Ahora para que los datos que pasemos en el body los podamos guardar en BBDD necesitaremos un modelo y un esquema para la entidad Category
. Vamos a crear una carpeta llamada schemas
en la carpeta src
de nuestro proyecto. Un schema no es m\u00e1s que un modelo de BBDD que especifica que campos estar\u00e1n presentes y cu\u00e1les ser\u00e1n sus tipos. Dentro de la carpeta de schemas creamos un archivo con el nombre category.schema.js
:
import mongoose from \"mongoose\";\nconst { Schema, model } = mongoose;\nimport normalize from 'normalize-mongoose';\n\nconst categorySchema = new Schema({\nname: {\ntype: String,\nrequire: true\n}\n});\ncategorySchema.plugin(normalize);\nconst CategoryModel = model('Category', categorySchema);\n\nexport default CategoryModel;\n
En este archivo estamos definiendo nuestro schema indicando sus propiedades y tipos, en nuestro caso \u00fanicamente name
. Adem\u00e1s del tipo tambi\u00e9n indicaremos que el campo es obligatorio con la validation require para indicar que ese campo es obligatorio. Si quieres conocer otras validaciones aqu\u00ed tienes m\u00e1s info. Aparte de definir nuestro schema tambi\u00e9n lo estamos transformado en un modelo para poder trabajar con \u00e9l. En el constructor de model le pasamos el nombre del modelo y el schema que vamos a utilizar.
Como hemos visto en nuestra estructura la capa controller no puede comunicarse con la capa modelo, debe de haber una capa intermedia, para ello vamos a crear una carpeta services
en la carpeta src
de nuestro proyecto y dentro un archivo category.service.js
:
import CategoryModel from '../schemas/category.schema.js';\n\nexport const createCategory = async function(name) {\ntry {\nconst category = new CategoryModel({ name });\nreturn await category.save();\n} catch (e) {\nthrow Error('Error creating category');\n}\n}\n
Hemos importado el modelo de categor\u00eda para poder realizar acciones sobre la BBDD y hemos creado una funci\u00f3n que recoger\u00e1 el nombre de la categor\u00eda y crear\u00e1 una nueva categor\u00eda con \u00e9l. Llamamos al m\u00e9todo save para guardar nuestra categor\u00eda y devolvemos el resultado. Ahora en nuestro m\u00e9todo del controller solo tenemos que llamar al servicio pas\u00e1ndole los par\u00e1metros que nos llegan en la petici\u00f3n:
category.controller.jsimport * as CategoryService from '../services/category.service.js';\n\nexport const createCategory = async (req, res) => {\nconst { name } = req.body;\ntry {\nconst category = await CategoryService.createCategory(name);\nres.status(200).json({\ncategory\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Si todo ha ido correctamente llamaremos al m\u00e9todo de respuesta con el c\u00f3digo 200 y la categor\u00eda creada. En caso contrario mandaremos un c\u00f3digo de error. Si ahora de nuevo vamos a postman y volvemos a lanzar nuestra petici\u00f3n podemos ver como nos devuelve una nueva categor\u00eda:
"},{"location":"develop/basic/nodejs/#resto-de-operaciones","title":"Resto de Operaciones","text":""},{"location":"develop/basic/nodejs/#recuperacion-categorias","title":"Recuperaci\u00f3n categor\u00edas","text":"Ahora que ya podemos crear categor\u00edas lo siguiente ser\u00e1 crear un endpoint para recuperar las categor\u00edas creadas en nuestra base de datos. Podemos empezar a\u00f1adiendo un nuevo m\u00e9todo en nuestro servicio:
category.service.jsexport const getCategories = async function () {\ntry {\nreturn await CategoryModel.find().sort('name');\n} catch (e) {\nthrow Error('Error fetching categories');\n}\n}\n
Al igual que en el anterior m\u00e9todo haremos uso del modelo, pero esta vez para hacer un find
y ordenando los resultados por el campo name
. Al m\u00e9todo find se le pueden pasar queries
, projections
y options
. Te dejo por aqu\u00ed m\u00e1s info. En nuestro caso simplemente queremos que nos devuelva todas las categor\u00edas por lo que no le pasaremos nada.
Creamos tambi\u00e9n un m\u00e9todo en el controlador para recuperar las categor\u00edas y que har\u00e1 uso del servicio:
category.controller.jsexport const getCategories = async (req, res) => {\ntry {\nconst categories = await CategoryService.getCategories();\nres.status(200).json(\ncategories\n);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Y ahora que ya tenemos el m\u00e9todo creado en el controlador lo siguiente ser\u00e1 relacionar este m\u00e9todo con una ruta. Para ello en nuestro archivo category.routes.js
tendremos que a\u00f1adir una nueva l\u00ednea:
import { Router } from 'express';\nimport { createCategory, getCategories } from '../controllers/category.controller.js';\n\nconst categoryRouter = Router();\ncategoryRouter.put('/', createCategory);\ncategoryRouter.get('/', getCategories);\n\nexport default categoryRouter;\n
De este modo cuando hagamos una petici\u00f3n GET a http://localhost:8080/category
nos devolver\u00e1 el listado de categor\u00edas existentes:
Ahora vamos a por el m\u00e9todo para actualizar nuestras categor\u00edas. En el servicio creamos el siguiente m\u00e9todo:
category.service.jsexport const updateCategory = async (id, name) => {\ntry {\nconst category = await CategoryModel.findById(id);\nif (!category) {\nthrow Error('There is no category with that Id');\n} return await CategoryModel.findByIdAndUpdate(id, {name});\n} catch (e) {\nthrow Error(e);\n}\n}\n
A este m\u00e9todo le pasaremos de entrada el id
y el nombre
. Con ese id
realizaremos una b\u00fasqueda para asegurarnos que esa categor\u00eda existe en nuestra base de datos. Si existe la categor\u00eda haremos una petici\u00f3n con findByIdAndUpdate
donde el primer par\u00e1metro es el id
y el segundo es el resto de los campos de nuestra entidad.
En el controlador creamos el m\u00e9todo correspondiente:
category.controller.jsexport const updateCategory = async (req, res) => {\nconst categoryId = req.params.id;\nconst { name } = req.body;\ntry {\nawait CategoryService.updateCategory(categoryId, name);\nres.status(200).json(1);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Aqu\u00ed recogeremos el par\u00e1metro id
que nos vendr\u00e1 en la url, por ejemplo: http://localhost:8080/category/1
. Esto lo hacemos con req.params.id
. El id
es el nombre de la variable que le daremos en el router como veremos m\u00e1s adelante. Y una vez creado el m\u00e9todo en el controlador tendremos que a\u00f1adir la ruta en nuestro fichero de rutas correspondiente, pero como ya hemos dicho tendremos que indicar que nuestra ruta espera un par\u00e1metro id, lo haremos de la siguiente forma:
import { Router } from 'express';\nimport { createCategory, getCategories, updateCategory } from '../controllers/category.controller.js';\n\nconst categoryRouter = Router();\ncategoryRouter.put('/', createCategory);\ncategoryRouter.get('/', getCategories);\ncategoryRouter.put('/:id', updateCategory);\n\nexport default categoryRouter;\n
Y volvemos a probar en Postman:
Y si hacemos de nuevo un GET
vemos como la categor\u00eda se ha modificado correctamente:
Ya solo nos faltar\u00eda la operaci\u00f3n de delete
para completar nuestro CRUD, en el servicio a\u00f1adimos un nuevo m\u00e9todo:
export const deleteCategory = async (id) => {\ntry {\nconst category = await CategoryModel.findById(id);\nif (!category) {\nthrow Error('There is no category with that Id');\n}\nreturn await CategoryModel.findByIdAndDelete(id);\n} catch (e) {\nthrow Error('Error deleting category');\n}\n}\n
Como vemos es muy parecido al update, recuperamos el id
de los par\u00e1metros de la ruta y en este caso llamaremos al m\u00e9todo findByIdAndDelete
. En nuestro controlador creamos el m\u00e9todo correspondiente:
export const deleteCategory = async (req, res) => {\nconst categoryId = req.params.id;\ntry {\nconst deletedCategory = await CategoryService.deleteCategory(categoryId);\nres.status(200).json({\ncategory: deletedCategory\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Y de nuevo a\u00f1adimos la ruta correspondiente al archivo de rutas:
category.routes.jsimport { Router } from 'express';\nimport { createCategory, getCategories, updateCategory, deleteCategory } from '../controllers/category.controller.js';\n\nconst categoryRouter = Router();\ncategoryRouter.put('/', createCategory);\ncategoryRouter.get('/', getCategories);\ncategoryRouter.put('/:id', updateCategory);\ncategoryRouter.delete('/:id', deleteCategory);\n\nexport default categoryRouter;\n
Y de nuevo, probamos en postman:
Hacemos un get para comprobar que se ha borrado de nuestra base de datos:
"},{"location":"develop/basic/nodejs/#capa-de-middleware-validaciones","title":"Capa de Middleware (Validaciones)","text":"Antes de pasar a nuestro siguiente CRUD vamos a ver en que consiste la Capa de Middleware
. Un middleware
es un c\u00f3digo que se ejecuta antes de que una petici\u00f3n http llegue a nuestro manejador de rutas o antes de que el cliente reciba una respuesta.
En nuestro caso vamos a crear un middleware para asegurarnos que todos los campos que necesitamos en nuestras entidades vienen en el body de la petici\u00f3n. Vamos a crear una carpeta middlewares
en la carpeta src
de nuestro proyecto y dentro crearemos el fichero validateFields.js
:
import { response } from 'express';\nimport { validationResult } from 'express-validator';\n\nconst validateFields = (req, res = response, next) => {\nconst errors = validationResult(req);\nif (!errors.isEmpty()) {\nreturn res.status(400).json({\nerrors: errors.mapped()\n});\n}\nnext();\n}\n\nexport default validateFields;\n
En este m\u00e9todo nos ayudaremos de la librer\u00eda express-validator
para ver los errores que tenemos en nuestras rutas. Para ello llamaremos a la funci\u00f3n validationResult
que nos devolver\u00e1 un array de errores que m\u00e1s tarde definiremos. Si el array no va vac\u00edo es porque se ha producido alg\u00fan error en las validaciones y ejecutara la response con un c\u00f3digo de error.
Ahora definiremos las validaciones en nuestro archivo de rutas, deber\u00eda quedar de la siguiente manera:
category.routes.jsimport { Router } from 'express';\nimport { check } from 'express-validator';\nimport validateFields from '../middlewares/validateFields.js';\nimport { getCategories, createCategory, deleteCategory, updateCategory } from '../controllers/category.controller.js';\nconst categoryRouter = Router();\n\ncategoryRouter.put('/:id', [\ncheck('name').not().isEmpty(),\nvalidateFields\n], updateCategory);\n\ncategoryRouter.put('/', [\ncheck('name').not().isEmpty(),\nvalidateFields\n], createCategory);\n\ncategoryRouter.get('/', getCategories);\ncategoryRouter.delete('/:id', deleteCategory);\n\nexport default categoryRouter;\n
Aqu\u00ed nos ayudamos de nuevo de express-validator
y de su m\u00e9todo check
. Para las rutas en las que necesitemos validaciones, a\u00f1adimos un array como segundo par\u00e1metro. En este array vamos a\u00f1adiendo todas las validaciones que necesitemos. En nuestro caso solo queremos que el campo name no sea vac\u00edo, pero existen muchas m\u00e1s validaciones que puedes encontrar en la documentaci\u00f3n de express-validator. Importamos nuestro middleware y lo a\u00f1adimos en la \u00faltima posici\u00f3n de este array.
De este modo no se realizar\u00e1n las peticiones que no pasen las validaciones:
Y con esto habremos terminado nuestro primer CRUD.
"},{"location":"develop/basic/nodejs/#depuracion","title":"Depuraci\u00f3n","text":"Una parte muy importante del desarrollo es tener la capacidad de depurar nuestro c\u00f3digo, en este apartado vamos a explicar como se realiza debug
en Backend.
Esta parte se realiza con las herramientas incluidas dentro de nuestro IDE favorito, en este caso vamos a utilizar el Visual Estudio.
Lo primero que debemos hacer es configurar el modo Debug
de nuestro proyecto.
Para ello nos dirigimos a la opci\u00f3n Run and Debug
y creamos el fichero de launch necesario:
Esto nos crear\u00e1 el fichero necesario y ya podremos arrancar la aplicaci\u00f3n mediante esta herramienta presionando el bot\u00f3n Launch Program
(seleccionamos tipo de aplicaci\u00f3n Node y el script de arranque que ser\u00e1 el que hemos utilizado en el desarrollo):
Arrancada la aplicaci\u00f3n de este modo, vamos a depurar la operaci\u00f3n de crear categor\u00eda.
Para ello vamos a abrir nuestro fichero donde tenemos la implementaci\u00f3n del servicio de creaci\u00f3n de la capa de la l\u00f3gica de negocio category.service.js
.
Dentro del fichero ya podemos a\u00f1adir puntos de ruptura (breakpoint), en nuestro caso queremos comprobar que el nombre introducido se recibe correctamente.
Colocamos el breakpoint en la primera l\u00ednea del m\u00e9todo (click sobre el n\u00famero de la l\u00ednea) y desde la interfaz/postman creamos una nueva categor\u00eda.
Hecho esto, podemos observar que a nivel de interfaz/postman, la petici\u00f3n se queda esperando y el IDE mostrar\u00e1 un panel de manejo de los puntos de interrupci\u00f3n:
El IDE nos lleva al punto exacto donde hemos a\u00f1adido el breakpoint y se para en este punto ofreci\u00e9ndonos la posibilidad de explorar el contenido de las variables del c\u00f3digo:
Aqu\u00ed podemos comprobar que efectivamente la variable name
tiene el valor que hemos introducido por pantalla/postman.
Para continuar con la ejecuci\u00f3n basta con darle al bot\u00f3n de play
del panel de manejo de los puntos de interrupci\u00f3n.
Ahora que ya tenemos listo el proyecto frontend de React (en el puerto 5173), ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/react/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 que React tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. Tanto en la propia web de documentaci\u00f3n de React como en la web de componentes Mui puedes buscar casi cualquier ejemplo que necesites.
Si abrimos el proyecto con el IDE que tengamos (Visual Studio Code en el caso del tutorial) podemos ver que en la carpeta src
existen unos ficheros ya creados por defecto. Estos ficheros son:
main.tsx
\u2192 contiene el componente principal del proyecto.index.css
\u2192 contiene los estilos CSS globales de la aplicaci\u00f3n.APP.tsx
\u2192 contiene el componente inicial del proyecto APP.css
\u2192 contiene los estilos para el componente APP.Aunque main.tsx
y App.tsx
puedan parecer lo mismo main.tsx
se suele dejar tal y como esta ya que lo \u00fanico que hace es asociar el div con id \u201croot\u201d del archivo index.html
de la ra\u00edz de nuestro proyecto para que sea el nodo principal de React. En el archivo App.tsx
es donde realmente empezamos a desarrollar c\u00f3digo.
Si abrimos main.tsx
podemos ver que se esta usando <App />
como una etiqueta html. El nombre con que exportemos nuestros componentes ser\u00e1 el nombre de la etiqueta html utilizado para renderizar los componentes.
Vamos a modificar este c\u00f3digo inicial para ver c\u00f3mo funciona. Abrimos el fichero App.tsx
y vamos a dejarlo de esta manera:
import { useState } from 'react'\nimport './App.css'\n\nfunction App() {\nconst [count, setCount] = useState(0)\nconst probando = \"probando 123\";\n\nreturn (\n<>\n<p>{probando}</p>\n<div className=\"card\">\n<button onClick={() => setCount((count) => count + 1)}>\ncount is {count}\n</button>\n</div>\n</>\n)\n}\n\nexport default App\n
En los componentes React siempre se suele seguir el mismo orden, primero introduciremos los imports necesarios, luego podemos declarar variables y funciones que no se vayan a modificar, despu\u00e9s creamos nuestra funci\u00f3n principal con el nombre del componente y dentro de esta lo primero que se suelen declarar son todas las variables, despu\u00e9s a\u00f1adiremos m\u00e9todos del componente y por \u00faltimo tenemos que llamar a return para devolver lo que queramos renderizar. Si ahora abrimos nuestro navegador veremos en pantalla el valor de la variable \"probando\" que hemos introducido mediante una expresi\u00f3n en un tag p de html y un bot\u00f3n que si pulsamos incrementar\u00e1 el valor de la cuenta en uno. Si refrescamos la pantalla el valor de la cuenta volver\u00e1 autom\u00e1ticamente a 0. Es hora de explicar como funciona un componente React y el hook useState.
"},{"location":"develop/basic/react/#jsx","title":"JSX","text":"JSX significa Javascript XML. JSX nos permite escribir elementos HTML en JavaScript y colocarlos en el DOM. Con JSX puedes escribir expresiones dentro de llaves \u201c{}\u201d. Estas expresiones pueden ser variables, propiedades o cualquier expresi\u00f3n Javascript valida. JSX ejecutar\u00e1 esta expresi\u00f3n y devolver\u00e1 el resultado.
Por ejemplo, si queremos mostrar un elemento de forma condicional lo podemos hacer de la siguiente manera:
{\nvariableBooleana && <p>El valor es true</p>\n}\n
Tambi\u00e9n podemos usar el operador ternario para condiciones:
{\nvariableBooleana ? <p>El valor es true</p> : <p>El valor es false</p>\n}\n
Y si lo que queremos es recorrer un array e ir representando los elementos lo podemos hacer de la siguiente manera:
{\narrayNumerico.map(numero => <p>Mi valor es {numero}</p>)\n}\n
React solo puede devolver un elemento en su bloque return, es por eso por lo que algunas veces se rodea todo el c\u00f3digo con un elemento llamado Fragment \u201c<>\u201d. Estos fragment no soportan ni propiedades ni atributos y no tendr\u00e1n visibilidad en el dom.
Dentro de una expresi\u00f3n podemos ver dos formas de llamar a una funci\u00f3n:
<Button onClick={callToCancelar}>Cancelar</Button>\n<Button onClick={() => callToCancelar('param1')}>Cancelar</Button>\n
En la primera se pasa una funci\u00f3n por referencia y Button es el responsable de los par\u00e1metros del evento. En la segunda tras hacer onClick se ejecuta la funci\u00f3n callToCancelar con los par\u00e1metros que nosotros queramos quitando esa responsabilidad a Button. En t\u00e9rminos de rendimiento es mejor la primera manera ya que en la segunda se vuelve a crear la funci\u00f3n en cada renderizado, pero hay veces que es necesario hacerlo as\u00ed para tomar control de los par\u00e1metros.
"},{"location":"develop/basic/react/#usestate-hook","title":"useState hook","text":"Todo componente en React tiene una serie de variables. Algunas de estas son propiedades de entrada como podr\u00edan serlo disabled en un bot\u00f3n y que se trasmiten de componentes padres a hijos.
Luego tenemos variables y constantes declaradas dentro del componente como por ejemplo la constante probando de nuestro ejemplo. Y finalmente tenemos unas variables especiales dentro de nuestro componente que corresponden al estado de este.
Si modificamos el estado de un componente este autom\u00e1ticamente se volver\u00e1 a renderizar y producir\u00e1 una nueva representaci\u00f3n en pantalla.
Como ya hemos comentado previamente los hooks aparecieron en la versi\u00f3n 16.8 de React. Antes de esto si quer\u00edamos acceder al estado de un componente solo pod\u00edamos acceder a este mediante componentes de clase, pero desde esta versi\u00f3n podemos hacer uso de estas funciones especiales para utilizar estas caracter\u00edsticas de React.
M\u00e1s tarde veremos otras, pero de momento vamos a ver useState.
const [count, setCount] = useState(0)\n
En nuestro ejemplo tenemos una variable count que va mostrando su valor en el interior de un bot\u00f3n. Si pulsamos el bot\u00f3n ejecutara la funci\u00f3n setCount
que actualiza el valor de nuestro contador. A esta funci\u00f3n se le puede pasar o bien el nuevo valor que tomar\u00e1 esta variable de estado o bien una funci\u00f3n cuyo primer par\u00e1metro es el valor actual de la variable. Siempre que se actualice la variable del estado de producir\u00e1 un nuevo renderizado del componente, eso lo pod\u00e9is comprobar escribiendo un console.log
antes del return. En nuestro caso hemos inicializado nuestra variable de estado con el valor 0, pero puede inicializarse con un valor de cualquier tipo javascript. No existe limite en el n\u00famero de variables de estado por componente.
Debemos tener en cuenta que si modificamos el estado de un componente que renderiza otros componentes, estos tambi\u00e9n se volver\u00e1n a renderizar al cambiar el estado del componente padre. Es por esto por lo que debemos tener cuidado a la hora de modificar estados y renderizar los hijos correctamente.
Nota
Para evitar el re-renderizado de los componentes hijos existe una funci\u00f3n especial en React llamada memo
que evita este comportamiento si las props de los hijos no se ven modificadas. En este curso no cubriremos esta funcionalidad.
Nota
Por convenci\u00f3n todos los hooks empiezan con use
. Si en alg\u00fan proyecto tienes que crear un custom hook es importante seguir esta nomenclatura.
Antes de continuar con nuestro curso vamos a instalar las dependencias necesarias para empezar a construir la base de nuestra aplicaci\u00f3n. Para ello ejecutamos lo siguiente en la consola en la ra\u00edz de nuestro proyecto:
npm i @mui/material @mui/icons-material react-router-dom react-redux @reduxjs/toolkit @emotion/react @emotion/styled\n
Como librer\u00eda de componentes vamos a utilizar Mui, anteriormente conocido como Material ui, es una librer\u00eda muy utilizada en los proyectos de React con una gran documentaci\u00f3n. Tambi\u00e9n necesitaremos las librer\u00edas de emotion necesarias para trabajar con Mui.
Vamos a utilizar la librer\u00eda react router dom que nos permitir\u00e1 definir y usar rutas de navegaci\u00f3n en nuestra aplicaci\u00f3n.
Vamos a instalar tambi\u00e9n react redux y redux toolkit para gestionar el estado global de nuestra aplicaci\u00f3n.
"},{"location":"develop/basic/react/#layout-general","title":"Layout general","text":""},{"location":"develop/basic/react/#crear-componente","title":"Crear componente","text":"Lo primero que haremos ser\u00e1 borrar el contenido del archivo App.css
y vamos a modificar index.css
con el siguiente contenido:
:root {\nfont-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;\n}\n\nbody {\nmargin: 0;\n}\n\n@media (prefers-color-scheme: light) {\n:root {\ncolor: #213547;\nbackground-color: #ffffff;\n}\n}\n\n.container {\nmargin: 20px;\n}\n\n.newButton {\ndisplay: flex;\njustify-content: flex-end;\nmargin-top: 20px;\n}\n
Ahora vamos a crear los distintos componentes que compondr\u00e1n nuestra aplicaci\u00f3n. Para ello dentro de la carpeta src vamos a crear una nueva carpeta llamada pages y dentro de esta crearemos tres carpetas relativas a nuestras paginas navegables: \u201cAuthor\u201d, \u201cCategory\u201d y \u201cGame\u201d. Dentro de estas a su vez crearemos un fichero llamado Author.tsx
, Category.tsx
y Game.tsx
respectivamente, cuyo contenido ser\u00e1 una funci\u00f3n que tendr\u00e1 por nombre el mismo nombre que el fichero y que devolver\u00e1 un div cuyo contenido ser\u00e1 tambi\u00e9n el nombre del fichero:
export const Game = () => {\nreturn (\n<div>Game</div>\n)\n}\n
Ahora vamos a crear en la carpeta src otra carpeta cuyo nombre ser\u00e1 \u201ccomponents\u201d y dentro de esta un fichero llamado Layout.tsx
cuyo contenido ser\u00e1 el siguiente:
import { useState } from \"react\";\nimport AppBar from \"@mui/material/AppBar\";\nimport Box from \"@mui/material/Box\";\nimport Toolbar from \"@mui/material/Toolbar\";\nimport IconButton from \"@mui/material/IconButton\";\nimport Typography from \"@mui/material/Typography\";\nimport Menu from \"@mui/material/Menu\";\nimport MenuIcon from \"@mui/icons-material/Menu\";\nimport Container from \"@mui/material/Container\";\nimport Button from \"@mui/material/Button\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport CasinoIcon from \"@mui/icons-material/Casino\";\nimport { useNavigate, Outlet } from \"react-router-dom\";\n\nconst pages = [\n{ name: \"Catalogo\", link: \"games\" },\n{ name: \"Categor\u00edas\", link: \"categories\" },\n{ name: \"Autores\", link: \"authors\" },\n];\n\nexport const Layout = () => {\nconst navigate = useNavigate();\n\nconst [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(\nnull\n);\n\nconst handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {\nsetAnchorElNav(event.currentTarget);\n};\n\nconst handleCloseNavMenu = (link: string) => {\nnavigate(`/${link}`);\nsetAnchorElNav(null);\n};\n\nreturn (\n<>\n<AppBar position=\"static\">\n<Container maxWidth=\"xl\">\n<Toolbar disableGutters>\n<CasinoIcon sx={{ display: { xs: \"none\", md: \"flex\" }, mr: 1 }} />\n<Typography\nvariant=\"h6\"\nnoWrap\ncomponent=\"a\"\nhref=\"/\"\nsx={{\nmr: 2,\ndisplay: { xs: \"none\", md: \"flex\" },\nfontFamily: \"monospace\",\nfontWeight: 700,\nletterSpacing: \".3rem\",\ncolor: \"inherit\",\ntextDecoration: \"none\",\n}}\n>\nLudoteca Tan\n</Typography>\n\n<Box sx={{ flexGrow: 1, display: { xs: \"flex\", md: \"none\" } }}>\n<IconButton\nsize=\"large\"\naria-label=\"account of current user\"\naria-controls=\"menu-appbar\"\naria-haspopup=\"true\"\nonClick={handleOpenNavMenu}\ncolor=\"inherit\"\n>\n<MenuIcon />\n</IconButton>\n<Menu\nid=\"menu-appbar\"\nanchorEl={anchorElNav}\nanchorOrigin={{\nvertical: \"bottom\",\nhorizontal: \"left\",\n}}\nkeepMounted\ntransformOrigin={{\nvertical: \"top\",\nhorizontal: \"left\",\n}}\nopen={Boolean(anchorElNav)}\nonClose={handleCloseNavMenu}\nsx={{\ndisplay: { xs: \"block\", md: \"none\" },\n}}\n>\n{pages.map((page) => (\n<MenuItem\nkey={page.name}\nonClick={() => handleCloseNavMenu(page.link)}\n>\n<Typography textAlign=\"center\">\n{page.name}\n</Typography>\n</MenuItem>\n))}\n</Menu>\n</Box>\n<CasinoIcon sx={{ display: { xs: \"flex\", md: \"none\" }, mr: 1 }} />\n<Typography\nvariant=\"h5\"\nnoWrap\ncomponent=\"a\"\nhref=\"\"\nsx={{\nmr: 2,\ndisplay: { xs: \"flex\", md: \"none\" },\nflexGrow: 1,\nfontFamily: \"monospace\",\nfontWeight: 700,\nletterSpacing: \".3rem\",\ncolor: \"inherit\",\ntextDecoration: \"none\",\n}}\n>\nLudoteca Tan\n</Typography>\n<Box sx={{ flexGrow: 1, display: { xs: \"none\", md: \"flex\" } }}>\n{pages.map((page) => (\n<Button\nkey={page.name}\nonClick={() => handleCloseNavMenu(page.link)}\nsx={{ my: 2, color: \"white\", display: \"block\" }}\n>\n{page.name}\n</Button>\n))}\n</Box>\n</Toolbar>\n</Container>\n</AppBar>\n<Outlet />\n</>\n);\n};\n
Aunque puede parecer complejo por su tama\u00f1o en realidad no es tanto, casi todo es c\u00f3digo cogido directamente de un ejemplo de layout de navegaci\u00f3n de un componente de MUI.
Lo m\u00e1s destacable es un nuevo hook (en realidad es un custom hook de react router dom) llamado useNavigate
que como su propio nombre indica navegara a la ruta correspondiente seg\u00fan el valor pulsado.
Las etiquetas sx son para dar estilo a los componentes de MUI. Tambi\u00e9n se puede sobrescribir el estilo mediante hojas css pero es m\u00e1s complejo y requiere una configuraci\u00f3n inicial que no cubriremos en este tutorial.
Si nos fijamos en la l\u00ednea 90 se introduce una expresi\u00f3n javascript en la cual se recorre el array de pages declarado al inicio del componente y para cada uno de los valores se llama a MenuItem
que es otro componente React al que se le pasan las props key, onClick y aunque no lo veamos tambi\u00e9n la prop \u201cchildren\u201d.
La prop children estar\u00e1 presente cuando pasemos elementos entre los tags de un elemento:
<MenuItem > <Typography>I\u2019m a child</Typography>\n</MenuItem>\n
El uso de la prop children no es muy recomendado y se prefiere que se pasen los elementos como una prop m\u00e1s. Siempre que rendericemos un array en react es recomendable usar una prop especial llamada \u201ckey\u201d, de hecho, si no la usamos la consola de desarrollo se nos llenar\u00e1 de warnings por no usarla.
Esta key lo que permite a React es identificar cada elemento de formar m\u00e1s eficiente, as\u00ed si modificamos, a\u00f1adimos o eliminamos un elemento de un array no ser\u00e1 necesario volver a renderizar todo el array, solo se eliminar\u00e1 el elemento necesario.
En la parte final del archivo tenemos una llamada al elemento Outlet
. Este elemento es el que albergara el componente asociado a la ruta seleccionada.
Por \u00faltimo, el archivo App.tsx
se tiene que quedar de esta manera:
import { BrowserRouter, Routes, Route, Navigate } from \"react-router-dom\";\nimport { Game } from \"./pages/Game/Game\";\nimport { Author } from \"./pages/Author/Author\";\nimport { Category } from \"./pages/Category/Category\";\nimport { Layout } from \"./components/Layout\";\n\nfunction App() {\nreturn (\n<BrowserRouter>\n<Routes>\n<Route element={<Layout />}>\n<Route index path=\"games\" element={<Game />} />\n<Route path=\"categories\" element={<Category />} />\n<Route path=\"authors\" element={<Author />} />\n<Route path=\"*\" element={<Navigate to=\"/games\" />} />\n</Route>\n</Routes>\n</BrowserRouter>\n);\n}\n\nexport default App;\n
De esta manera definimos cada una de nuestras rutas y las asociamos a una p\u00e1gina. Vamos a arrancar el proyecto de nuevo con npm run dev y navegamos a http://localhost:5173/.
Ahora podemos ver como autom\u00e1ticamente nos lleva a http://localhost:5173/games debido al \u00faltimo route en el que redirigimos cualquier path que no coincida con los anteriores a /games
. Si pulsamos sobre las distintas opciones del men\u00fa podemos ver c\u00f3mo va cambiando el outlet de nuestra aplicaci\u00f3n con los distintos div creados para cada uno de los componentes p\u00e1gina.
Ya tenemos la estructura principal, ahora vamos a crear nuestra primera pantalla. Vamos a empezar por la de categor\u00edas.
Lo primero que vamos a hacer es crear una carpeta llamada types
dentro de src/
. Aqu\u00ed crearemos los tipos de typescript. Creamos un nuevo fichero llamado Category.ts
cuyo contenido ser\u00e1 el siguiente:
export interface Category {\nid: string;\nname: string;\n}\n
Ahora vamos a crear un archivo de estilos que ser\u00e1 solo utilizado por el componente Category. Para ello dentro de la carpeta src/pages/Category
vamos a crear un archivo llamado Category.module.css
. Al llamar al archivo de esta manera React reconoce este archivo como un archivo \u00fanico para un componente y hace que sus reglas css sean m\u00e1s prioritarias, aunque por ejemplo exista una clase con el mismo nombre en el archivo index.css
.
El contenido de nuestro archivo css ser\u00e1 el siguiente:
.tableActions {\nmargin-right: 20px;\ndisplay: flex;\njustify-content: flex-end;\nalign-content: flex-start;\ngap: 19px;\n}\n
Y por \u00faltimo el contenido de nuestro fichero src/pages/Category.tsx
quedar\u00eda as\u00ed:
import { useState } from \"react\";\nimport Table from \"@mui/material/Table\";\nimport TableBody from \"@mui/material/TableBody\";\nimport TableCell from \"@mui/material/TableCell\";\nimport TableContainer from \"@mui/material/TableContainer\";\nimport TableHead from \"@mui/material/TableHead\";\nimport TableRow from \"@mui/material/TableRow\";\nimport Paper from \"@mui/material/Paper\";\nimport Button from \"@mui/material/Button\";\nimport EditIcon from \"@mui/icons-material/Edit\";\nimport ClearIcon from \"@mui/icons-material/Clear\";\nimport IconButton from \"@mui/material/IconButton\";\nimport styles from \"./Category.module.css\";\nimport { Category as CategoryModel } from \"../../types/Category\";\n\nexport const Category = () => {\nconst data = [\n{\nid: \"1\",\nname: \"Test 1\",\n},\n{\nid: \"2\",\nname: \"Test 2\",\n},\n];\n\nreturn (\n<div className=\"container\">\n<h1>Listado de Categor\u00edas</h1>\n<TableContainer component={Paper}>\n<Table sx={{ minWidth: 650 }} aria-label=\"simple table\">\n<TableHead\nsx={{\n\"& th\": {\nbackgroundColor: \"lightgrey\",\n},\n}}\n>\n<TableRow>\n<TableCell>Identificador</TableCell>\n<TableCell>Nombre categor\u00eda</TableCell>\n<TableCell></TableCell>\n</TableRow>\n</TableHead>\n<TableBody>\n{data.map((category: CategoryModel) => (\n<TableRow\nkey={category.id}\nsx={{ \"&:last-child td, &:last-child th\": { border: 0 } }}\n>\n<TableCell component=\"th\" scope=\"row\">\n{category.id}\n</TableCell>\n<TableCell component=\"th\" scope=\"row\">\n{category.name}\n</TableCell>\n<TableCell>\n<div className={styles.tableActions}>\n<IconButton aria-label=\"update\" color=\"primary\">\n<EditIcon />\n</IconButton>\n<IconButton aria-label=\"delete\" color=\"error\">\n<ClearIcon />\n</IconButton>\n</div>\n</TableCell>\n</TableRow>\n))}\n</TableBody>\n</Table>\n</TableContainer>\n<div className=\"newButton\">\n<Button variant=\"contained\">Nueva categor\u00eda</Button>\n</div>\n</div>\n);\n};\n
De momento vamos a usar un listado mockeado para mostrar nuestras categorias. El c\u00f3digo JSX esta sacado pr\u00e1cticamente en su totalidad del ejemplo de una tabla de Mui y solo hemos modificado el nombre del array que tenemos que recorrer, sus atributos y hemos a\u00f1adido unos botones de acci\u00f3n para editar y borrar autores que de momento no hacen nada. Si abrimos un navegador (con el servidor arrancado npm run dev) y vamos a http://localhost:5173/categories podremos ver nuestro listado con los datos mockeados.
Ahora vamos a crear un componente que se mostrar\u00e1 cuando pulsemos el bot\u00f3n de nueva categor\u00eda. En la carpeta src/pages/category
vamos a crear una nueva carpeta llamada components
y dentro de esta crearemos un nuevo fichero llamado CreateCategory.tsx
que tendr\u00e1 el siguiente contenido:
import { useState } from \"react\";\nimport Button from \"@mui/material/Button\";\nimport TextField from \"@mui/material/TextField\";\nimport Dialog from \"@mui/material/Dialog\";\nimport DialogActions from \"@mui/material/DialogActions\";\nimport DialogContent from \"@mui/material/DialogContent\";\nimport DialogTitle from \"@mui/material/DialogTitle\";\nimport { Category } from \"../../../types/Category\";\n\ninterface Props {\ncategory: Category | null;\ncloseModal: () => void;\ncreate: (name: string) => void;\n}\n\nexport default function CreateCategory(props: Props) {\nconst [name, setName] = useState(props?.category?.name || \"\");\n\nreturn (\n<div>\n<Dialog open={true} onClose={props.closeModal}>\n<DialogTitle>\n{props.category ? \"Actualizar Categor\u00eda\" : \"Crear Categor\u00eda\"}\n</DialogTitle>\n<DialogContent>\n{props.category && (\n<TextField\nmargin=\"dense\"\ndisabled\nid=\"id\"\nlabel=\"Id\"\nfullWidth\nvalue={props.category.id}\nvariant=\"standard\"\n/>\n)}\n<TextField\nmargin=\"dense\"\nid=\"name\"\nlabel=\"Nombre\"\nfullWidth\nvariant=\"standard\"\nonChange={(event) => setName(event.target.value)}\nvalue={name}\n/>\n</DialogContent>\n<DialogActions>\n<Button onClick={props.closeModal}>Cancelar</Button>\n<Button onClick={() => props.create(name)} disabled={!name}>\n{props.category ? \"Actualizar\" : \"Crear\"}\n</Button>\n</DialogActions>\n</Dialog>\n</div>\n);\n}\n
Para este componente hemos definido una categor\u00eda como par\u00e1metro de entrada para poder reutilizar el componente en el caso de una edici\u00f3n y poder pasar la categor\u00eda a editar, en nuestro caso inicial al ser un alta esta categor\u00eda tendr\u00e1 el valor null
. Tambi\u00e9n hemos definido dos funciones en los par\u00e1metros de entrada para controlar o bien el cerrado del modal o bien la creaci\u00f3n de un autor.
Esta es la forma directa que tienen de comunicaci\u00f3n los componentes padre/hijo en React, el padre puede pasar datos de lectura o funciones a sus componentes hijos a las que estos llamaran para comunicarse con \u00e9l.
As\u00ed en nuestro ejemplo el componente CreateCategory
llamar\u00e1 a la funci\u00f3n create
a la que pasar\u00e1 un nuevo objecto Category
y ser\u00e1 el padre (nuestra p\u00e1gina Category
) el que decidir\u00e1 qu\u00e9 hacer con esos datos al igual que ocurre con los eventos en los tags de html.
En el estado de nuestro componente solo vamos a almacenar los datos introducidos en el formulario, en el caso de una edici\u00f3n el valor inicial del nombre de la categor\u00eda ser\u00e1 el que venga de entrada.
Adem\u00e1s introducido unas expresiones que modificar\u00e1n la visualizaci\u00f3n del componente (titulo, id, texto de los botones, \u2026) dependiendo de si tenemos un autor de entrada o no.
Ahora tenemos que a\u00f1adir nuestro nuevo componente en nuestra p\u00e1gina Category:
Importamos el componente:
import CreateCategory from \"./components/CreateCategory\";\n
Creamos las funciones que le pasaremos al componente, dej\u00e1ndolas de momento vac\u00edas:
const createCategory = () => {\n\n}\n\nconst handleCloseCreate = () => {\n\n}\n
Y a\u00f1adimos en el c\u00f3digo JSX lo siguiente tras nuestro button
:
<CreateCategory\ncreate={createCategory}\ncategory={null}\ncloseModal={handleCloseCreate}\n/>\n
Si ahora vamos a nuestro navegador, a la p\u00e1gina de categor\u00edas, podremos ver el formulario para crear una categor\u00eda, pero \u00e9sta fijo y no hay manera de cerrarlo. Vamos a cambiar este comportamiento mediante una variable booleana en el estado del componente que decidir\u00e1 cuando se muestra este. Adem\u00e1s, a\u00f1adiremos a nuestro bot\u00f3n el c\u00f3digo necesario para mostrar el componente y a\u00f1adiremos a la funci\u00f3n handleCloseCreate
el c\u00f3digo para ocultarlo.
A\u00f1adimos un nuevo estado:
const [openCreate, setOpenCreate] = useState(false);\n
Modificamos la function handleCloseCreate
:
const handleCloseCreate = () => {\nsetOpenCreate(false);\n};\n
Y por \u00faltimo modificamos el c\u00f3digo del return de la siguiente manera:
<div className=\"newButton\">\n<Button variant=\"contained\" onClick={() => setOpenCreate(true)}>\nNueva categor\u00eda\n</Button>\n</div>\n{openCreate && (\n<CreateCategory\ncreate={createCategory}\ncategory={null}\ncloseModal={handleCloseCreate}\n/>\n)}\n
Si probamos ahora vemos que ya se realiza la funcionalidad de abrir y cerrar nuestro formulario de manera correcta.
Ahora vamos a a\u00f1adir la funcionalidad para que al pulsar el bot\u00f3n de edici\u00f3n pasemos la categor\u00eda a editar a nuestro formulario. Para esto vamos a necesitar una nueva variable en nuestro estado donde almacenaremos la categor\u00eda a editar:
const [categoryToUpdate, setCategoryToUpdate] =\nuseState<CategoryModel | null>(null);\n
Modificamos el c\u00f3digo de nuestro bot\u00f3n:
<IconButton\naria-label=\"update\"\ncolor=\"primary\"\nonClick={() => {\nsetCategoryToUpdate(category);\nsetOpenCreate(true);\n}}\n>\n<EditIcon />\n</IconButton>\n
Y la entrada a nuestro componente:
{openCreate && (\n<CreateCategory\ncreate={createCategory}\ncategory={categoryToUpdate}\ncloseModal={handleCloseCreate}\n/>\n)}\n
Si ahora hacemos una prueba en nuestro navegador y pulsamos el bot\u00f3n de editar vemos como nuestro formulario ya se carga correctamente pero hay un problema, si pulsamos el bot\u00f3n de editar, cerramos el formulario y le damos al boton de nueva categor\u00eda vemos que el formulario mantiene los datos anteriores. Vamos a solucionar este problema volviendo a dejar vacia la variable categoryToUpdate
cuando se cierre el componente:
Modificamos la funci\u00f3n handleCloseCreate
:
const handleCloseCreate = () => {\nsetOpenCreate(false);\nsetCategoryToUpdate(null);\n};\n
Y vemos que el funcionamiento ya es el correcto.
Ahora vamos a darle funcionalidad al bot\u00f3n de borrado. Cuando pulsemos sobre este bot\u00f3n se nos debe mostrar un mensaje de alerta para confirmar nuestra decisi\u00f3n. Como este es un mensaje que vamos a reutilizar en el resto de la aplicaci\u00f3n vamos a crear un componente en la carpeta src/components
llamado ConfirmDialog.tsx
con el siguiente contenido:
import Button from \"@mui/material/Button\";\nimport DialogContentText from \"@mui/material/DialogContentText\";\nimport Dialog from \"@mui/material/Dialog\";\nimport DialogActions from \"@mui/material/DialogActions\";\nimport DialogContent from \"@mui/material/DialogContent\";\nimport DialogTitle from \"@mui/material/DialogTitle\";\n\ninterface Props {\ncloseModal: () => void;\nconfirm: () => void;\ntitle: string;\ntext: string;\n}\n\nexport const ConfirmDialog = (props: Props) => {\nreturn (\n<div>\n<Dialog open={true} onClose={props.closeModal}>\n<DialogTitle>{props.title}</DialogTitle>\n<DialogContent>\n<DialogContentText>{props.text}</DialogContentText>\n</DialogContent>\n<DialogActions>\n<Button onClick={props.closeModal}>Cancelar</Button>\n<Button onClick={() => props.confirm()}>Confirmar</Button>\n</DialogActions>\n</Dialog>\n</div>\n);\n};\n
Y vamos a a\u00f1adirlo a nuestra p\u00e1gina de categorias, pero al igual que paso con nuestro formulario de altas no queremos que este componente se muestre siempre, sino que estar\u00e1 condicionado al valor de una nueva variable en nuestro estado. En este caso vamos a almacenar el id de la categor\u00eda a borrar.
Importamos nuestro nuevo componente:
import { ConfirmDialog } from \"../../components/ConfirmDialog\";\n
Creamos una nueva variable en el estado:
const [idToDelete, setIdToDelete] = useState(\"\");\n
Creamos una nueva funci\u00f3n:
const deleteCategory = () => {};\n
Modificamos el bot\u00f3n de borrado:
<IconButton\naria-label=\"delete\"\ncolor=\"error\"\nonClick={() => {\nsetIdToDelete(category.id);\n}}\n>\n<ClearIcon />\n</IconButton>\n
Y a\u00f1adimos el c\u00f3digo necesario en nuestro return para incluir el nuevo componente:
{!!idToDelete && (\n<ConfirmDialog\ntitle=\"Eliminar categor\u00eda\"\ntext=\"Atenci\u00f3n si borra la categor\u00eda se perder\u00e1n sus datos. \u00bfDesea eliminar la categor\u00eda?\"\nconfirm={deleteCategory}\ncloseModal={() => setIdToDelete('')}\n/>\n)}\n
"},{"location":"develop/basic/react/#recuperando-datos","title":"Recuperando datos","text":"Ya estamos preparados para llamar a nuestro back. Hay muchas maneras de recuperar datos del back en React. Si no queremos usar ninguna librer\u00eda externa podemos hacer uso del m\u00e9todo fetch, pero tendr\u00edamos que repetir mucho c\u00f3digo o bien construir interceptores, para el manejo de errores, construcci\u00f3n de middlewares,\u2026 adem\u00e1s, no es lo mas utilizado. Hoy en d\u00eda se opta por librer\u00edas como Axios
o Redux Toolkit query
que facilitan el uso de este m\u00e9todo.
Nosotros vamos a utilizar una herramienta de redux llamada Redux Toolkit Query
, pero primero vamos a explicar que es redux.
Redux es una librer\u00eda que implementa el patr\u00f3n de dise\u00f1o Flux y que nos permite crear un estado global.
Nuestros componentes pueden realizar acciones asociadas a un reducer que modificar\u00e1n este estado global llamado generalmente store
y a su vez estar\u00e1n subscritos a variables de este estado para estar atentos a posibles cambios.
Antes se sol\u00edan construir ficheros de actions, de reducers y un fichero de store, pero con redux toolkit se ha simplificado todo. Por un lado, podemos tener slices, que son ficheros que agrupan acciones, reducers y parte del estado y por otro lado podemos tener servicios donde declaramos llamadas a nuestra api y redux guarda las llamadas en nuestro estado global para que sean accesibles desde cualquier parte de nuestra aplicaci\u00f3n.
Vamos a crear una carpeta llamada redux
dentro de la carpeta src
y a su vez dentro de src/redux
vamos a crear dos carpetas: features
donde crearemos nuestros slices y services
donde crearemos las llamadas al api.
Dentro de la carpeta services
vamos a crear un fichero llamado ludotecaApi.ts
con el siguiente contenido:
import { createApi, fetchBaseQuery } from \"@reduxjs/toolkit/query/react\";\nimport { Category } from \"../../types/Category\";\n\nexport const ludotecaAPI = createApi({\nreducerPath: \"ludotecaApi\",\nbaseQuery: fetchBaseQuery({\nbaseUrl: \"http://localhost:8080\",\n}),\ntagTypes: [\"Category\"],\nendpoints: (builder) => ({\ngetCategories: builder.query<Category[], null>({\nquery: () => \"category\",\nprovidesTags: [\"Category\"],\n}),\ncreateCategory: builder.mutation({\nquery: (payload) => ({\nurl: \"/category\",\nmethod: \"PUT\",\nbody: payload,\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Category\"],\n}),\ndeleteCategory: builder.mutation({\nquery: (id: string) => ({\nurl: `/category/${id}`,\nmethod: \"DELETE\",\n}),\ninvalidatesTags: [\"Category\"],\n}),\nupdateCategory: builder.mutation({\nquery: (payload: Category) => ({\nurl: `category/${payload.id}`,\nmethod: \"PUT\",\nbody: payload,\n}),\ninvalidatesTags: [\"Category\"],\n}),\n}),\n});\n\nexport const {\nuseGetCategoriesQuery,\nuseCreateCategoryMutation,\nuseDeleteCategoryMutation,\nuseUpdateCategoryMutation\n} = ludotecaAPI;\n
Con esto ya habr\u00edamos creado las acciones que llaman al back y almacenan el resultado en nuestro estado. Para configurar nuestra api le tenemos que dar un nombre, una url base, una series de tags y nuestros endpoints que pueden ser de tipo query para realizar consultas o mutation
. Tambi\u00e9n exportamos los hooks que nos van a permitir hacer uso de estos endpoints. Si los endpoints los creamos de tipo query
, cuando hacemos uso de estos hooks se realizar\u00e1 una consulta al back y recibiremos los datos de la consulta en nuestros par\u00e1metros del hook entre otras cosas. Si los creamos de tipo mutation
lo que nos devolver\u00e1 el hook ser\u00e1 la acci\u00f3n que tenemos que llamar para realizar esta llamada.
Los tags sirven para cachear el resultado, pero cuando llamamos a una mutation
y pasamos informaci\u00f3n en invalidateTags
, esto va a hacer que se vuelva a lanzar la query afectada por estos tags para actualizar su resultado, por eso hemos a\u00f1adido el providesTags
en la query, para que desde nuestras p\u00e1ginas usemos los hooks exportados.
Ahora vamos a crear dentro de la carpeta src/redux
un fichero llamado store.ts
con el siguiente contenido:
import { configureStore } from \"@reduxjs/toolkit\";\nimport { setupListeners } from \"@reduxjs/toolkit/dist/query\";\nimport { ludotecaAPI } from \"./services/ludotecaApi\";\n\nexport const store = configureStore({\nreducer: {\n[ludotecaAPI.reducerPath]: ludotecaAPI.reducer,\n},\nmiddleware: (getDefaultMiddleware) =>\ngetDefaultMiddleware().concat([ludotecaAPI.middleware]),\n});\n\nsetupListeners(store.dispatch);\n\n// Infer the `RootState` and `AppDispatch` types from the store itself\nexport type RootState = ReturnType<typeof store.getState>;\n// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}\nexport type AppDispatch = typeof store.dispatch;\n
Aqu\u00ed b\u00e1sicamente creamos el store con nuestro reducer
. Cabe destacar que podemos crear tantos reducers
como queramos siempre que les demos distintos nombres.
Ahora ya podr\u00edamos hacer uso de los hooks que vienen con redux llamados useDispatch
para llamar a nuestras actions y useSelect
para suscribirnos a los cambios en el estado, pero como estamos usando typescript tendr\u00edamos que tipar todos estos m\u00e9todos y variables que usamos en todos nuestros componentes resultando un c\u00f3digo un tanto sucio y repetitivo. Tambi\u00e9n podemos simplemente ignorar a typescript y deshabilitar las reglas para estos ficheros, pero vamos a hacerlo bien.
Vamos a crear un fichero llamado hooks.ts
dentro de la carpeta de redux y su contenido ser\u00e1 el siguiente:
import { useDispatch, useSelector } from 'react-redux'\nimport type { TypedUseSelectorHook } from 'react-redux'\nimport type { RootState, AppDispatch } from './store'\n\n// Use throughout your app instead of plain `useDispatch` and `useSelector`\ntype DispatchFunc = () => AppDispatch\nexport const useAppDispatch: DispatchFunc = useDispatch\nexport const useAppSelector: TypedUseSelectorHook<RootState> = useSelector\n
Estos ser\u00e1n los m\u00e9todos que usaremos en lugar de useDispatch
y useSelector
.
Ahora vamos a modificar nuestro fichero App.tsx
a\u00f1adiendo los imports necesarios y rodeando nuestro c\u00f3digo con el tag provider
:
import { Provider } from \"react-redux\";\n\nimport { store } from \"./redux/store\";\n\n<Provider store={store}>\n<BrowserRouter>\n<Routes>\n<Route element={<Layout />}>\n<Route index path=\"games\" element={<Game />} />\n<Route path=\"categories\" element={<Category />} />\n<Route path=\"authors\" element={<Author />} />\n<Route path=\"*\" element={<Navigate to=\"/games\" />} />\n</Route>\n</Routes>\n</BrowserRouter>\n</Provider>\n
Ahora ya podemos hacer uso de los m\u00e9todos de redux
para modificar y leer el estado global de nuestra aplicaci\u00f3n.
Volvemos a nuestro componente Category
y vamos a importar los hooks de nuestra api para hacer uso de ellos:
import { useAppDispatch } from \"../../redux/hooks\";\nimport {\nuseCreateCategoryMutation,\nuseDeleteCategoryMutation,\nuseGetCategoriesQuery,\nuseUpdateCategoryMutation,\n} from \"../../redux/services/ludotecaApi\";\n
Eliminamos la variable mockeada data y a\u00f1adimos en su lugar lo siguiente:
const dispatch = useAppDispatch();\nconst { data, error, isLoading } = useGetCategoriesQuery(null);\n\nconst [\ndeleteCategoryApi,\n{ isLoading: isLoadingDelete, error: errorDelete },\n] = useDeleteCategoryMutation();\nconst [createCategoryApi, { isLoading: isLoadingCreate }] =\nuseCreateCategoryMutation();\n\nconst [updateCategoryApi, { isLoading: isLoadingUpdate }] =\nuseUpdateCategoryMutation();\n
Como ya hemos dicho anteriormente, los hooks
de la api de tipo query nos devolver\u00e1n datos mientras que los hooks
de tipo mutation
nos devuelven acciones que podemos lanzar con el m\u00e9todo dispatch
. El resto de los par\u00e1metros nos dan informaci\u00f3n para saber el estado de la llamada, por ejemplo, para saber si esta cargando, si se ha producido un error, etc\u2026
Tenemos que modificar el c\u00f3digo que recorre data ya que este valor ahora puede estar sin definir:
<TableBody>\n{data &&\ndata.map((category: CategoryModel) => (\n
Y ahora si tenemos datos en la base de datos y vamos a nuestro navegador podemos ver que ya se est\u00e1n representando estos datos en la tabla de categor\u00edas.
Modificamos el m\u00e9todo createCategory
:
const createCategory = (category: string) => {\nsetOpenCreate(false);\nif (categoryToUpdate) {\nupdateCategoryApi({ id: categoryToUpdate.id, name: category })\n.then(() => {\nsetCategoryToUpdate(null);\n})\n.catch((err) => console.log(err));\n} else {\ncreateCategoryApi({ name: category })\n.then(() => {\nsetCategoryToUpdate(null);\n})\n.catch((err) => console.log(err));\n}\n};\n
Si tenemos almacenada alguna categor\u00eda para actualizar llamaremos a la acci\u00f3n para actualizar la categor\u00eda que recuperamos del hook y si no tenemos categor\u00eda almacenada llamaremos al m\u00e9todo para crear una categor\u00eda nueva. Estos m\u00e9todos nos devuelven una promesa que cuando resolvemos volvemos a poner el valor de la categor\u00eda a actualizar a null
.
Implementamos el m\u00e9todo para borrar categor\u00edas:
const deleteCategory = () => {\ndeleteCategoryApi(idToDelete)\n.then(() => setIdToDelete(''))\n.catch((err) => console.log(err));\n};\n
Ahora si probamos en nuestro navegador ya podremos realizar todas las funciones de la p\u00e1gina: listar, crear, actualizar y borrar, pero aun vamos a darle m\u00e1s funcionalidad.
Vamos a crear una variable en el estado global de nuestra aplicaci\u00f3n para mostrar alertas de informaci\u00f3n o de error. Para ello creamos un nuevo fichero en la carpeta src/redux/features
llamado messageSlice.ts
cuyo contenido ser\u00e1 el siguiente:
import { createSlice } from '@reduxjs/toolkit'\nimport type {PayloadAction} from \"@reduxjs/toolkit\"\n\nexport const messageSlice = createSlice({\nname: 'message',\ninitialState: {\ntext: '',\ntype: ''\n},\nreducers: {\ndeleteMessage: (state) => {\nstate.text = ''\nstate.type = ''\n},\nsetMessage: (state, action : PayloadAction<{text: string; type: string}>) => {\nstate.text = action.payload.text;\nstate.type = action.payload.type;\n},\n},\n})\n\nexport const { deleteMessage, setMessage } = messageSlice.actions;\nexport default messageSlice.reducer;\n
Como ya hemos dicho anteriormente los slices
son un concepto introducido en Redux Toolkit
y no es ni m\u00e1s ni menos que un fichero que agrupa reducers
, actions
y selectors
.
En este fichero declaramos el nombre del selector (message
) para despu\u00e9s poder recuperar los datos en un componente, declaramos el estado inicial de nuestro slice
, creamos las funciones de los reducers
y declaramos dos acciones.
Los reducers
son funciones puras que modifican el estado, en nuestro caso utilizamos un reducer
para resetear el estado y otro para setear el texto y el tipo de mensaje. Con Redux Toolkit
podemos acceder directamente al estado dentro de nuestros reducers
. En los reducers
que no usan esta herramienta lo que se hace es devolver un objeto que ser\u00e1 el nuevo estado.
Las acciones son las que invocan a los reducers
. Estas solo dicen que hacer, pero no como hacerlo. Con Redux Toolkit
las acciones se generan autom\u00e1ticamente y solo tenemos que hacer un destructuring del objecto actions de nuestro slice
para recuperarlas y exportarlas.
Ahora vamos a modificar el fichero src/redux/store.ts
para a\u00f1adir el nuevo reducer
:
import { configureStore } from \"@reduxjs/toolkit\";\nimport { setupListeners } from \"@reduxjs/toolkit/dist/query\";\nimport { ludotecaAPI } from \"./services/ludotecaApi\";\nimport messageReducer from \"./features/messageSlice\";\n\nexport const store = configureStore({\nreducer: {\nmessageReducer,\n[ludotecaAPI.reducerPath]: ludotecaAPI.reducer,\n},\nmiddleware: (getDefaultMiddleware) =>\ngetDefaultMiddleware().concat([ludotecaAPI.middleware]),\n});\n\nsetupListeners(store.dispatch);\n\n// Infer the `RootState` and `AppDispatch` types from the store itself\nexport type RootState = ReturnType<typeof store.getState>;\n// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}\nexport type AppDispatch = typeof store.dispatch;\n
Con esto ya podemos hacer uso de esta funcionalidad. Vamos a modificar el componente Layout para que pueda recibir mensajes y mostrarlos por pantalla.
import Alert from \"@mui/material/Alert\";\nimport { useAppDispatch, useAppSelector } from \"../redux/hooks\";\nimport { deleteMessage } from \"../redux/features/messageSlice\";\n\nconst dispatch = useAppDispatch();\nconst { text, type } = useAppSelector((state) => state.messageReducer);\n\nuseEffect(() => {\nsetTimeout(() => {\ndispatch(deleteMessage());\n}, 3000);\n}, [text, type]);\n\n{text && (\n<Alert severity={type === \"error\" ? \"error\" : \"success\"}>{text}</Alert>\n)}\n
Hemos a\u00f1adido c\u00f3digo para que el componente layout
este subscrito a las variables text
y type
de nuestro contexto global. Si tenemos text
se mostrar\u00e1 la alerta y adem\u00e1s hemos incluido un nuevo hook
useEffect
gracias al cual cuando el componente reciba un text llamar\u00e1 a una funci\u00f3n que pasados 3 segundos borrar\u00e1 el mensaje de nuestro estado ocultando as\u00ed el Alert
.
Pero antes de seguir adelante vamos a explicar que hace useEffect
exactamente ya que es un hook
de React muy utilizado.
El ciclo de vida de los componentes en React permit\u00eda en los componentes de tipo clase poder ejecutar c\u00f3digo en diferentes fases de montaje, actualizaci\u00f3n y desmontaje. De esta forma, pod\u00edamos a\u00f1adir cierta funcionalidad en las distintas etapas de nuestro componente.
Con los hooks
tambi\u00e9n podremos acceder a ese ciclo de vida en nuestros componentes funcionales, aunque de una forma m\u00e1s clara y sencilla. Para ello usaremos useEffect
, un hook
que recibe como par\u00e1metro una funci\u00f3n que se ejecutar\u00e1 cada vez que se modifique el valor de las las dependencias que pasemos como segundo par\u00e1metro.
Hay otros casos especiales de useEffect
, por ejemplo, si hubi\u00e9semos dejado el array de dependencias de useEffect
vac\u00edo, solo se llamar\u00eda a la funci\u00f3n la primera vez que se renderiza el componente.
useEffect(() => {\nconsole.log(\u2018Solo me muestro en el primer render\u2019);\n}, []);\n
Y si queremos que solo se llame a la funci\u00f3n cuando se desmonta el componente lo que tenemos que hacer es devolver de useEffect
una funci\u00f3n con el c\u00f3digo que queremos que se ejecute una vez que se desmonte:
useEffect(() => {\nreturn () => {\nconsole.log(\u2018Me desmonto!!\u2019)\n}\n}, []);\n
Dentro de la carpeta src/types
vamos a crear un fichero llamado appTypes.ts
que contendr\u00e1 todos aquellos tipos o interfaces auxiliares para construir nuestra aplicaci\u00f3n:
export interface BackError {\nmsg: string;\n}\n
Ahora ya podemos incluir en nuestra p\u00e1gina de categor\u00edas el c\u00f3digo para guardar los mensajes de informaci\u00f3n y error en el estado global, importamos lo necesario:
import { setMessage } from \"../../redux/features/messageSlice\";\nimport { BackError } from \"../../types/appTypes\";\n
A\u00f1adimos:
useEffect(() => {\nif (errorDelete) {\nif (\"status\" in errorDelete) {\ndispatch(\nsetMessage({\ntext: (errorDelete?.data as BackError).msg,\ntype: \"error\",\n})\n);\n}\n}\n}, [errorDelete, dispatch]);\n\nuseEffect(() => {\nif (error) {\ndispatch(setMessage({ text: \"Se ha producido un error\", type: \"error\" }));\n}\n}, [error]);\n
Y modificamos:
const createCategory = (category: string) => {\nsetOpenCreate(false);\nif (categoryToUpdate) {\nupdateCategoryApi({ id: categoryToUpdate.id, name: category })\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Categor\u00eda actualizada correctamente\",\ntype: \"ok\",\n})\n);\nsetCategoryToUpdate(null);\n})\n.catch((err) => console.log(err));\n} else {\ncreateCategoryApi({ name: category })\n.then(() => {\ndispatch(\nsetMessage({ text: \"Categor\u00eda creada correctamente\", type: \"ok\" })\n);\nsetCategoryToUpdate(null);\n})\n.catch((err) => console.log(err));\n}\n};\n\nconst deleteCategory = () => {\ndeleteCategoryApi(idToDelete)\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Categor\u00eda borrada correctamente\",\ntype: \"ok\",\n})\n);\nsetIdToDelete(\"\");\n})\n.catch((err) => console.log(err));\n};\n
Si ahora probamos nuestra aplicaci\u00f3n al borrar, actualizar o crear una categor\u00eda nos deber\u00eda de mostrar un mensaje de informaci\u00f3n.
Ya casi estamos terminando con nuestra p\u00e1gina de categor\u00edas, pero vamos a a\u00f1adir tambi\u00e9n un loader
para cuando nuestra acciones est\u00e9n en estado de loading
. Para esto vamos a hacer uso de otra de las maneras que tiene React de almacenar informaci\u00f3n global, el contexto.
Una de las caracter\u00edsticas que llegaron en las \u00faltimas versiones de React fue el contexto, una forma de pasar datos que pueden considerarse globales a un \u00e1rbol de componentes sin la necesidad de utilizar Redux
. El uso de contextos mediante la Context API
es una soluci\u00f3n m\u00e1s ligera y sencilla que redux y que no est\u00e1 mal para aplicaciones que no son excesivamente grandes.
En general cuando queramos usar estados globales que no sean demasiado grandes y no se haga demasiada escritura sobre ellos ser\u00e1 preferible usar Context API
en lugar de redux
.
Vamos a crear un contexto para utilizar un loader
en nuestra aplicaci\u00f3n.
Lo primero ser\u00e1 crear una carpeta llamada context
dentro de la carpeta src
de nuestro proyecto y dentro de esta crearemos un nuevo fichero llamado LoaderProvider.tsx
con el siguiente contenido:
import { createContext, useState } from \"react\";\nimport Backdrop from \"@mui/material/Backdrop\";\nimport CircularProgress from \"@mui/material/CircularProgress\";\n\nexport const LoaderContext = createContext({\nloading: false,\nshowLoading: (_show: boolean) => {},\n});\n\ntype Props = {\nchildren: JSX.Element;\n};\n\nexport const LoaderProvider = ({ children }: Props) => {\nconst showLoading = (show: boolean) => {\nsetState((prev) => ({\n...prev,\nloading: show,\n}));\n};\n\nconst [state, setState] = useState({\nloading: false,\nshowLoading,\n});\n\nreturn (\n<LoaderContext.Provider value={state}>\n<Backdrop\nsx={{ color: \"#fff\", zIndex: (theme) => theme.zIndex.drawer + 1 }}\nopen={state.loading}\n>\n<CircularProgress color=\"inherit\" />\n</Backdrop>\n\n{children}\n</LoaderContext.Provider>\n);\n};\n
Y ahora modificamos nuestro fichero App.tsx
de la siguiente manera:
import { LoaderProvider } from \"./context/LoaderProvider\";\n<LoaderProvider>\n<Provider store={store}>\n<BrowserRouter>\n<Routes>\n<Route element={<Layout />}>\n<Route index path=\"games\" element={<Game />} />\n<Route path=\"categories\" element={<Category />} />\n<Route path=\"authors\" element={<Author />} />\n<Route path=\"*\" element={<Navigate to=\"/games\" />} />\n</Route>\n</Routes>\n</BrowserRouter>\n</Provider>\n</LoaderProvider>\n
Lo que hemos hecho ha sido envolver toda nuestra aplicaci\u00f3n dentro de nuestro provider
de tal modo que esta el children
en el fichero LoaderProvider
, pero ahora y gracias a la funcionalidad de createContext
la variable loading
y el m\u00e9todo showLoading
estar\u00e1n disponibles en todos los sitios de nuestra aplicaci\u00f3n.
Ahora para hacer uso de esta funcionalidad nos vamos a nuestra pagina de Categorias
e importamos lo siguiente:
import { useState, useEffect, useContext } from \"react\";\n\nimport { LoaderContext } from \"../../context/LoaderProvider\";\n
Declaramos una nueva constante:
const loader = useContext(LoaderContext);\n
Podemos hace uso del m\u00e9todo showLoading
donde queramos, en nuestro caso vamos a crear otro useEffect
que estar\u00e1 pendiente de los cambios en cualquiera de los loadings
:
useEffect(() => {\nloader.showLoading(\nisLoadingCreate || isLoading || isLoadingDelete || isLoadingUpdate\n);\n}, [isLoadingCreate, isLoading, isLoadingDelete, isLoadingUpdate]);\n
Probamos la aplicaci\u00f3n y vemos que cuando se carga el listado o realizamos cualquier llamada al back se muestra brevemente nuestro loader
.
Ahora que ya tenemos listo el proyecto backend de Spring Boot (en el puerto 8080) ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/springboot/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 en Spring Boot tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. Tanto la propia web de Spring como en el portal de tutoriales de Baeldung puedes buscar casi cualquier ejemplo que necesites.
"},{"location":"develop/basic/springboot/#estructurar-el-codigo","title":"Estructurar el c\u00f3digo","text":"Vamos a hacer un breve refresco de la estructura del c\u00f3digo que ya se ha visto en puntos anteriores.
Las clases deben estar agrupadas por \u00e1mbito funcional, en nuestro caso como vamos a hacer la funcionalidad de Categor\u00edas
pues deber\u00eda estar todo dentro de un package del tipo com.ccsw.tutorial.category
.
Adem\u00e1s, deber\u00edamos aplicar la separaci\u00f3n por capas como ya se vi\u00f3 en el esquema:
La primera capa, la de Controlador
, se encargar\u00e1 de procesar las peticiones y transformar datos. Esta capa llamar\u00e1 a la capa de L\u00f3gica
de negocio que ejecutar\u00e1 las operaciones, ayud\u00e1ndose de otros objetos de esa misma capa de L\u00f3gica
o bien de llamadas a datos a trav\u00e9s de la capa de Acceso a Datos
Ahora s\u00ed, vamos a programar!.
"},{"location":"develop/basic/springboot/#capa-de-operaciones-controller","title":"Capa de operaciones: Controller","text":"En esta capa es donde se definen las operaciones que pueden ser consumidas por los clientes. Se caracterizan por estar anotadas con las anotaciones @Controller o @RestController y por las anotaciones @RequestMapping que nos permiten definir las rutas de acceso.
Recomendaci\u00f3n: Breve detalle REST
Antes de continuar te recomiendo encarecidamente que leas el Anexo: Detalle REST donde se explica brevemente como estructurar los servicios REST que veremos a continuaci\u00f3n.
"},{"location":"develop/basic/springboot/#controller-de-ejemplo","title":"Controller de ejemplo","text":"Vamos a crear una clase CategoryController.java
dentro del package com.ccsw.tutorial.category
para definir las rutas de las operaciones.
package com.ccsw.tutorial.category;\n\nimport java.util.List;\n\nimport org.springframework.web.bind.annotation.CrossOrigin;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestMethod;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport io.swagger.v3.oas.annotations.tags.Tag;\n\n/**\n * @author ccsw\n * \n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\n/**\n * M\u00e9todo para probar el servicio\n * \n */\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic String prueba() {\n\nreturn \"Probando el Controller\";\n}\n\n}\n
Ahora si arrancamos la aplicaci\u00f3n server, abrimos el Postman y creamos una petici\u00f3n GET a la url http://localhost:8080/category nos responder\u00e1 con el mensaje que hemos programado.
"},{"location":"develop/basic/springboot/#implementar-operaciones","title":"Implementar operaciones","text":"Ahora que ya tenemos un controlador y una operaci\u00f3n de negocio ficticia, vamos a borrarla y a\u00f1adir las operaciones reales que consumir\u00e1 nuestra pantalla. Deberemos a\u00f1adir una operaci\u00f3n para listar, una para actualizar, una para guardar y una para borrar. Aunque para hacerlo m\u00e1s c\u00f3modo, utilizaremos la misma operaci\u00f3n para guardar y para actualizar. Adem\u00e1s, no vamos a trabajar directamente con datos simples, sino que usaremos objetos para recibir informaci\u00f3n y para enviar informaci\u00f3n.
Estos objetos t\u00edpicamente se denominan DTO (Data Transfer Object) y nos sirven justamente para encapsular informaci\u00f3n que queremos transportar. En realidad no son m\u00e1s que clases pojo sencillas con propiedades, getters y setters.
Para nuestro ejemplo crearemos una clase CategoryDto
dentro del package com.ccsw.tutorial.category.model
con el siguiente contenido:
package com.ccsw.tutorial.category.model;\n\n/**\n * @author ccsw\n * \n */\npublic class CategoryDto {\n\nprivate Long id;\n\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
A continuaci\u00f3n utilizaremos esta clase en nuestro Controller para implementar las tres operaciones de negocio.
CategoryController.javapackage com.ccsw.tutorial.category;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport org.springframework.web.bind.annotation.CrossOrigin;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestMethod;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\n\n/**\n * @author ccsw\n * \n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\nprivate long SEQUENCE = 1;\nprivate Map<Long, CategoryDto> categories = new HashMap<Long, CategoryDto>();\n\n/**\n * M\u00e9todo para recuperar todas las categorias\n *\n * @return {@link List} de {@link CategoryDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Categories\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<CategoryDto> findAll() {\n\nreturn new ArrayList<CategoryDto>(this.categories.values());\n}\n\n/**\n * M\u00e9todo para crear o actualizar una categoria\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Category\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody CategoryDto dto) {\n\nCategoryDto category;\n\nif (id == null) {\ncategory = new CategoryDto();\ncategory.setId(this.SEQUENCE++);\nthis.categories.put(category.getId(), category);\n} else {\ncategory = this.categories.get(id);\n}\n\ncategory.setName(dto.getName());\n}\n\n/**\n * M\u00e9todo para borrar una categoria\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Category\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) {\n\nthis.categories.remove(id);\n}\n}\n
Como todav\u00eda no tenemos acceso a BD, hemos creado una variable tipo HashMap y una variable Long, que simular\u00e1n una BD y una secuencia. Tambi\u00e9n hemos implementado tres operaciones GET, PUT y DELETE que realizan las acciones necesarias por nuestra pantalla. Ahora podr\u00edamos probarlo desde el Postman con cuatro ejemplo sencillos.
F\u00edjate que el m\u00e9todo save
tiene dos rutas. La ruta normal category/
y la ruta informada category/3
. Esto es porque hemos juntado la acci\u00f3n create y update en un mismo m\u00e9todo para facilitar el desarrollo. Es totalmente v\u00e1lido y funcional.
Atenci\u00f3n
Los datos que se reciben pueden venir informados como un par\u00e1metro en la URL Get, como una variable en el propio path o dentro del body de la petici\u00f3n. Cada uno de ellos se recupera con una anotaci\u00f3n especial: @RequestParam
, @PathVariable
y @RequestBody
respectivamente.
Como no tenemos ning\u00fan dato dado de alta, podemos probar en primer lugar a realizar una inserci\u00f3n de datos con el m\u00e9todo PUT.
PUT /category nos sirve para insertar Categor\u00edas
nuevas (si no tienen el id informado) o para actualizar Categor\u00edas
(si tienen el id informado). F\u00edjate que los datos que se env\u00edan est\u00e1n en el body como formato JSON (parte izquierda de la imagen). Si no env\u00edas datos, te dar\u00e1 un error.
GET /category nos devuelve un listado de Categor\u00edas
, siempre que hayamos insertado algo antes.
DELETE /category nos sirve eliminar Categor\u00edas
. F\u00edjate que el dato del ID que se env\u00eda est\u00e1 en el path.
Prueba a jugar borrando categor\u00edas que no existen o modificando categor\u00edas que no existen. Tal y como est\u00e1 programado, el borrado no dar\u00e1 error, pero la modificaci\u00f3n deber\u00eda dar un NullPointerException al no existir el dato a modificar.
"},{"location":"develop/basic/springboot/#documentacion-openapi","title":"Documentaci\u00f3n (OpenAPI)","text":"Si te acuerdas, en el punto de Entorno de desarrollo
, a\u00f1adimos el m\u00f3dulo de OpenAPI a nuestro proyecto, y en el desarrollo de nuestro Controller
hemos anotado tanto la clase como los m\u00e9todos con sus correspondientes etiquetas @Tag
y @Operation
.
Esto nos va a ayudar a generar documentaci\u00f3n autom\u00e1tica de nuestras APIs haciendo que nuestro c\u00f3digo sea m\u00e1s mantenible y nuestra documentaci\u00f3n mucho m\u00e1s fiable.
Para ver el resultado, con el proyecto arrancado nos dirigimos a la ruta por defecto de OpenAPI: http://localhost:8080/swagger-ui/index.html
Aqu\u00ed podemos observar el cat\u00e1logo de endpoints generados, ver los tipos de entrada y salida e incluso realizar peticiones a los mismos. Este ser\u00e1 el contrato de nuestros endpoints, que nos ayudar\u00e1 a integrarnos con el equipo frontend (en el caso del tutorial seguramente seremos nosotros mismos).
"},{"location":"develop/basic/springboot/#aspectos-importantes","title":"Aspectos importantes","text":"Los aspectos importantes de la capa Controller
son:
@Controller
o @RestController
. Mejor usar la \u00faltima anotaci\u00f3n, ya que est\u00e1s diciendo que las operaciones son de tipo Rest y no har\u00e1 falta configurar nada@RequestMapping
global de la clase, aunque tambi\u00e9n se puede obviar esta anotaci\u00f3n y a\u00f1adir a cada una de las operaciones la ruta ra\u00edz.@RequestMapping
con la info:path
\u2192 Que nos permite definir un path para la operaci\u00f3n, siempre sum\u00e1ndole el path de la clase (si es que tuviera)method
\u2192 Que nos permite definir el verbo de http que vamos a atender. Podemos tener el mismo path con diferente method, sin problema. Por lo general utilizaremos:Pero en realidad la cosa no funciona as\u00ed. Hemos implementado parte de la l\u00f3gica de negocio (las operaciones/acciones de guardado, borrado y listado) dentro de lo que ser\u00eda la capa de operaciones o servicios al cliente. Esta capa no debe ejecutar l\u00f3gica de negocio, tan solo debe hacer transformaciones de datos y enrutar peticiones, toda la l\u00f3gica deber\u00eda ir en la capa de servicio.
"},{"location":"develop/basic/springboot/#implementar-servicios","title":"Implementar servicios","text":"Pues vamos a arreglarlo. Vamos a crear un servicio y vamos a mover la l\u00f3gica de negocio al servicio.
CategoryService.javaCategoryServiceImpl.javaCategoryController.javapackage com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n * \n */\npublic interface CategoryService {\n\n/**\n * M\u00e9todo para recuperar todas las categor\u00edas\n *\n * @return {@link List} de {@link Category}\n */\nList<CategoryDto> findAll();\n\n/**\n * M\u00e9todo para crear o actualizar una categor\u00eda\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, CategoryDto dto);\n\n/**\n * M\u00e9todo para borrar una categor\u00eda\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id);\n\n}\n
package com.ccsw.tutorial.category;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport org.springframework.stereotype.Service;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\n/**\n * @author ccsw\n *\n */\n@Service\npublic class CategoryServiceImpl implements CategoryService {\n\nprivate long SEQUENCE = 1;\nprivate Map<Long, CategoryDto> categories = new HashMap<Long, CategoryDto>();\n\n/**\n * {@inheritDoc}\n */\npublic List<CategoryDto> findAll() {\n\nreturn new ArrayList<CategoryDto>(this.categories.values());\n}\n\n/**\n * {@inheritDoc}\n */\npublic void save(Long id, CategoryDto dto) {\n\nCategoryDto category;\n\nif (id == null) {\ncategory = new CategoryDto();\ncategory.setId(this.SEQUENCE++);\nthis.categories.put(category.getId(), category);\n} else {\ncategory = this.categories.get(id);\n}\n\ncategory.setName(dto.getName());\n}\n\n/**\n * {@inheritDoc}\n */\npublic void delete(Long id) {\n\nthis.categories.remove(id);\n}\n\n}\n
package com.ccsw.tutorial.category;\n\nimport java.util.List;\n\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.CrossOrigin;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestMethod;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\n\n/**\n * @author ccsw\n * \n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\n@Autowired\nprivate CategoryService categoryService;\n\n/**\n * M\u00e9todo para recuperar todas las categorias\n *\n * @return {@link List} de {@link CategoryDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Categories\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<CategoryDto> findAll() {\n\nreturn this.categoryService.findAll();\n}\n\n/**\n * M\u00e9todo para crear o actualizar una categoria\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Category\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody CategoryDto dto) {\n\nthis.categoryService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para borrar una categoria\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Category\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) {\n\nthis.categoryService.delete(id);\n}\n}\n
Ahora ya tenemos bien estructurado nuestro proyecto. Ya tenemos las dos capas necesarias Controladores y Servicios y cada uno se encarga de llevar a cabo su cometido de forma correcta.
"},{"location":"develop/basic/springboot/#aspectos-importantes_1","title":"Aspectos importantes","text":"Los aspectos importantes de la capa Service
son:
@Service
y adem\u00e1s debe implementar la Interface del servicio. Un error muy com\u00fan al arrancar un proyecto y ver que no funcionan las llamadas, es porqu\u00e9 no existe la anotaci\u00f3n @Service
o porqu\u00e9 no se ha implementado la Interface.inyectar
y utilizar componentes manejados por Spring Boot es mediante la anotaci\u00f3n @Autowired
. NO intentes crear un objeto de CategoryServiceImpl, ni hacer un new
, ya que no estar\u00e1 manejado por Springboot y dar\u00e1 fallos de NullPointer. Lo mejor es dejar que Spring Boot lo gestione y utilizar las inyecciones de dependencias.Pero no siempre vamos a acceder a los datos mediante un HasMap en memoria. En algunas ocasiones queremos que nuestro proyecto acceda a un servicio de datos como puede ser una BBDD, un servicio externo, un acceso a disco, etc. Estos accesos se deben hacer desde la capa de acceso a datos, y en concreto para nuestro ejemplo, lo haremos a trav\u00e9s de un Repository para que acceda a una BBDD.
Para el tutorial no necesitamos configurar una BBDD externa ni complicarnos demasiado. Vamos a utilizar una librer\u00eda muy \u00fatil llamada H2
que nos permite levantar una BBDD en memoria persistiendo los datos en memoria o en disco, de hecho ya la configuramos en el apartado de Entorno de desarrollo
.
Lo primero que haremos ser\u00e1 crear nuestra entity con la que vamos a persistir y recuperar informaci\u00f3n. Las entidades igual que los DTOs deber\u00edan estar agrupados dentro del package model
de cada funcionalidad, as\u00ed que vamos a crear una nueva clase java.
package com.ccsw.tutorial.category.model;\n\nimport jakarta.persistence.*;\n\n/**\n * @author ccsw\n * \n */\n@Entity\n@Table(name = \"category\")\npublic class Category {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"name\", nullable = false)\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
Si te fijas, la Entity suele ser muy similar a un DTO, tiene unas propiedades y sus getters y setters. Pero a diferencia de los DTOs, esta clase tiene una serie de anotaciones que permiten a JPA hacer su magia y generar consultas SQL a la BBDD. En este ejemplo vemos 4 anotaciones importantes:
@Entity
\u2192 Le indica a Springboot que se trata de una clase que implementa una Entidad de BBDD. Sin esta anotaci\u00f3n no es posible hacer queries.@Table
\u2192 Le indica a JPA el nombre y el schema de la tabla que representa esta clase. Por claridad se deber\u00eda poner siempre, aunque si el nombre de la tabla es igual al nombre de la clase no es necesaria la anotaci\u00f3n.@Id
y @GeneratedValue
\u2192 Le indica a JPA que esta propiedad es la que mapea una Primary Key y adem\u00e1s que esta PK se genera con la estrategia que se le indique en la anotaci\u00f3n @GeneratedValue
, que puede ser:Secuence
, la que utiliza Oracle, en este caso habr\u00e1 que indicarle un nombre de secuencia.Indentity
, la que utiliza MySql o SQLServer, el auto-incremental. Table
, en algunas BBDD se permite tener una tabla donde se almacenan como registros todas las secuencias.Auto
, elige la mejor estrategia en funci\u00f3n de la BBDD que hemos seleccionado.@Column
\u2192 Le indica a JPA que esta propiedad mapea una columna de la tabla y le especifica el nombre de la columna. Al igual que la anotaci\u00f3nd de Table
, esta anotaci\u00f3n no es necesaria aunque si es muy recomendable. Por claridad se deber\u00eda poner siempre, aunque si el nombre de la columna es igual al nombre de la propiedad no es necesaria la anotaci\u00f3n.Hay muchas otras anotaciones, pero estas son las b\u00e1sicas, ya ir\u00e1s aprendiendo otras.
Consejo
Para definir las PK de las tablas, intenta evitar una PK compuesta de m\u00e1s de una columna. La programaci\u00f3n se hace muy compleja y las magias que hace JPA en la oscuridad se complican mucho. Mi recomendaci\u00f3n es que siempre utilices una PK n\u00famerica, en la medida de lo posible, y si es necesario, crees \u00edndices compuestos de b\u00fasqueda o checks compuestos para evitar duplicidades.
"},{"location":"develop/basic/springboot/#juego-de-datos-de-bbdd","title":"Juego de datos de BBDD","text":"Spring Boot autom\u00e1ticamente cuando arranque el proyecto escaner\u00e1 todas las @Entity
y crear\u00e1 las estructuras de las tablas en la BBDD en memoria, gracias a las anotaciones que hemos puesto. Adem\u00e1s de esto, lanzar\u00e1 los scripts de construcci\u00f3n de BBDD que tenemos en la carpeta src/main/resources/
. As\u00ed que, teniendo clara la estructura de la Entity
podemos configurar los ficheros con los juegos de datos que queramos, y para ello vamos a utilizar el fichero data.sql
que creamos en su momento.
Sabemos que la tabla se llamar\u00e1 category
y que tendr\u00e1 dos columnas, una columna id
, que ser\u00e1 la PK autom\u00e1tica, y una columna name
. Podemos escribir el siguiente script para rellenar datos:
INSERT INTO category(name) VALUES ('Eurogames');\nINSERT INTO category(name) VALUES ('Ameritrash');\nINSERT INTO category(name) VALUES ('Familiar');\n
"},{"location":"develop/basic/springboot/#implementar-repository","title":"Implementar Repository","text":"Ahora que ya tenemos el juego de datos y la entidad implementada, vamos a ver como acceder a BBDD desde Java. Esto lo haremos con un Repository
. Existen varias formas de utilizar los repositories, desde el todo autom\u00e1tico y magia de JPA hasta el repositorio manual en el que hay que codificar todo. En el tutorial voy a explicar varias formas de implementarlo para este CRUD y los siguientes CRUDs.
Como ya se dijo en puntos anteriores, el acceso a datos se debe hacer siempre a trav\u00e9s de un Repository
, as\u00ed que vamos a implementar uno. En esta capa, al igual que pasaba con los services, es recomendable utilizar el patr\u00f3n fachada, para poder sustituir implementaciones sin afectar al c\u00f3digo.
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface CategoryRepository extends CrudRepository<Category, Long> {\n\n}\n
\u00bfQu\u00e9 te parece?, sencillo, \u00bfno?. Spring ya tiene una implementaci\u00f3n por defecto de un CrudRepository, tan solo tenemos que crear una interface que extienda de la interface CrudRepository
pas\u00e1ndole como tipos la Entity
y el tipo de la Primary Key. Con eso Spring construye el resto y nos provee de los m\u00e9todos t\u00edpicos y necesarios para un CRUD.
Ahora vamos a utilizarla en \u00e9l Service
, pero hay un problema. \u00c9l Repository
devuelve un objeto tipo Category
y \u00e9l Service
y Controller
devuelven un objeto tipo CategoryDto
. Esto es porque en cada capa se debe con un \u00e1mbito de modelos diferente. Podr\u00edamos hacer que todo el back trabajara con Category
que son entidades de persistencia, pero no es lo correcto y nos podr\u00eda llevar a cometer errores, o modificar el objeto y que sin que nosotros lo orden\u00e1semos se persistiera ese cambio en BBDD.
El \u00e1mbito de trabajo de las capas con el que solemos trabajar y est\u00e1 m\u00e1s extendido es el siguiente:
Controller
esos datos json se transforman en un DTO. Al salir del Controller
hacia el cliente, esos DTOs se transforman en formato json. Estas conversiones son autom\u00e1ticas, las hace Springboot (en realidad las hace la librer\u00eda de jackson codehaus).Controller
ejecuta una llamada a un Service
, generalmente le pasa sus datos en DTO, y el Service
se encarga de transformar esto a una Entity
. A la inversa, cuando un Service
responde a un Controller
, \u00e9l responde con una Entity
y el Controller
ya se encargar\u00e1 de transformarlo a DTO.Repository
, siempre se trabaja de entrada y salida con objetos tipo Entity
Parece un l\u00edo, pero ya ver\u00e1s como es muy sencillo ahora que veremos el ejemplo. Una \u00faltima cosa, para hacer esas transformaciones, las podemos hacer a mano usando getters y setters o bien utilizar el objeto DozerBeanMapper
que hemos configurado al principio.
El c\u00f3digo deber\u00eda quedar as\u00ed:
CategoryServiceImpl.javaCategoryService.javaCategoryController.javapackage com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class CategoryServiceImpl implements CategoryService {\n\n@Autowired\nCategoryRepository categoryRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Category> findAll() {\n\nreturn (List<Category>) this.categoryRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, CategoryDto dto) {\n\nCategory category;\n\nif (id == null) {\ncategory = new Category();\n} else {\ncategory = this.categoryRepository.findById(id).orElse(null);\n}\n\ncategory.setName(dto.getName());\n\nthis.categoryRepository.save(category);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.categoryRepository.findById(id).orElse(null) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.categoryRepository.deleteById(id);\n}\n\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n * \n */\npublic interface CategoryService {\n\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link Category}\n */\nList<Category> findAll();\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, CategoryDto dto);\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\n@Autowired\nCategoryService categoryService;\n\n@Autowired\nModelMapper mapper;\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link CategoryDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Categories\"\n)\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<CategoryDto> findAll() {\n\nList<Category> categories = this.categoryService.findAll();\n\nreturn categories.stream().map(e -> mapper.map(e, CategoryDto.class)).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Category\"\n)\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody CategoryDto dto) {\n\nthis.categoryService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Category\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\nthis.categoryService.delete(id);\n}\n\n}\n
El Service
no tiene nada raro, simplemente accede al Repository
(siempre anotado como @Autowired
) y recupera datos o los guarda. F\u00edjate en el caso especial del save que la \u00fanica diferencia es que en un caso tendr\u00e1 id != null
y por tanto internamente har\u00e1 un update, y en otro caso tendr\u00e1 id == null
y por tanto internamente har\u00e1 un save.
En cuanto a la interface, lo \u00fanico que cambiamos fue los objetos de respuesta, que ahora pasan a ser de tipo Category
. Los de entrada se mantienen como CategoryDto
.
Por \u00faltimo, en \u00e9l Controller
se puede ver como se utiliza el conversor de DozerBeanMapper
(siempre anotado como @Autowired
), que permite traducir una lista a un tipo concreto, o un objeto \u00fanico a un tipo concreto. La forma de hacer estas conversiones siempre es por nombre de propiedad. Las propiedades del objeto destino se deben llamar igual que las propiedades del objeto origen. En caso contrario se quedar\u00e1n a null.
Ojo con el mapeo
Ojo a esta \u00faltima frase, debe quedar meridianamente claro. La forma de mapear de un objeto origen a un objeto destino siempre es a trav\u00e9s del nombre. Los getters del origen deben ser iguales a los getters del destino. Si hay una letra diferente o unas may\u00fasculas o min\u00fasculas diferentes NO realizar\u00e1 el mapeo y se quedar\u00e1 la propiedad a null.
Para terminar, cuando queramos realizar un mapeo masivo de los diferentes registros, tenemos que itulizar la API Stream de Java, que nos proporciona una forma sencilla de realizar estas operativas, sobre colecciones de elementos, mediante el uso del m\u00e9todo intermedio map
y el reductor por defecto para listas. Te recomiendo echarle un ojo a la teor\u00eda de Introducci\u00f3n a API Java Streams.
BBDD
Si quires ver el contenido de la base de datos puedes acceder a un IDE web autopublicado por H2 en la ruta http://localhost:8080/h2-console
Los aspectos importantes de la capa Repository
son:
Service
, se debe utilizar el patr\u00f3n fachada, por lo que tendremos una Interface y posiblemente una implementaci\u00f3n.Repository
trabajan siempre con Entity
que no son m\u00e1s que mapeos de una tabla o de una vista que existe en BBDD.Repository
no contienen l\u00f3gica de negocio, ni transformaciones, simplemente acceder a datos, persisten o devuelven informaci\u00f3n.Repository
JAM\u00c1S deben llamar a otros Repository
ni Service
.Entity
sean lo m\u00e1s sencillas posible, sobre todo en cuanto a Primary Keys, se simplificar\u00e1 mucho el desarrollo.Por \u00faltimo y aunque no deber\u00eda ser lo \u00faltimo que se desarrolla sino todo lo contrario, deber\u00eda ser lo primero en desarrollar, tenemos la bater\u00eda de pruebas. Con fines did\u00e1cticos, he querido ense\u00f1arte un ciclo de desarrollo para ir recorriendo las diferentes capas de una aplicaci\u00f3n, pero en realidad, para realizar el desarrollo deber\u00eda aplicar TDD (Test Driven Development). Si quieres aprender las reglas b\u00e1sicas de como aplicar TDD al desarrollo diario, te recomiendo que leas el Anexo. TDD.
En este caso, y sin que sirva de precedente, ya tenemos implementados los m\u00e9todos de la aplicaci\u00f3n, y ahora vamos a testearlos. Existen muchas formas de testing en funci\u00f3n del componente o la capa que se quiera testear. En realidad, a medida que vayas programando ir\u00e1s aprendiendo todas ellas, de momento realizaremos dos tipos de test simples que prueben las casu\u00edsticas de los m\u00e9todos.
El enfoque que seguiremos en este tutorial ser\u00e1 realizar las pruebas mediante test unitarios y test de integraci\u00f3n.
Lo primero ser\u00e1 conocer que queremos probar y para ello nos vamos a hacer una lista:
Test unitarios:
Categor\u00eda
Categor\u00eda
Categor\u00eda
existenteCategor\u00eda
existenteTest de integraci\u00f3n:
Categor\u00eda
Categor\u00eda
Categor\u00eda
existenteCategor\u00eda
que no existeCategor\u00eda
existenteCategor\u00eda
que no existeSe podr\u00edan hacer muchos m\u00e1s tests, pero creo que con esos son suficientes para que entiendas como se comporta esta capa. Si te fijas, hay que probar tanto los resultados correctos como los resultados incorrectos. El usuario no siempre se va a comportar como nosotros pensamos.
Pues vamos a ello.
"},{"location":"develop/basic/springboot/#pruebas-de-listado","title":"Pruebas de listado","text":"Vamos a empezar haciendo una clase de test dentro de la carpeta src/test/java
. No queremos que los test formen parte del c\u00f3digo productivo de la aplicaci\u00f3n, por eso utilizamos esa ruta que queda fuera del scope general de la aplicaci\u00f3n (main).
Crearemos las clases (en la package category
):
com.ccsw.tutorial.category.CategoryTest
com.ccsw.tutorial.category.CategoryIT
package com.ccsw.tutorial.category;\n\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\n@ExtendWith(MockitoExtension.class)\npublic class CategoryTest {\n\n}\n
package com.ccsw.tutorial.category;\n\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.web.client.TestRestTemplate;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.test.annotation.DirtiesContext;\n\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)\n@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)\npublic class CategoryIT {\n\n@LocalServerPort\nprivate int port;\n\n@Autowired\nprivate TestRestTemplate restTemplate;\n\n}\n
Estas clases son sencillas y tan solo tienen anotaciones espec\u00edficas de Spring Boot para cada tipo de test:
@SpringBootTest
. Esta anotaci\u00f3n lo que hace es inicializar el contexto de Spring cada vez que se inician los test de jUnit. Aunque el contexto tarda unos segundos en arrancar, lo bueno que tiene es que solo se inicializa una vez y se lanzan todos los jUnits en bater\u00eda con el mismo contexto.@DirtiesContext
. Esta anotaci\u00f3n le indica a Spring que los test van a ser transaccionales, y por tanto cuando termine la ejecuci\u00f3n de cada uno de los test, autom\u00e1ticamente por la anotaci\u00f3n de arriba, Spring har\u00e1 un rearranque parcial del contexto y dejar\u00e1 el estado de la BBDD como estaba inicialmente.@ExtendWith
. Esta anotaci\u00f3n le indica a Spring que no debe inicializar el contexto, ya que se trata de test est\u00e1ticos que no lo requieren.Para las pruebas de integraci\u00f3n nos faltar\u00e1 configurar la aplicaci\u00f3n de test, al igual que hicimos con la aplicaci\u00f3n 'productiva'. Deberemos abrir el fichero src/test/resources/application.properties
y a\u00f1adir la configuraci\u00f3n de la BBDD. Para este tutorial vamos a utilizar la misma BBDD que la aplicaci\u00f3n productiva, pero de normal la aplicaci\u00f3n se conectar\u00e1 a una BBDD, generalmente f\u00edsica, mientras que los test jUnit se conectar\u00e1n a otra BBDD, generalmente en memoria.
#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\n
Con todo esto ya podemos crear nuestro primer test. Iremos a las clases CategoryIT
y CategoryTest
donde a\u00f1adiremos un m\u00e9todo p\u00fablico. Los test siempre tienen que ser m\u00e9todos p\u00fablicos que devuelvan el tipo void
.
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.*;\n@ExtendWith(MockitoExtension.class)\npublic class CategoryTest {\n\n@Mock\nprivate CategoryRepository categoryRepository;\n@InjectMocks\nprivate CategoryServiceImpl categoryService;\n@Test\npublic void findAllShouldReturnAllCategories() {\nList<Category> list = new ArrayList<>();\nlist.add(mock(Category.class));\nwhen(categoryRepository.findAll()).thenReturn(list);\nList<Category> categories = categoryService.findAll();\nassertNotNull(categories);\nassertEquals(1, categories.size());\n}\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.test.annotation.DirtiesContext;\nimport java.util.List;\nimport static org.junit.jupiter.api.Assertions.*;\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)\n@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)\npublic class CategoryIT {\n\npublic static final String LOCALHOST = \"http://localhost:\";\npublic static final String SERVICE_PATH = \"/category\";\n@LocalServerPort\nprivate int port;\n\n@Autowired\nprivate TestRestTemplate restTemplate;\n\nParameterizedTypeReference<List<CategoryDto>> responseType = new ParameterizedTypeReference<List<CategoryDto>>(){};\n@Test\npublic void findAllShouldReturnAllCategories() {\nResponseEntity<List<CategoryDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseType);\nassertNotNull(response);\nassertEquals(3, response.getBody().size());\n}\n}\n
Es muy importante marcar cada m\u00e9todo de prueba con la anotaci\u00f3n @Test
, en caso contrario no se ejecutar\u00e1. Lo que se debe hacer en cada m\u00e9todo que implementemos es probar una y solo una acci\u00f3n.
En los ejemplos anteriores (CategoryTest)
, por un lado hemos comprobado el m\u00e9todo findAll()
el cual por debajo invoca una llamada al repository de categor\u00eda, la cual hemos simulado con una respuesta ficticia limit\u00e1ndonos \u00fanicamente a la l\u00f3gica contenida en la operaci\u00f3n de negocio.
Mientras que por otro lado (CategoryIT)
, hemos probado la llamando al m\u00e9todo GET
del endpoint http://localhost:XXXX/category
comprobando que realmente nos devuelve 3 resultados, que son los que hay en BBDD inicialmente.
Muy importante: Nomenclatura de los tests
La nomenclatura de los m\u00e9todos de test debe sigue una estructura determinada. Hay muchas formas de nombrar a los m\u00e9todos, pero nosotros solemos utilizar la estructura 'should', para indicar lo que va a hacer. En el ejemplo anterior el m\u00e9todo 'findAll' debe devolver 'AllCategories'. De esta forma sabemos cu\u00e1l es la intenci\u00f3n del test, y si por cualquier motivo diera un fallo, sabemos que es lo que NO est\u00e1 funcionando de nuestra aplicaci\u00f3n.
Para comprobar que el test funciona, podemos darle bot\u00f3n derecho sobre la clase de CategoryIT
y pulsar en Run as
-> JUnit Test
. Si todo funciona correctamente, deber\u00e1 aparecer una pantalla de ejecuci\u00f3n y todos nuestros tests (en este caso solo uno) corriendo correctamente (en verde). El proceso es el mismo para la clase CategoryTest
.
Vamos con los siguientes test, los que probar\u00e1n una creaci\u00f3n de una nueva Categor\u00eda
. A\u00f1adimos el siguiente m\u00e9todo a la clase de test:
public static final String CATEGORY_NAME = \"CAT1\";\n\n@Test\npublic void saveNotExistsCategoryIdShouldInsert() {\n\nCategoryDto categoryDto = new CategoryDto();\ncategoryDto.setName(CATEGORY_NAME);\n\nArgumentCaptor<Category> category = ArgumentCaptor.forClass(Category.class);\n\ncategoryService.save(null, categoryDto);\n\nverify(categoryRepository).save(category.capture());\n\nassertEquals(CATEGORY_NAME, category.getValue().getName());\n}\n
public static final Long NEW_CATEGORY_ID = 4L;\npublic static final String NEW_CATEGORY_NAME = \"CAT4\";\n\n@Test\npublic void saveWithoutIdShouldCreateNewCategory() {\n\nCategoryDto dto = new CategoryDto();\ndto.setName(NEW_CATEGORY_NAME);\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nResponseEntity<List<CategoryDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseType);\nassertNotNull(response);\nassertEquals(4, response.getBody().size());\n\nCategoryDto categorySearch = response.getBody().stream().filter(item -> item.getId().equals(NEW_CATEGORY_ID)).findFirst().orElse(null);\nassertNotNull(categorySearch);\nassertEquals(NEW_CATEGORY_NAME, categorySearch.getName());\n}\n
En el primer caso, estamos construyendo un objeto CategoryDto
para darle un nombre a la Categor\u00eda
e invocamos a la operaci\u00f3n save
pasandole un ID a nulo. Para identificar que el funcionamiento es el esperado, capturamos la categor\u00eda que se proporciona al repository al intentar realizar la acci\u00f3n ficticia de guardado y comprobamos que el valor es el que se proporciona en la invocaci\u00f3n.
De forma similar en el segundo caso, estamos construyendo un objeto CategoryDto
para darle un nombre a la Categor\u00eda
e invocamos al m\u00e9todo PUT
sin a\u00f1adir en la ruta referencia al identificador. Seguidamente, recuperamos de nuevo la lista de categor\u00edas y en este caso deber\u00eda tener 4 resultados. Hacemos un filtrado buscando la nueva Categor\u00eda
que deber\u00eda tener un ID = 4 y deber\u00eda ser la que acabamos de crear.
Si ejecutamos, veremos que ambos test ahora aparecen en verde.
"},{"location":"develop/basic/springboot/#pruebas-de-modificacion","title":"Pruebas de modificaci\u00f3n","text":"Para este caso de prueba, vamos a realizar varios test, como hemos comentado anteriormente. Tenemos que probar que es lo que pasa cuando intentamos modificar un elemento que existe, pero tambi\u00e9n debemos probar que es lo que pasa cuando intentamos modificar un elemento que no existe.
Empezamos con el sencillo, un test que pruebe una modificaci\u00f3n existente.
CategoryTest.javaCategoryIT.javapublic static final Long EXISTS_CATEGORY_ID = 1L;\n\n@Test\npublic void saveExistsCategoryIdShouldUpdate() {\n\nCategoryDto categoryDto = new CategoryDto();\ncategoryDto.setName(CATEGORY_NAME);\n\nCategory category = mock(Category.class);\nwhen(categoryRepository.findById(EXISTS_CATEGORY_ID)).thenReturn(Optional.of(category));\n\ncategoryService.save(EXISTS_CATEGORY_ID, categoryDto);\n\nverify(categoryRepository).save(category);\n}\n
public static final Long MODIFY_CATEGORY_ID = 3L;\n\n@Test\npublic void modifyWithExistIdShouldModifyCategory() {\n\nCategoryDto dto = new CategoryDto();\ndto.setName(NEW_CATEGORY_NAME);\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + MODIFY_CATEGORY_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nResponseEntity<List<CategoryDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseType);\nassertNotNull(response);\nassertEquals(3, response.getBody().size());\n\nCategoryDto categorySearch = response.getBody().stream().filter(item -> item.getId().equals(MODIFY_CATEGORY_ID)).findFirst().orElse(null);\nassertNotNull(categorySearch);\nassertEquals(NEW_CATEGORY_NAME, categorySearch.getName());\n}\n
En el caso de los test unitarios, comprobamos la l\u00f3gica de la modificaci\u00f3n simulando que el repository nos devuelve una categor\u00eda que modificar y verificado que se invoca el guardado sobre la misma.
En el caso de los test de integraci\u00f3n, la misma filosof\u00eda que en el test anterior, pero esta vez modificamos la Categor\u00eda
de ID = 3. Luego la filtramos y vemos que realmente se ha modificado. Adem\u00e1s comprobamos que el listado de todas los registros sigue siendo 3 y no se ha creado un nuevo registro.
En el siguiente test, probaremos un resultado err\u00f3neo.
CategoryIT.java@Test\npublic void modifyWithNotExistIdShouldInternalError() {\n\nCategoryDto dto = new CategoryDto();\ndto.setName(NEW_CATEGORY_NAME);\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + NEW_CATEGORY_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n
Intentamos modificar el ID = 4, que no deber\u00eda existir en BBDD y por tanto lo que se espera en el test es que lance un 500 Internal Server Error
al llamar al m\u00e9todo PUT
.
Ya por \u00faltimo implementamos las pruebas de borrado.
CategoryTest.javaCategoryIT.java@Test\npublic void deleteExistsCategoryIdShouldDelete() throws Exception {\n\nCategory category = mock(Category.class);\nwhen(categoryRepository.findById(EXISTS_CATEGORY_ID)).thenReturn(Optional.of(category));\n\ncategoryService.delete(EXISTS_CATEGORY_ID);\n\nverify(categoryRepository).deleteById(EXISTS_CATEGORY_ID);\n}\n
public static final Long DELETE_CATEGORY_ID = 2L;\n\n@Test\npublic void deleteWithExistsIdShouldDeleteCategory() {\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + DELETE_CATEGORY_ID, HttpMethod.DELETE, null, Void.class);\n\nResponseEntity<List<CategoryDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseType);\nassertNotNull(response);\nassertEquals(2, response.getBody().size());\n}\n\n@Test\npublic void deleteWithNotExistsIdShouldInternalError() {\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + NEW_CATEGORY_ID, HttpMethod.DELETE, null, Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n
En cuanto al test unitario, se invoca a la operaci\u00f3n delete
y se verifica que la operaci\u00f3n requerida del repository es invocado con el atributo correcto.
En lo relativo a las pruebas de integraci\u00f3n, en el primer test, se invoca el m\u00e9todo DELETE
y posteriormente se comprueba que el listado tiene un tama\u00f1o de 2 (uno menos que el original). Mientras que en el segundo test, se comprueba que con ID no v\u00e1lido, devuelve un 500 Internal Server Error
.
Con esto tendr\u00edamos m\u00e1s o menos probados los casos b\u00e1sicos de nuestra aplicaci\u00f3n y tendr\u00edamos una peque\u00f1a red de seguridad que nos ayudar\u00eda por si a futuro necesitamos hacer alg\u00fan cambio o evolutivo.
"},{"location":"develop/basic/springboot/#que-hemos-aprendido","title":"\u00bfQ\u00fae hemos aprendido?","text":"Resumiendo un poco los pasos que hemos seguido:
com.ccsw.tutorial.category
para aglutinar todas las clases.Controller
\u2192 Maneja las peticiones de entrada del cliente y realiza transformaciones. No ejecuta directamente l\u00f3gica de negocio, para eso utiliza llamadas a la siguiente capa.Service
\u2192 Ejecuta la l\u00f3gica de negocio en sus m\u00e9todos o llamando a otros objetos de la misma capa. No ejecuta directamente accesos a datos, para eso utiliza la siguiente capa.Repository
\u2192 Realiza los accesos a datos de lectura y escritura. NUNCA debe llamar a otros objetos de la misma capa ni de capas anteriores.Json
\u2192 Los datos que vienen y van del cliente al Controller
.DTO
\u2192 Los datos se mueven dentro del Controller
y sirven para invocar llamadas. Tambi\u00e9n son los datos que devuelve un Controller
.Entity
\u2192 Los datos que sirven para persistir y leer datos de una BBDD y que NUNCA deber\u00edan ir m\u00e1s all\u00e1 del Controller
.Una parte muy importante del desarrollo es tener la capacidad de depurar nuestro c\u00f3digo, en este apartado vamos a explicar como se realiza debug
en Backend.
Esta parte se realiza con las herramientas incluidas dentro de nuestro IDE favorito, en este caso vamos a utilizar el Eclipse.
Lo primero que debemos hacer es arrancar la aplicaci\u00f3n en modo Debug
:
Arrancada la aplicaci\u00f3n en este modo, vamos a depurar la operaci\u00f3n de crear categor\u00eda.
Para ello vamos a abrir nuestro fichero donde tenemos la implementaci\u00f3n del servicio de creaci\u00f3n de la capa de la l\u00f3gica de negocio CategoryServiceImpl
.
Dentro del fichero ya podemos a\u00f1adir puntos de ruptura (breakpoint), en nuestro caso queremos comprobar que el nombre introducido se recibe correctamente.
Colocamos el breakpoint en la primera l\u00ednea del m\u00e9todo (click sobre el n\u00famero de la l\u00ednea) y desde la interfaz/postman creamos una nueva categor\u00eda.
Hecho esto, podemos observar que a nivel de interfaz/postman, la petici\u00f3n se queda esperando y el IDE pasa modo Debug
(la primera vez nos preguntar\u00e1 si queremos hacerlo, le decimos que si):
El IDE nos lleva al punto exacto donde hemos a\u00f1adido el breakpoint y se para en este punto ofreci\u00e9ndonos la posibilidad de explorar el contenido de las variables del c\u00f3digo:
Aqu\u00ed podemos comprobar que efectivamente el atributo name
de la variable dto
tiene el valor que hemos introducido por pantalla/postman.
Para continuar con la ejecuci\u00f3n basta con darle al bot\u00f3n de play
de la barra de herramientas superior.
Nota: para volver al modo Java
de Eclipse, presionamos el bot\u00f3n que se sit\u00faa a la izquierda del modo Debug
en el que ha entrado el IDE autom\u00e1ticamente.
Ahora que ya tenemos listo el proyecto frontend de VUE, ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/vuejs/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 que VUE tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. En la propia web de documentaci\u00f3n de VUE puedes buscar casi cualquier ejemplo que necesites.
Vamos a realizar una pantalla lo m\u00e1s parecida a la siguiente captura para empezar:
Lo primero que vamos a hacer es crear los componentes de las tres pr\u00f3ximas pantallas mediante el siguiente comando:
npx quasar new page CatalogPage CategoriesPage AuthorsPage\n
Y ahora vamos a crear las rutas que nos van a hacer llegar hasta ellos:
import { RouteRecordRaw } from 'vue-router';\nimport MainLayout from 'layouts/MainLayout.vue';\nimport IndexPage from 'pages/IndexPage.vue';\nimport CatalogPage from 'pages/CatalogPage.vue';\nimport CategoriesPage from 'pages/CategoriesPage.vue';\nimport AuthorsPage from 'pages/AuthorsPage.vue';\n\nconst routes: RouteRecordRaw[] = [\n {\n path: '/',\n component: MainLayout,\n children: [\n { path: '', component: IndexPage },\n { path: 'games', component: CatalogPage },\n { path: 'categories', component: CategoriesPage },\n { path: 'authors', component: AuthorsPage },\n ],\n },\n\n // Always leave this as last one,\n // but you can also remove it\n // {\n // path: '/:catchAll(.*)*',\n // component: () => import('pages/ErrorNotFound.vue'),\n // },\n];\n\nexport default routes;\n
Una vez realizado esto, vamos a ponerle dentro de cada uno de los archivos creados el nombre del archivo donde est\u00e1 el comentario para saber que lleva al lugar correcto:
<template>\n <q-page padding> CatalogPage </q-page>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\nexport default defineComponent({\n name: 'CatalogPage',\n});\n</script>\n
Por \u00faltimo, modificaremos el men\u00fa lateral para que lleve las opciones correctas y nos enlace a dichas pantallas (para esto, iremos al archivo MainLayout.vue
):
const linksList = [\n {\n title: 'Cat\u00e1logo',\n icon: 'list',\n link: 'games',\n },\n {\n title: 'Categor\u00edas',\n icon: 'dashboard',\n link: 'categories',\n },\n {\n title: 'Autores',\n icon: 'face',\n link: 'authors',\n },\n];\n
En caso de que no funcione correctamente, deber\u00eda solucionarse cambiando en el archivo EssentialLink.vue
el prop \u201chref\u201d
por el prop \u201cto\u201d
:
<template>\n <q-item clickable tag=\"a\" :to=\"link\">\n <q-item-section v-if=\"icon\" avatar>\n <q-icon :name=\"icon\" />\n </q-item-section>\n\n <q-item-section>\n <q-item-label>{{ title }}</q-item-label>\n </q-item-section>\n </q-item>\n</template>\n
"},{"location":"develop/basic/vuejs/#codigo-de-la-pantalla","title":"C\u00f3digo de la pantalla","text":"Para empezar, usaremos un componente de tabla de la librer\u00eda de Quasar. Este componente nos ayudar\u00e1 a mostrar los datos de los juegos en un futuro.
<template>\n <q-page padding>\n <q-table\n :rows=\"catalogData\"\n :columns=\"columns\"\n title=\"Cat\u00e1logo\"\n row-key=\"id\"\n />\n </q-page>\n</template>\n
As\u00ed es como deber\u00eda quedar nuestro componente de tabla con todas las supuestas variables que m\u00e1s adelante le settearemos:
<template>\n <q-page padding>\n <q-table\n hide-bottom\n :rows=\"catalogData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Cat\u00e1logo\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n />\n </q-page>\n</template>\n
Y as\u00ed es como vamos a necesitar que est\u00e9, ya que no va a tener paginado. \u00bfPor qu\u00e9?
hide-bottom
\u2192 hace que no se muestre la zona baja de la tabla que es donde est\u00e1 el paginado.v-model:pagination
\u2192 har\u00e1 que vengan los datos que vengan, se muestren todos de la misma manera.class
\u2192 esta clase har\u00e1 que, si haciendo scroll pierdes los header, estos te acompa\u00f1en y siempre sepas qu\u00e9 columna es la que est\u00e1s mirando.no-data-label
\u2192 un mensaje por si alg\u00fan d\u00eda no hay datos o tiene un fallo el back.Todo esto no hace falta aprend\u00e9rselo, est\u00e1 en la documentaci\u00f3n de este componente. Pero vamos a ir usando algunos props como estos para configurar correctamente la tabla.
"},{"location":"develop/basic/vuejs/#mockeando-datos","title":"Mockeando datos","text":"Y esto va a hacer que podamos mostrar datos:
<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nconst columns = [\n { name: 'id', align: 'left', label: 'ID', field: 'id', sortable: true },\n {\n name: 'name',\n align: 'left',\n label: 'Nombre',\n field: 'name',\n sortable: true,\n },\n { name: 'options', align: 'left', label: 'Options', field: 'options' },\n];\n\nconst data = [\n { id: 1, name: 'Dados' },\n { id: 2, name: 'Fichas' },\n { id: 3, name: 'Cartas' },\n { id: 4, name: 'Rol' },\n { id: 5, name: 'Tableros' },\n { id: 6, name: 'Tem\u00e1ticos' },\n { id: 7, name: 'Europeos' },\n { id: 8, name: 'Guerra' },\n { id: 9, name: 'Abstractos' },\n];\n\nexport default defineComponent({\n name: 'CatalogPage',\n\n setup() {\n const catalogData = ref(data);\n\n return {\n catalogData,\n columns: columns,\n pagination: {\n page: 1,\n rowsPerPage: 0, // 0 means all rows\n },\n };\n },\n});\n</script>\n
Lo que estamos haciendo es settear unos datos, los nombres y estilos de las columnas, y los ajustes de la paginaci\u00f3n."},{"location":"develop/basic/vuejs/#anadir-editar-y-eliminar-filas","title":"A\u00f1adir, editar y eliminar filas","text":"El c\u00f3digo final para esto, que m\u00e1s adelante explicaremos, es el siguiente:
<template>\n <q-page padding>\n <q-table\n hide-bottom\n :rows=\"catalogData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Cat\u00e1logo\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n >\n <template v-slot:top>\n <q-btn flat round color=\"primary\" icon=\"add\" @click=\"showAdd = true\" />\n </template>\n <template v-slot:body=\"props\">\n <q-tr :props=\"props\">\n <q-td key=\"id\" :props=\"props\">{{ props.row.id }}</q-td>\n <q-td key=\"name\" :props=\"props\">\n {{ props.row.name }}\n <q-popup-edit\n v-model=\"props.row.name\"\n title=\"Cambiar nombre\"\n v-slot=\"scope\"\n >\n <q-input\n v-model=\"scope.value\"\n dense\n autofocus\n counter\n @keyup.enter=\"scope.set\"\n >\n <template v-slot:append>\n <q-icon name=\"edit\" />\n </template>\n </q-input>\n </q-popup-edit>\n </q-td>\n <q-td key=\"options\" :props=\"props\">\n <q-btn\n flat\n round\n color=\"negative\"\n icon=\"delete\"\n @click=\"showDeleteDialog(props.row)\"\n />\n </q-td>\n </q-tr>\n </template>\n </q-table>\n <q-dialog v-model=\"showDelete\" persistent>\n <q-card>\n <q-card-section class=\"row items-center\">\n <q-icon\n name=\"delete\"\n size=\"sm\"\n color=\"negative\"\n @click=\"showDelete = true\"\n />\n <span class=\"q-ml-sm\">\n \u00bfEst\u00e1s seguro de que quieres borrar este elemento?\n </span>\n </q-card-section>\n\n <q-card-actions align=\"right\">\n <q-btn flat label=\"Cancelar\" color=\"primary\" v-close-popup />\n <q-btn\n flat\n label=\"Confirmar\"\n color=\"primary\"\n v-close-popup\n @click=\"deleteGame\"\n />\n </q-card-actions>\n </q-card>\n </q-dialog>\n <q-dialog v-model=\"showAdd\">\n <q-card style=\"min-width: 350px\">\n <q-card-section>\n <div class=\"text-h6\">Nombre del juego</div>\n </q-card-section>\n\n <q-card-section class=\"q-pt-none\">\n <q-input dense v-model=\"nameToAdd\" autofocus @keyup.enter=\"addGame\" />\n </q-card-section>\n\n <q-card-actions align=\"right\" class=\"text-primary\">\n <q-btn flat label=\"Cancelar\" v-close-popup />\n <q-btn flat label=\"A\u00f1adir juego\" v-close-popup @click=\"addGame\" />\n </q-card-actions>\n </q-card>\n </q-dialog>\n </q-page>\n</template>\n\n<script lang=\"ts\">\nimport { ref } from 'vue';\nimport { defineComponent } from 'vue';\n\nconst columns = [\n { name: 'id', align: 'left', label: 'ID', field: 'id', sortable: true },\n {\n name: 'name',\n align: 'left',\n label: 'Nombre',\n field: 'name',\n sortable: true,\n },\n { name: 'options', align: 'left', label: 'Options', field: 'options' },\n];\n\nconst data = [\n { id: 1, name: 'Dados' },\n { id: 2, name: 'Fichas' },\n { id: 3, name: 'Cartas' },\n { id: 4, name: 'Rol' },\n { id: 5, name: 'Tableros' },\n { id: 6, name: 'Tem\u00e1ticos' },\n { id: 7, name: 'Europeos' },\n { id: 8, name: 'Guerra' },\n { id: 9, name: 'Abstractos' },\n];\n\nexport default defineComponent({\n name: 'CatalogPage',\n\n setup() {\n const catalogData = ref(data);\n const showDelete = ref(false);\n const showAdd = ref(false);\n const nameToAdd = ref('');\n const selectedRow = ref({});\n\n const deleteGame = () => {\n catalogData.value.splice(\n catalogData.value.findIndex((i) => i.id === selectedRow.value.id),\n 1\n );\n showDelete.value = false;\n };\n\n const showDeleteDialog = (item: any) => {\n selectedRow.value = item;\n showDelete.value = true;\n };\n\n const addGame = () => {\n catalogData.value.push({\n id: Math.max(...catalogData.value.map((o) => o.id)) + 1,\n name: nameToAdd.value,\n });\n nameToAdd.value = '';\n showAdd.value = false;\n };\n\n return {\n catalogData,\n columns: columns,\n pagination: {\n page: 1,\n rowsPerPage: 0, // 0 means all rows\n },\n showDelete,\n showAdd,\n nameToAdd,\n showDeleteDialog,\n deleteGame,\n addGame,\n };\n },\n});\n</script>\n
"},{"location":"develop/basic/vuejs/#anadir-fila","title":"A\u00f1adir fila","text":"Para esto hemos necesitado el primer template dentro del componente tabla para mostrar un bot\u00f3n que har\u00e1 que se muestre un dialog para introducir el nombre del juego que es el \u00faltimo q-dialog mostrado en el componente. Tanto al pulsar en el bot\u00f3n como al pulsar Enter se ejecutar\u00e1 la funci\u00f3n para a\u00f1adirlo llamada addGame, que se encarga de a\u00f1adirlo poni\u00e9ndole un id superior a cualquiera de los ya creados, el nombre seleccionado almacenado en la variable nameToAdd y de dejar de mostrar el dialog una vez realizado el proceso.
"},{"location":"develop/basic/vuejs/#editar-fila","title":"Editar fila","text":"Para esto hemos necesitado el segundo template de dentro del componente (a excepci\u00f3n del \u00faltimo q-td). Este hace que cuando sea la columna id simplemente muestre su valor, pero en cambio cuando sea la del nombre, en caso de que se pulse sobre esa casilla se muestre un dialog con un campo de texto con el valor de la casilla pulsada.
"},{"location":"develop/basic/vuejs/#borrar-fila","title":"Borrar fila","text":"Por \u00faltimo, para el borrado hemos necesitado el q-td con la key de options para mostrar un bot\u00f3n para ejecutar la funci\u00f3n showDeleteDialog pas\u00e1ndole el item completo de la fila seleccionada, este hace que se muestre el dialog y se almacene el item seleccionado y por \u00faltimo el dialog se encarga de realizar la pregunta de confirmaci\u00f3n para su posterior borrado. En caso de confirmarlo, la funci\u00f3n deleteGame busca la posici\u00f3n del item seleccionado y lo borra. Una vez hecho eso, limpia el valor de fila seleccionada y deja de mostrar el dialog.
"},{"location":"develop/basic/vuejs/#conexion-con-backend","title":"Conexi\u00f3n con backend","text":"Antes de nada, para poder realizar peticiones vamos a tener que instalar: @vueuse/core
.
Vamos a proceder a modificar lo m\u00ednimo e indispensable para que los datos mostrados no sean los mockeados y vengan del back mediante esta petici\u00f3n:
const { data } = useFetch('http://localhost:8080/game').get().json();\nwhenever(data, () => (catalogData.value = data.value));\n
Tambi\u00e9n tendremos que modificar los campos a mostrar, ya que ya no es name, si no title el nombre del juego. Y tambi\u00e9n habr\u00e1 que mostrar la edad, la categor\u00eda y el autor.
"},{"location":"develop/basic/vuejs/#edicion-de-una-fila","title":"Edici\u00f3n de una fila","text":"Solo modificaremos los campos referidos al juego (de momento) para que sea lo m\u00e1s sencillo posible, es decir, solo se modificar\u00e1 el t\u00edtulo y la edad tal y como lo hab\u00edamos hecho antes con el q-popup-edit
.
Ya que no tenemos en el back de Node realizado el back necesario para poder borrar una fila, terminaremos con el a\u00f1adido de una nueva fila.
Para esto, tendremos que modificar la funci\u00f3n para a\u00f1adir, adem\u00e1s de eliminar la variable nameToAdd
y modificar el dialog. As\u00ed deber\u00eda quedar la funci\u00f3n:
const addGame = async () => {\n const response = await useFetch('http://localhost:8080/game', {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(gameToAdd.value),\n })\n .put()\n .json();\n\n getGames();\n gameToAdd.value = newGame;\n};\n
Y as\u00ed el dialog:
<q-dialog v-model=\"showAdd\">\n <q-card style=\"width: 300px\" class=\"q-px-sm q-pb-md\">\n <q-card-section>\n <div class=\"text-h6\">Nuevo juego</div>\n </q-card-section>\n\n <q-item-label header>T\u00edtulo</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"sports_esports\" />\n </q-item-section>\n <q-item-section>\n <q-input dense v-model=\"gameToAdd.title\" autofocus />\n </q-item-section>\n </q-item>\n\n <q-item-label header>Edad</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"cake\" />\n </q-item-section>\n <q-item-section>\n <q-slider\n color=\"teal\"\n v-model=\"gameToAdd.age\"\n :min=\"0\"\n :max=\"100\"\n :step=\"1\"\n label\n label-always\n />\n </q-item-section>\n </q-item>\n\n <q-item-label header>Categor\u00eda</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"category\" />\n </q-item-section>\n <q-item-section>\n <q-select\n name=\"category\"\n v-model=\"gameToAdd.category.id\"\n :options=\"categories\"\n filled\n clearable\n emit-value\n map-options\n option-disable=\"inactive\"\n option-value=\"id\"\n option-label=\"name\"\n color=\"primary\"\n label=\"Category\"\n />\n </q-item-section>\n </q-item>\n\n <q-item-label header>Autor</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"face\" />\n </q-item-section>\n <q-item-section>\n <q-select\n name=\"author\"\n v-model=\"gameToAdd.author.id\"\n :options=\"authors\"\n filled\n clearable\n emit-value\n map-options\n option-disable=\"inactive\"\n option-value=\"id\"\n option-label=\"name\"\n color=\"primary\"\n label=\"Author\"\n />\n </q-item-section>\n </q-item>\n\n <q-card-actions align=\"right\" class=\"text-primary\">\n <q-btn flat label=\"Cancelar\" v-close-popup />\n <q-btn flat label=\"A\u00f1adir juego\" v-close-popup @click=\"addGame\" />\n </q-card-actions>\n </q-card>\n </q-dialog>\n
"},{"location":"develop/basic/vuejs/#ultimo-paso","title":"\u00daltimo paso","text":"Este resultado vamos a copiarlo y pegarlo en las pantallas de Categor\u00eda y Autor para que tengamos exactamente el mismo formato cambiando todo donde diga \u201cjuego\u201d o \u201cgame\u201d por su traducci\u00f3n a \u201ccategor\u00eda\u201d o \u201cautor\u201d.
"},{"location":"develop/basic/vuejs/#ejercicio","title":"Ejercicio","text":"Al realizar el cambio descrito anteriormente podremos ver que no todo funciona, ya que el objeto que se env\u00eda para modificar no ser\u00eda correcto adem\u00e1s de que las tablas de Categor\u00eda y Autor s\u00ed que tienen una funci\u00f3n para poder borrar esas filas.
El ejercicio se va a realizar en la pantalla de Categor\u00eda. Consta en, despu\u00e9s de haber realizado todos los cambios, hacer que a\u00f1ada, edite y borre las filas seg\u00fan sea necesario.
El c\u00f3digo resultante deber\u00eda ser algo parecido al siguiente c\u00f3digo:
<template>\n <q-page padding>\n <q-table\n hide-bottom\n :rows=\"categoriesData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Cat\u00e1logo\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n >\n <template v-slot:top>\n <q-btn flat round color=\"primary\" icon=\"add\" @click=\"showAdd = true\" />\n </template>\n <template v-slot:body=\"props\">\n <q-tr :props=\"props\">\n <q-td key=\"id\" :props=\"props\">{{ props.row.id }}</q-td>\n <q-td key=\"name\" :props=\"props\">\n {{ props.row.name }}\n <q-popup-edit\n v-model=\"props.row.name\"\n title=\"Cambiar nombre\"\n v-slot=\"scope\"\n >\n <q-input\n v-model=\"scope.value\"\n dense\n autofocus\n counter\n @keyup.enter=\"editRow(props, scope, 'name')\"\n >\n <template v-slot:append>\n <q-icon name=\"edit\" />\n </template>\n </q-input>\n </q-popup-edit>\n </q-td>\n <q-td key=\"options\" :props=\"props\">\n <q-btn\n flat\n round\n color=\"negative\"\n icon=\"delete\"\n @click=\"showDeleteDialog(props.row)\"\n />\n </q-td>\n </q-tr>\n </template>\n </q-table>\n <q-dialog v-model=\"showDelete\" persistent>\n <q-card>\n <q-card-section class=\"row items-center\">\n <q-icon\n name=\"delete\"\n size=\"sm\"\n color=\"negative\"\n @click=\"showDelete = true\"\n />\n <span class=\"q-ml-sm\">\n \u00bfEst\u00e1s seguro de que quieres borrar este elemento?\n </span>\n </q-card-section>\n\n <q-card-actions align=\"right\">\n <q-btn flat label=\"Cancelar\" color=\"primary\" v-close-popup />\n <q-btn\n flat\n label=\"Confirmar\"\n color=\"primary\"\n v-close-popup\n @click=\"deleteCategory\"\n />\n </q-card-actions>\n </q-card>\n </q-dialog>\n <q-dialog v-model=\"showAdd\">\n <q-card style=\"width: 300px\" class=\"q-px-sm q-pb-md\">\n <q-card-section>\n <div class=\"text-h6\">Nueva categor\u00eda</div>\n </q-card-section>\n\n <q-item-label header>Nombre</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"category\" />\n </q-item-section>\n <q-item-section>\n <q-input\n dense\n v-model=\"categoryToAdd.name\"\n autofocus\n @keyup.enter=\"addCategory\"\n />\n </q-item-section>\n </q-item>\n <q-card-actions align=\"right\" class=\"text-primary\">\n <q-btn flat label=\"Cancelar\" v-close-popup />\n <q-btn\n flat\n label=\"A\u00f1adir categor\u00eda\"\n v-close-popup\n @click=\"addCategory\"\n />\n </q-card-actions>\n </q-card>\n </q-dialog>\n </q-page>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useFetch, whenever } from '@vueuse/core';\n\nconst columns = [\n { name: 'id', align: 'left', label: 'ID', field: 'id', sortable: true },\n {\n name: 'name',\n align: 'left',\n label: 'Nombre',\n field: 'name',\n sortable: true,\n },\n { name: 'options', align: 'left', label: 'Options', field: 'options' },\n];\nconst pagination = {\n page: 1,\n rowsPerPage: 0,\n};\nconst newCategory = {\n name: '',\n id: '',\n};\n\nconst categoriesData = ref([]);\nconst showDelete = ref(false);\nconst showAdd = ref(false);\nconst selectedRow = ref({});\nconst categoryToAdd = ref({ ...newCategory });\n\nconst getCategories = () => {\n const { data } = useFetch('http://localhost:8080/category').get().json();\n whenever(data, () => (categoriesData.value = data.value));\n};\ngetCategories();\n\nconst showDeleteDialog = (item: any) => {\n selectedRow.value = item;\n showDelete.value = true;\n};\n\nconst addCategory = async () => {\n await useFetch('http://localhost:8080/category', {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(categoryToAdd.value),\n })\n .put()\n .json();\n\n getCategories();\n categoryToAdd.value = newCategory;\n showAdd.value = false;\n};\n\nconst editRow = (props: any, scope: any, field: any) => {\n const row = {\n name: props.row.name,\n };\n row[field] = scope.value;\n scope.set();\n editCategory(props.row.id, row);\n};\n\nconst editCategory = async (id: string, reqBody: any) => {\n await useFetch(`http://localhost:8080/category/${id}`, {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(reqBody),\n })\n .put()\n .json();\n\n getCategories();\n};\n\nconst deleteCategory = async () => {\n await useFetch(`http://localhost:8080/category/${selectedRow.value.id}`, {\n method: 'DELETE',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n })\n .delete()\n .json();\n\n getCategories();\n};\n</script>\n
"},{"location":"develop/basic/vuejs/#depuracion","title":"Depuraci\u00f3n","text":"Una parte muy importante del desarrollo es tener la capacidad de depurar nuestro c\u00f3digo, en este apartado vamos a explicar como se realiza debug
en Front.
Esta parte se puede realizar con nuestro navegador favorito, en este caso vamos a utilizar Chrome.
El primer paso es abrir las herramientas del desarrollador del navegador presionando F12
.
En esta herramienta tenemos varias partes importantes:
Identificados los elementos importantes, vamos a depurar la operaci\u00f3n de crear categor\u00eda.
Para ello nos dirigimos a la pesta\u00f1a de Source
, en el \u00e1rbol de carpetas nos dirigimos a la ruta donde est\u00e1 localizado el c\u00f3digo de nuestra aplicaci\u00f3n webpack://src/app
.
Dentro de esta carpeta est\u00e9 localizado todo el c\u00f3digo fuente de la aplicaci\u00f3n, en nuestro caso vamos a localizar componente category-edit.component
que crea una nueva categor\u00eda.
Dentro del fichero ya podemos a\u00f1adir puntos de ruptura (breakpoint), en nuestro caso queremos comprobar que el nombre introducido se captura bien y se env\u00eda al service correctamente.
Colocamos el breakpoint en la l\u00ednea de invocaci\u00f3n del service (click sobre el n\u00famero de la l\u00ednea) y desde la interfaz creamos una nueva categor\u00eda.
Hecho esto, podemos observar que a nivel de interfaz, la aplicaci\u00f3n se detiene y aparece un panel de manejo de los puntos de interrupci\u00f3n:
En cuanto a la herramienta del desarrollador nos lleva al punto exacto donde hemos a\u00f1adido el breakpoint y se para en este punto ofreci\u00e9ndonos la posibilidad de explorar el contenido de las variables del c\u00f3digo:
Aqu\u00ed podemos comprobar que efectivamente la variable category
tiene el valor que hemos introducido por pantalla y se propaga correctamente hacia el service.
Para continuar con la ejecuci\u00f3n basta con darle al bot\u00f3n de play
del panel de manejo de interrupci\u00f3n o al que aparece dentro de la herramienta de desarrollo (parte superior derecha).
Por \u00faltimo, vamos a revisar que la petici\u00f3n REST se ha realizado correctamente al backend, para ello nos dirigimos a la pesta\u00f1a Network
y comprobamos las peticiones realizadas:
Aqu\u00ed podemos observar el registro de todas las peticiones y haciendo click sobre una de ellas, obtenemos el detalle de esta.
Ahora que ya tenemos listo el proyecto frontend de VUE, ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/vuejsold/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 que VUE tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. En la propia web de documentaci\u00f3n de VUE puedes buscar casi cualquier ejemplo que necesites.
Si abrimos el proyecto con el IDE que tengamos (Visual Studio Code en el caso del tutorial) podemos ver que en la carpeta src/
existen unos ficheros ya creados por defecto. Estos ficheros son:
App.vue
\u2192 contiene el c\u00f3digo inicial del proyecto.main.ts
\u2192 es el punto de entrada a la aplicaci\u00f3n.Lo primero que vamos a hacer es instalar SASS para poder trabajar con este preprocesador CSS, para ello tendremos que irnos a la terminal, en la misma carpeta donde tenemos el proyecto y ejecutar el siguiente comando:
npm install -D sass\n
Con esto ya lo tendremos instalado y para usarlo es tan f\u00e1cil como poner la etiqueta style de esta manera:
<style lang=\"scss\"></style> <---> con Sass activado\n<style></style> <---> sin Sass, css normal\n
En los estilos tambi\u00e9n veremos la propiedad scoped en VUE, el atributo scoped se utiliza para limitar el \u00e1mbito de los estilos de un componente a los elementos del propio componente y no a los elementos hijos o padres, lo que ayuda a evitar conflictos de estilo entre los diferentes componentes de una aplicaci\u00f3n.
Esto significa que los estilos definidos en una etiqueta <style scoped>
solo se aplicar\u00e1n a los elementos dentro del componente actual, y no se propagar\u00e1n a otros componentes en la jerarqu\u00eda del DOM. De esta manera, se puede evitar que los estilos de un componente afecten a otros componentes en la aplicaci\u00f3n.
<style scoped></style> <---> Estos estilos solo afectar\u00e1n al componente donde se aplican\n<style></style> <---> Estos estilos son generales y afectan a toda la aplicaci\u00f3n.\n
Con estas cositas sobre los estilos en cabeza vamos lo primero a limpiar la aplicaci\u00f3n para poder empezar a trabajar desde cero.
assets
y borraremos todos los archivos excepto base.css
.components
y borraremos todos los archivos dejando solo la carpeta que usaremos m\u00e1s adelante.router
la dejaremos tal cual esta, sin tocar nada.views
y borraremos todos los archivos dejando solo la carpeta que usaremos m\u00e1s adelante.Con esto tenemos nuestra estructura preparada y quedar\u00eda tal que asi:
Vamos a a\u00f1adir unas l\u00edneas al tsconfig.json
para que el typescript deje de marcarnos lo como error, lo dejaremos asi:
{\n\"extends\": \"@vue/tsconfig/tsconfig.web.json\",\n\"include\": [\"env.d.ts\", \"src/**/*\", \"src/**/*.vue\"],\n\"compilerOptions\": {\n\"preserveValueImports\": false,\n\"importsNotUsedAsValues\": \"remove\",\n\"verbatimModuleSyntax\": true,\n\"baseUrl\": \".\",\n\"paths\": {\n\"@/*\": [\"./src/*\"]\n}\n},\n\n\"references\": [\n{\n\"path\": \"./tsconfig.node.json\"\n}\n]\n}\n
Para que la aplicaci\u00f3n funcione de nuevo y poder empezar a trabajar faltar\u00eda hacer un par de cositas que os explico:
base.css
no hace falta cambiar nada para que funcione, pero tenemos muchas cosas que seguramente no vamos a usar, este archivo lo conservamos solamente para trabajar en variables css todo el tema de los colores de nuestra web o algunas otras cositas como el ancho del menu o del header, etc\u2026 Lo primero vamos a eliminar todas las variables CSS y crearnos las nuestras propias con nuestro color primario y secundario tanto para botones y dem\u00e1s como para texto y tambi\u00e9n para el background principal. Tenemos que dejar nuestro archivo de esta manera::root {\n--primary: #2a6fa8;\n--secondary: #12abdb;\n--text-ligth: #2c3e50;\n--text-dark: #fff;\n--background-color: #fff;\n}\n\n*,\n*::before,\n*::after {\nbox-sizing: border-box;\nmargin: 0;\nposition: relative;\nfont-weight: normal;\n}\n\nbody {\nmin-height: 100vh;\ncolor: var(--text-ligth);\nbackground: var(--background-color);\nline-height: 1.6;\nfont-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,\nCantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\nfont-size: 16px;\ntext-rendering: optimizeLegibility;\n-webkit-font-smoothing: antialiased;\n-moz-osx-font-smoothing: grayscale;\n}\n
main.ts
y cambiaremos el import que hace del CSS por el base que es el que estamos usando, quedar\u00eda de esta manera:import { createApp } from 'vue'\nimport App from './App.vue'\nimport router from './router'\n\nimport './assets/base.css'\n\nconst app = createApp(App)\n\napp.use(router)\n\napp.mount('#app')\n
App.vue
y lo dejaremos solo como la entrada a la aplicaci\u00f3n, esto ya son maneras de trabajar de cada uno, pero a m\u00ed me gusta hacerlo asi para tener si hiciera falta diferentes layouts, uno con header y men\u00fa, otro sin header y men\u00fa, otro de la parte de admin, etc\u2026 Lo dejaremos exactamente asi:<script setup lang=\"ts\">\nimport { RouterView } from 'vue-router'\n</script>\n\n<template>\n <RouterView />\n</template>\n
src
y crearemos una nueva carpeta llamada layouts
, dentro de esta carpeta crearemos otra que se llamara main-layout
(esto lo hacemos por si luego tenemos m\u00e1s de un layout que cada uno tenga su carpeta para tener sus propias cosas) y dentro de la carpeta main-layout
crearemos el archivo MainLayout.vue
, nos deber\u00eda de quedar asi:Una vez tenemos el archivo MainLayout.vue
creado lo abriremos y escribiremos el siguiente c\u00f3digo:
<script setup lang=\"ts\">\nconst helloWorld = 'Hola Mundo';\n</script>\n\n<template>\n <h1>{{ helloWorld }}</h1>\n</template>\n
Vamos a intentar explicar este c\u00f3digo un poco:
script
metemos todo el c\u00f3digo Javascript, en este caso como vamos a trabajar con Typescript le ponemos la etiqueta Lang=\u201dts\u201d
para que el compilador sepa que estamos trabajando con Typescript.setup
porque estamos trabajando con la composition api
, en VUE podemos trabajar con la options api
y con la composition api
, nosotros vamos a usar la composition api
que aunque al principio cuesta un poco m\u00e1s, luego nos va a hacer la vida much\u00edsimo m\u00e1s f\u00e1cil, sobre todo en aplicaciones \"reales\".template
va el HTML y como estamos usando el m\u00e9todo setup
no necesitamos retornar nada para poder acceder a ello desde la plantilla.Las llaves dobles permiten hacen un binding entre el c\u00f3digo del componente y la plantilla. Es decir, en este caso ir\u00e1 al c\u00f3digo TypeScript y buscar\u00e1 el valor de la variable helloWorld.
Consejo
El binding tambi\u00e9n nos sirve para ejecutar los m\u00e9todos de TypeScript desde el c\u00f3digo HTML. Adem\u00e1s, si el valor que contiene la variable se modificar\u00e1 durante la ejecuci\u00f3n de alg\u00fan m\u00e9todo, autom\u00e1ticamente el c\u00f3digo HTML refrescar\u00eda el nuevo valor de la variable helloWorld
.
Ponemos en marcha la aplicaci\u00f3n con npm run dev
.
Si abrimos el navegador y accedemos a http://localhost:5173/
podremos ver el resultado del c\u00f3digo.
Lo primero que vamos a hacer es escoger un tema y una paleta de componentes para trabajar. VUE no tiene una librer\u00eda de componentes oficial al igual que, por ejemplo, Angular tiene Material, por lo que podremos elegir entre las diferentes opciones y ver la que m\u00e1s se ajusta a las necesidades del proyecto o crearnos la nuestra propia, si entramos en proyectos ya comenzados, seguramente este paso ya habr\u00e1 sido abordado y ya sabr\u00e1s con qu\u00e9 librer\u00eda de componentes trabajar, para este proyecto vamos a optar por PrimeVue, no tenemos ning\u00fan motivo especial para decidir esa en especial, pero la hemos usado en un curso anterior y optamos por seguir con la misma librer\u00eda.
Para instalarla bastar\u00e1 con seguir los pasos de su documentaci\u00f3n.
Vamos a hacerlo y la instalamos en nuestro proyecto:
npm install primevue\n
Despu\u00e9s instalaremos PrimeVue con la funci\u00f3n use
en el main.ts
que es donde tenemos nuestra configuraci\u00f3n, quedando asi nuestro main.ts
:
main.ts
:base.css
cambiaremos la fuente del proyecto por la que trae el tema de PrimeVue, cambiando en el body
la l\u00ednea:...\nfont-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,\nCantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\n...\n
Por:
base.css...\nfont-family: (--font-family);\n...\n
Recuerda
Al a\u00f1adir una nueva librer\u00eda tenemos que parar el servidor y volver a arrancarlo para que compile y pre-cargue las nuevas dependencias.
Una vez a\u00f1adida la dependencia, lo que queremos es crear una primera estructura inicial a la p\u00e1gina. Si te acuerdas cual era la estructura (y si no te acuerdas, vuelve a la secci\u00f3n Contexto de la aplicaci\u00f3n y lo revisas), ten\u00edamos una cabecera superior con un logo y t\u00edtulo y unas opciones de men\u00fa.
Antes de empezar a crear y programar vamos a instalar unas extensiones en Visual Studio Code que nos har\u00e1n la vida mucho mas f\u00e1cil, en cada una de ellas podeis ver una descripci\u00f3n de que hacen y para que sirven, tu ya dices si la quieres instalar o no, nosotros vamos a trabajar con ellas y por eso te las recomendamos:
Para poder seguir trabajando con comodidad vamos a a\u00f1adir una fuente de iconos para todos los iconitos que usemos en la aplicaci\u00f3n, nosotros vamos a usar Material porque es la que estamos acostumbrados, para a\u00f1adirla tenemos una gu\u00eda.
Lo haremos paso a paso:
index.html
la fuente a trav\u00e9s de Google fonts, hay muchas otras maneras de hacerlo, como bajarla y servirla desde local, pero para este tutorial vamos a usar esta por ser la m\u00e1s f\u00e1cil, para a\u00f1adirla pegaremos en el index.html
esta l\u00ednea:<link href=\"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\" rel=\"stylesheet\" />\n
Quedando de esta manera:
index.html<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <link rel=\"icon\" href=\"/favicon.ico\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <link\n href=\"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\"\n rel=\"stylesheet\"\n />\n <title>Vite App</title>\n </head>\n <body>\n <div id=\"app\"></div>\n <script type=\"module\" src=\"/src/main.ts\"></script>\n </body>\n</html>\n
Para que no nos salga el error de comments, a\u00f1adiremos al eslintrc.js
estas l\u00edneas:
...\nrules: {\n'vue/comment-directive': 'off'\n}\n...\n
Despu\u00e9s nos iremos al fichero base.css
y a\u00f1adiremos esto al final del archivo:
...\n.material-symbols-outlined {\nfont-family: \"Material Symbols Outlined\", sans-serif;\nfont-weight: normal;\nfont-style: normal;\nfont-size: 24px; /* Preferred icon size */\ndisplay: inline-block;\nline-height: 1;\ntext-transform: none;\nletter-spacing: normal;\nword-wrap: normal;\nwhite-space: nowrap;\ndirection: ltr;\n}\n
Con esto ya tendremos a\u00f1adida la fuente material-symbols y podremos usar todos los iconos disponibles.
npm install primeicons\n
Una vez instalados, importaremos los iconos en el main.ts
poniendo este import debajo de todos los de css:
...\nimport 'primeicons/primeicons.css';\n...\n
Con esto ya lo tendr\u00edamos todo.
Pues vamos a ello, con las extensiones ya instaladas y la fuente para los iconos a\u00f1adida crearemos esa estructura com\u00fan para toda la aplicaci\u00f3n.
Lo primero crearemos el componente header, dentro de la carpeta components al ser un m\u00f3dulo de la aplicaci\u00f3n y no especifico de una vista o p\u00e1gina. Para eso crearemos una nueva carpeta dentro de components que llamaremos header, nos situaremos encima de la carpeta header y crearemos el archivo HeaderComponent.vue
, con el archivo vac\u00edo escribiremos, vbase-3-ts-setup
y conforme lo escribimos nos aparecer\u00e1 esto:
Consejo
Esto nos aparece gracias a las extensiones que hemos instalado, aseg\u00farate de instalarlas para que aparezca o si no las quieres instalar lo puedes crear a mano. Si no te aparece y has instalado las extensiones, cierra vscode y vu\u00e9lvelo a abrir.
Podemos seleccionar vbase-3-ts-setup
, esto es un snippet que lo que har\u00e1 es generarnos todo el c\u00f3digo de un componente vac\u00edo y lo dejara asi:
<template>\n <div>\n\n </div>\n</template>\n\n<script setup lang=\"ts\">\n\n</script>\n\n<style scoped>\n\n</style>\n
Con esto solo nos faltar\u00eda agregar a la etiqueta style que vamos a trabajar con Sass y la dejar\u00edamos asi:
HeaderComponent.vue...\n<style lang=\"scss\" scoped>\n\n</style>\n...\n
Si os dais cuenta hemos a\u00f1adido Lang=\u201dscss\u201d
y con esto ya estamos preparados para crear nuestro componente.
Para continuar cambiaremos el c\u00f3digo del HeaderComponent.vue
por este:
<template>\n <div class=\"card relative z-2\">\n <Menubar :model=\"items\">\n <template #start>\n <span class=\"material-symbols-outlined\">storefront</span>\n <span class=\"title\">LUDOTECA TAN</span>\n </template>\n <template #end>\n <Avatar icon=\"pi pi-user\" class=\"mr-2 avatar-image\" size=\"large\" shape=\"circle\" />\n <span class=\"sign-text\">Sign in</span>\n </template>\n </Menubar>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from \"vue\";\nimport Menubar from \"primevue/menubar\";\nimport Avatar from \"primevue/avatar\";\n\nconst items = ref([\n{\nlabel: \"Cat\u00e1logo\",\n},\n{\nlabel: \"Categor\u00edas\",\n},\n{\nlabel: \"Autores\",\n},\n]);\n</script>\n\n<style lang=\"scss\" scoped>\n.p-menubar {\npadding: 0.5rem;\nbackground: var(--primary);\ncolor: var(--text-dark);\nborder: none;\nborder-radius: 0px;\n}\n\n.title {\nmargin-left: 1rem;\nfont-weight: 600;\n}\n\n.avatar-image {\nbackground-color: var(--secondary);\ncolor: var(--text-dark);\nborder: 1px solid var(--text-dark);\ncursor: pointer;\n}\n\n.sign-text {\ncolor: var(--text-dark);\nmargin-left: 1rem;\ncursor: pointer;\n}\n\n:deep(.p-menubar-start) {\ndisplay: flex;\nflex-direction: row;\nalign-items: center;\njustify-content: center;\nmargin-right: 1rem;\n}\n\n:deep(.p-menubar-end) {\ndisplay: flex;\nflex-direction: row;\nalign-items: center;\njustify-content: center;\n}\n\n:deep(.p-menuitem-text) {\ncolor: var(--text-dark) !important;\n}\n\n:deep(.p-menuitem-content:hover) {\nbackground: var(--secondary) !important;\n}\n\n.material-symbols-outlined {\nfont-size: 36px;\n}\n</style>\n
Intentaremos explicarlo un poco:
En el template estamos a\u00f1adiendo el Menubar
de la librer\u00eda de componentes que estamos utilizando, si queremos saber como se a\u00f1ade podemos verlo en este link.
Veremos que lo primero que hacemos es el import
dentro de las etiquetas <script>
para poder tener el componente disponible y poder usarlo.
...\nimport Menubar from \"primevue/menubar\";\n...\n
Luego, con el import
ya hecho, podemos copiar el HTML que nos dan y ponerlo en nuestro componente:
...\n<div class=\"card relative z-2\">\n <Menubar :model=\"items\">\n <template #start>\n <span class=\"material-symbols-outlined\">storefront</span>\n <span class=\"title\">LUDOTECA TAN</span>\n </template>\n <template #end>\n <Avatar icon=\"pi pi-user\" class=\"mr-2 avatar-image\" size=\"large\" shape=\"circle\" />\n <span class=\"sign-text\">Sign in</span>\n </template>\n </Menubar>\n</div>\n...\n
Si os dais cuenta es el c\u00f3digo que ellos nos dan retocado para cubrir nuestras necesidades, primero hemos metido un icono de material dentro del template #start
que es lo que se situara al principio pegado a la izquierda del Menubar
y tras el icono metemos el t\u00edtulo.
El template #end
se situar\u00e1 al final pegado a la derecha y alli estamos metiendo otro componente de la librer\u00eda de componentes, pod\u00e9is ver la info de como usarlo en este link.
Este simplemente lo pegamos como esta y le a\u00f1adimos detr\u00e1s la frase Sign in
.
En la parte del script metemos todo nuestro Javascript/Typescript:
HeaderComponent.vue...\n<script setup lang=\"ts\">\nimport { ref } from \"vue\";\nimport Menubar from \"primevue/menubar\";\nimport Avatar from \"primevue/avatar\";\n\nconst items = ref([\n{\nlabel: \"Cat\u00e1logo\",\n},\n{\nlabel: \"Categor\u00edas\",\n},\n{\nlabel: \"Autores\",\n},\n]);\n</script>\n...\n
Si os dais cuenta, lo \u00fanico que estamos haciendo son los imports necesarios para que todo funcione y creando una variable \u00edtems
que es la que luego estamos usando en el men\u00fa para pintar los diferentes menus. Si os dais cuenta envolvemos el valor de la variable dentro de ref()
. En Vue 3, la funci\u00f3n ref()
se utiliza para crear una referencia reactiva a un valor. Una referencia reactiva es un objeto que puede ser pasado como prop, utilizado en una plantilla, y observado para detectar cambios en su valor.
La funci\u00f3n ref()
toma un valor como argumento y devuelve un objeto con una propiedad value que contiene el valor proporcionado. Por ejemplo, si queremos crear una referencia a un n\u00famero entero, podemos hacer lo siguiente:
import { ref } from 'vue'\nconst myNumber = ref(42)\nconsole.log(myNumber.value) // 42\n
La referencia myNumber
es ahora un objeto con una propiedad value que contiene el valor 42
. Si cambiamos el valor de la propiedad value
, la referencia notificar\u00e1 a cualquier componente que est\u00e9 observando el valor que ha cambiado. Por ejemplo:
myNumber.value = 21\nconsole.log(myNumber.value) // 21\n
Cualquier componente que est\u00e9 utilizando myNumber se actualizar\u00e1 autom\u00e1ticamente para reflejar el nuevo valor. La funci\u00f3n ref()
es muy \u00fatil en Vue 3 para crear referencias reactivas a valores que pueden cambiar con el tiempo.
En los styles tenemos poco que explicar, simplemente estamos haciendo que se vea como nosotros queremos, que todos los colores y dem\u00e1s los traemos de las variables que hemos creado antes en el base.css
y adem\u00e1s me gustar\u00eda mencionar una cosa:
...\n:deep(.p-menubar-start) {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: center;\n margin-right: 1rem;\n}\n...\n
Si os dais cuenta algunos estilos llevan el :Deep delante, como seguro ya sabes, puedes utilizar el atributo scoped
dentro de la etiqueta <style>
para escribir CSS y as\u00ed impedir que tus estilos afecten a posibles sub-componentes. Pero, \u00bfqu\u00e9 ocurre si necesitas que al menos una regla s\u00ed afecte a tu componente hijo?. Para ello puedes usar la pseudo-clase :deep
de Vue 3.
En este ejemplo lo hemos creado asi para que sepas de su existencia y busques un poco de informaci\u00f3n sobre ella y las otras que existen, este CSS lo podr\u00edamos poner en el styles.scss
principal y no tendr\u00edamos que poner el :deep
que seria lo mas recomendado. Es importante tener en cuenta que la directiva :deep
puede tener un impacto en el rendimiento, ya que Vue necesita buscar en todo el \u00e1rbol de elementos para aplicar los estilos. Por lo tanto, se recomienda utilizar esta directiva con moderaci\u00f3n y solo en casos en los que sea necesario seleccionar elementos anidados de forma din\u00e1mica. Tenerlo en cuenta y solo usarla cuando de verdad sea necesario.
Ya por \u00faltimo nos iremos a nuestro MainLayout.vue
y a\u00f1adiremos el header que acabamos de crearnos:
<script setup lang=\"ts\">\nimport HeaderComponent from '@/components/header/HeaderComponent.vue';\n\nconst helloWorld = 'Hola Mundo';\n</script>\n\n<template>\n <HeaderComponent></HeaderComponent>\n <h1>{{ helloWorld }}</h1>\n</template>\n
Como antes, lo \u00fanico que hacemos es importar el componente en el script
y usarlo en el HTML.
Lo siguiente iremos a la carpeta router
, al archivo index.ts
y lo dejaremos asi:
import { createRouter, createWebHistory } from 'vue-router'\nimport MainLayout from '@/layouts/main-layout/MainLayout.vue'\n\nconst router = createRouter({\nhistory: createWebHistory(import.meta.env.BASE_URL),\nroutes: [\n{\npath: '/',\nname: 'home',\ncomponent: MainLayout\n}\n]\n})\n\nexport default router\n
Hemos cambiado la ruta principal para que apunte a nuestro layout
y nada m\u00e1s entrar en la aplicaci\u00f3n lo carguemos gracias al router de VUE.
Si guardamos todo y ponemos en marcha el proyecto ya veremos algo como esto:
"},{"location":"develop/basic/vuejsold/#creando-un-listado-basico","title":"Creando un listado b\u00e1sico","text":""},{"location":"develop/basic/vuejsold/#crear-componente_1","title":"Crear componente","text":"Ya tenemos la estructura principal, ahora vamos a crear nuestra primera pantalla. Vamos a empezar por la de Categor\u00edas que es la m\u00e1s sencilla, ya que se trata de un listado, que muestra datos sin filtrar ni paginar.
Como categor\u00edas es un dominio funcional de la aplicaci\u00f3n, vamos a crear una nueva carpeta dentro de la carpeta views
llamada categories, todas las pantallas, componentes y servicios que creemos, referidos a este dominio funcional, deber\u00e1n ir dentro del m\u00f3dulo categories. Dentro de esa carpeta crearemos un fichero que se llamara CategoriesView.vue
y dentro nos crearemos el esqueleto de la misma manera que hicimos anteriormente.
Escribiremos vbase-3-ts-setup
, le daremos al enter y nos generara toda la estructura a la que solo faltara agregar a la etiqueta <style> Lang=\u201dscss\u201d
para decirle que vamos a trabajar con SASS. Con esto tenemos nuestra vista preparada para empezar a trabajar.
Lo primero vamos a conectar nuestro componente al router
para que cuando hagamos click en el men\u00fa correspondiente podamos llegar hasta \u00e9l y tambi\u00e9n para poder ver lo que vamos trabajando. Para ello lo primero que vamos a hacer en el template de nuestro componente es a\u00f1adir cualquier cosa para saber que estamos donde toca, por ejemplo:
<template>\n <div>SOY CATEGORIAS</div>\n</template>\n
Con esto cuando entremos en la ruta de categor\u00edas deber\u00edamos ver SOY CATEGORIAS
.
Lo siguiente crearemos en el layout
un sitio para cargar todas nuestras rutas que van a ir dentro de ese layout, para ello iremos al archivo MainLayout.vue
y a\u00f1adiremos un <RouterView />
que ser\u00e1 el segundo de nuestra aplicaci\u00f3n, el primero lo tenemos en el App.vue
que servir\u00e1 para cargar nuestras rutas principales (diferentes layouts, pagina 404, etc) y el segundo es este que acabamos de crear, podemos tener tantos como queramos en una aplicaci\u00f3n y cada uno tendr\u00e1 su cometido. Este que acabamos de crear ser\u00e1 donde se cargaran todas las rutas que quieran estar dentro del layout
principal.
Para crearlo importaremos \u00e9l RouterView
dentro de los <script>
desde vue-router
:
import { RouterView } from 'vue-router';\n
Lo a\u00f1adiremos dentro de los <template>
exactamente donde queramos cargar las rutas y si puede ser con un div
padre que haga de contenedor asi podremos darle los estilos sin sufrir demasiado.
<div class=\"outlet-container\">\n <RouterView />\n</div>\n
Y luego dentro de <style>
le daremos estilo al contenedor padre de acuerdo a lo que necesitemos (grid, flex, etc\u2026) en este ejemplo para hacerlo f\u00e1cil lo haremos con flex, con todo esto quedar\u00eda asi:
<script setup lang=\"ts\">\nimport { RouterView } from 'vue-router';\nimport HeaderComponent from \"@/components/header/HeaderComponent.vue\";\n</script>\n\n<template>\n <HeaderComponent></HeaderComponent>\n <div class=\"outlet-container\">\n <RouterView />\n </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.outlet-container {\ndisplay: flex;\nflex-direction: column;\nflex-grow: 1;\nwidth: 100%;\nmin-height: calc(100vh - 65px);\npadding: 1rem;\n}\n</style>\n
Ahora vamos a a\u00f1adirlo a nuestras rutas, para ello nos vamos a la carpeta router
y dentro tendremos el index.ts
con nuestras rutas actuales, vamos a a\u00f1adir la nueva ruta como hija de layout
para que siempre se muestre dentro del layout
que hemos creado con \u00e9l header
:
import { createRouter, createWebHistory } from 'vue-router'\nimport MainLayout from '@/layouts/main-layout/MainLayout.vue'\n\nconst router = createRouter({\nhistory: createWebHistory(import.meta.env.BASE_URL),\nroutes: [\n{\npath: '/',\nname: 'home',\ncomponent: MainLayout,\nchildren: [\n{\npath: '/categories',\nname: 'categories',\ncomponent: () => import('../views/categories/CategoriesView.vue')\n}\n]\n}\n]\n})\n\nexport default router\n
Si os dais cuenta lo hemos a\u00f1adido como hijo de layout
y adem\u00e1s lo hemos hecho con lazy loading
, es decir, este componente solo se cargara cuando el usuario navegue a esa ruta, asi evitamos cargas much\u00edsimo m\u00e1s grandes al inicio de la aplicaci\u00f3n.
Posteriormente nos iremos al HeaderComponent.vue
y a\u00f1adiremos la ruta a los \u00edtems del men\u00fa de esta manera:
const items = ref([\n {\n label: \"Cat\u00e1logo\",\n },\n {\n label: \"Categor\u00edas\",\n to: { name: 'categories'}\n },\n {\n label: \"Autores\",\n },\n]);\n
Si nos fijamos hemos a\u00f1adido la navegaci\u00f3n por el nombre de ruta en el men\u00fa categor\u00edas para que sepa cuando apretemos ese men\u00fa donde nos tiene que llevar.
Con todo esto si ponemos en marcha nuestra aplicaci\u00f3n, ya podremos navegar haciendo click en el men\u00fa Categor\u00edas a esta nueva ruta que hemos creado y ya ver\u00edamos el SOY CATEGORIAS
pero tenemos un problemilla en los menus, cuando apretamos un men\u00fa se pone el fondo gris, lo cual no nos gusta y adem\u00e1s aunque estemos en categor\u00edas si apretamos en otro men\u00fa se pone el otro gris y se quita el categor\u00edas lo cual tampoco es lo deseado ya que queremos que se quede marcado el men\u00fa donde estamos actualmente para informaci\u00f3n del usuario. Para ello nos iremos al base.css
y a\u00f1adiremos al final estas l\u00edneas:
...\n.router-link-active {\nbackground: var(--secondary);\nborder-radius: 5px;\n}\n\n.p-menuitem.p-focus > .p-menuitem-content:not(:hover) {\nbackground: transparent !important;\n}\n
En Vue 3, la directiva router-link-active
se utiliza para establecer una clase CSS en un enlace de router activo, con esto ya tendremos resuelto el problema y todo estar\u00e1 funcionando como toca y poniendo en marcha la aplicaci\u00f3n y haciendo click en el men\u00fa Categor\u00edas ya deber\u00edamos ver esto:
Ahora vamos a construir la pantalla. Para manejar la informaci\u00f3n del listado, necesitamos tipar los datos para que Typescript no se queje. Para ello crearemos un fichero en categories\\models\\category-interface.ts
donde implementaremos la interface necesaria. Esta interface ser\u00e1 la que utilizaremos para tipar el c\u00f3digo de nuestro componente.
export interface Category {\nid: number\nname: string\n}\n
Tambi\u00e9n, escribiremos el c\u00f3digo de CategoriesView.vue
:
<template>\n <div class=\"card\">\n <DataTable\n v-model:editingRows=\"editingRows\"\n :value=\"categories\"\n tableStyle=\"min-width: 50rem\"\n editMode=\"row\"\n dataKey=\"id\"\n @row-edit-save=\"onRowEditSave\"\n >\n <Column field=\"id\" header=\"IDENTIFICADOR\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n </Column>\n <Column field=\"name\" header=\"NOMBRE CATEGOR\u00cdA\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n </Column>\n <Column\n :rowEditor=\"true\"\n style=\"width: 110px\"\n bodyStyle=\"text-align:center\"\n ></Column>\n <Column\n style=\"width: 30px; padding: 0px 2rem 0px 0px; color: red\"\n bodyStyle=\"text-align:center\"\n >\n <template #body=\"{ data }\">\n <i class=\"pi pi-times\" @click=\"onRowDelete(data)\"></i>\n </template>\n </Column>\n </DataTable>\n </div>\n <div class=\"actions\">\n <Button label=\"Nueva categor\u00eda\" />\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from \"vue\";\nimport DataTable, { type DataTableRowEditSaveEvent } from \"primevue/datatable\";\nimport Column from \"primevue/column\";\nimport InputText from \"primevue/inputtext\";\nimport Button from 'primevue/button';\nimport type { CategoryInterface } from \"./model/category.interface\";\nconst categories = ref([]);\nconst editingRows = ref([]);\nconst onRowEditSave = (event: DataTableRowEditSaveEvent) => {\nconsole.log(event);\n};\nconst onRowDelete = (data: CategoryInterface) => {\nconsole.log(data);\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.actions {\ndisplay: flex;\nflex-direction: row;\nmargin-top: 1rem;\njustify-content: flex-end;\n}\n\n.p-button {\nbackground: var(--primary);\nborder: 1px solid var(--primary);\n\n&:enabled {\n&:hover {\nbackground: var(--secondary);\nborder-color: var(--secondary);\n}\n}\n}\n</style>\n
Intentaremos explicar un poco el c\u00f3digo:
Lo primero vamos a importar el componente DataTable desde la librer\u00eda de componentes que estamos usando, para ello podemos ver algunos ejemplos de como hacerlo en la documentaci\u00f3n oficial
Nosotros hemos puesto las importaciones que necesitamos en el <script>
:
import DataTable, { type DataTableRowEditSaveEvent } from \"primevue/datatable\";\nimport Column from \"primevue/column\";\n
Hemos creado nuestra tabla con las exigencias de la aplicaci\u00f3n, hemos puesto dos columnas, la columna identificador donde en \u00e9l header=\u201d\u201d
le ponemos que nombre se muestra en la cabecera y le hemos dicho que debe mostrar en ella el dato id poni\u00e9ndolo en \u00e9l field=\u201d\u201d
.
<Column field=\"id\" header=\"IDENTIFICADOR\"></Column>\n
Como a la tabla le hemos dicho que debe ser editable con:
CategoriesView.vueeditMode=\"row\"\n
Le decimos a esta columna que debe hacer cuando entremos en modo de edici\u00f3n, con el template le decimos que mostrara un InputText
que es otro componente de la librer\u00eda de componentes que viene a ser un input de toda la vida donde podemos escribir texto para editar el valor, quedando al final asi:
<Column field=\"id\" header=\"IDENTIFICADOR\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n</Column>\n
Luego hemos creado dos columnas, una que tiene el l\u00e1piz y activa el modo edici\u00f3n:
CategoriesView.vue<Column\n :rowEditor=\"true\"\n style=\"width: 110px\"\n bodyStyle=\"text-align:center\">\n</Column>\n
Y otra que tiene la X y lo que har\u00e1 ser\u00e1 borrar la fila:
CategoriesView.vue<Column\n style=\"width: 30px; padding: 0px 2rem 0px 0px; color: red\"\n bodyStyle=\"text-align:center\"\n>\n <template #body=\"{ data }\">\n <i class=\"pi pi-times\" @click=\"onRowDelete(data)\"></i>\n </template>\n</Column>\n
Al final a\u00f1adimos otro contenedor que vale para alojar los botones como en nuestro caso el de crear nueva categor\u00eda, el bot\u00f3n es tambi\u00e9n un componente de la librer\u00eda por lo que tendremos que hacer su import en la etiqueta <script>
:
<div class=\"actions\">\n <Button label=\"Nueva categor\u00eda\" />\n</div>\n
Si abrimos el navegador y accedemos a http://localhost:5173/
y pulsamos en el men\u00fa de Categor\u00edas obtendremos una pantalla con un listado vac\u00edo (solo con cabeceras) y un bot\u00f3n de crear Nueva Categor\u00eda
que a\u00fan no hace nada.
En este punto y para ver como responde el listado, vamos a a\u00f1adir datos. Si tuvi\u00e9ramos el backend implementado podr\u00edamos consultar los datos directamente de una operaci\u00f3n de negocio de backend, pero ahora mismo no lo tenemos implementado as\u00ed que para no bloquear el desarrollo vamos a mockear los datos.
En Vue para conectar a APIS externas solemos usar una librer\u00eda llamada Axios, lo primero que haremos ser\u00e1 descargarla e instalarla como indica en su documentaci\u00f3n oficial.
Para instalarla simplemente nos iremos a la terminal dentro de la carpeta donde tenemos el proyecto y pondremos:
npm install axios\n
Con esto ya podremos ver que se ha a\u00f1adido a nuestro package.json
, luego crearemos una carpeta api
dentro de la carpeta src
y dentro de la carpeta api
crearemos el archivo app-api.ts
. Dentro de este archivo vamos a inicializar nuestra config de la API y guardaremos todos los par\u00e1metros iniciales conforme nos vayan haciendo falta, de momento pondremos solo este c\u00f3digo:
import axios from 'axios';\n\nexport const appApi = axios.create({\nbaseURL: 'http://localhost:8080',\n});\n
Si os dais cuenta lo \u00fanico que hacemos es importar axios
que acabamos de instalarlo y definir nuestra url base del api para no tener que escribirla cada vez y para s\u00ed alg\u00fan d\u00eda cambia, tener que cambiarla solo en un sitio y no en todos los servicios que la usen.
Para mockear los datos con axios
usaremos una librer\u00eda que se llama axios-mock-adapter
y la pod\u00e9is encontrar en este link.
Para instalarla lo haremos con npm como siempre, pondremos esta orden en el terminal y enter:
npm install axios-mock-adapter --save-dev\n
Si nos vamos al package.json
veremos que ya la tenemos en las devDependencies
, la diferencia entre estas y las dependencias es que las dependencias las necesitamos en el proyecto y estar\u00e1n en nuestro bundle
final que serviremos a la gente, las devDependencies
se usan solo mientras programamos y no entraran en el bundle
final. Los mocks los usaremos solo en el desarrollo y hasta que podamos conectar con la API real por eso los metemos en las devDependencies.
Como hemos comentado anteriormente, el backend todav\u00eda no est\u00e1 implementado as\u00ed que vamos a mockear datos. Nos crearemos un fichero mock-categories.ts
dentro de views/categories/mocks
, con datos ficticios y crearemos una llamada a la API que nos devuelva estos datos. De esta forma, cuando tengamos implementada la operaci\u00f3n de negocio en backend, tan solo tenemos que sustituir el c\u00f3digo que devuelve datos est\u00e1ticos por una llamada HTTP.
Dentro de la carpeta mocks
crearemos el archivo mock-categories.ts
con el siguiente c\u00f3digo:
import type { Category } from \"@/views/categories/models/category-interface\";\n\nexport const CATEGORY_DATA_MOCK: Category[] = [\n{ id: 1, name: 'Dados' },\n{ id: 2, name: 'Fichas' },\n{ id: 3, name: 'Cartas' },\n{ id: 4, name: 'Rol' },\n{ id: 5, name: 'Tableros' },\n{ id: 6, name: 'Tem\u00e1ticos' },\n{ id: 7, name: 'Europeos' },\n{ id: 8, name: 'Guerra' },\n{ id: 9, name: 'Abstractos' },\n]\n
Despu\u00e9s nos crearemos un composable
que usaremos para llamar a la API y poder reutilizarlo en otros componentes si hiciera falta. Dentro de la carpeta categories
crearemos otra carpeta llamada composables
y dentro crearemos un archivo llamado categories-composable.ts
, en ese archivo escribiremos este c\u00f3digo:
import appApi from '@/api/app-api'\nimport MockAdapter from 'axios-mock-adapter';\nimport { CATEGORY_DATA_MOCK } from '@/views/categories/mocks/mock-categories'\n\nconst mock = new MockAdapter(appApi);\n\nconst useCategoriesApiComposable = () => {\nmock.onGet(\"/category\").reply(200, CATEGORY_DATA_MOCK);\n\nconst getCategories = async () => {\nconst categories = await appApi.get(\"/category\");\nreturn categories.data;\n};\n\nreturn {\ngetCategories\n}\n}\n\nexport default useCategoriesApiComposable\n
A\u00f1adiremos \u00e9l composable
a nuestro CategoriesView.vue
dentro de las etiquetas <script>
, lo primero en el import
que ya tenemos desde Vue a\u00f1adiremos el m\u00e9todo onMounted
dej\u00e1ndolo asi:
import { onMounted, ref } from 'vue'\n
El m\u00e9todo onMounted
es un ciclo de vida que se dispara nada m\u00e1s montarse el componente, despu\u00e9s a\u00f1adiremos al final el import
del composable
para poder usarlo:
import useCategoriesApiComposable from '@/views/categories/composables/categories-composable'\n
Nos traeremos el m\u00e9todo del composable
con la desestructuraci\u00f3n del objeto:
const { getCategories } = useCategoriesApiComposable()\n
Crearemos una funci\u00f3n as\u00edncrona para llamar al composable
y llamaremos al composable
en el onMounted
:
async function getInitCategories() {\n categories.value = await getCategories()\n}\n\nonMounted(() => {\n getInitCategories()\n})\n
El CategoriesView.vue
quedar\u00eda asi:
<template>\n <div class=\"card\">\n <DataTable\n v-model:editingRows=\"editingRows\"\n :value=\"categories\"\n tableStyle=\"min-width: 50rem\"\n editMode=\"row\"\n dataKey=\"id\"\n @row-edit-save=\"onRowEditSave\"\n >\n <Column field=\"id\" header=\"IDENTIFICADOR\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n </Column>\n <Column field=\"name\" header=\"NOMBRE CATEGOR\u00cdA\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n </Column>\n <Column :rowEditor=\"true\" style=\"width: 110px\" bodyStyle=\"text-align:center\"></Column>\n <Column\n style=\"width: 30px; padding: 0px 2rem 0px 0px; color: red\"\n bodyStyle=\"text-align:center\"\n >\n <template #body=\"{ data }\">\n <i class=\"pi pi-times\" @click=\"onRowDelete(data)\"></i>\n </template>\n </Column>\n </DataTable>\n </div>\n <div class=\"actions\">\n <Button label=\"Nueva categor\u00eda\" />\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { onMounted, ref } from 'vue'\nimport DataTable, { type DataTableRowEditSaveEvent } from 'primevue/datatable'\nimport Column from 'primevue/column'\nimport InputText from 'primevue/inputtext'\nimport Button from 'primevue/button'\nimport type { CategoryInterface } from './model/category.interface'\nimport useCategoriesApiComposable from '@/views/categories/composables/categories-composable'\n\nconst categories = ref([])\nconst editingRows = ref([])\n\nconst { getCategories } = useCategoriesApiComposable()\n\nconst onRowEditSave = (event: DataTableRowEditSaveEvent) => {\nconsole.log(event)\n}\n\nconst onRowDelete = (data: CategoryInterface) => {\nconsole.log(data)\n}\n\nasync function getInitCategories() {\ncategories.value = await getCategories()\n}\n\nonMounted(() => {\ngetInitCategories()\n})\n</script>\n\n<style lang=\"scss\" scoped>\n.actions {\ndisplay: flex;\nflex-direction: row;\nmargin-top: 1rem;\njustify-content: flex-end;\n}\n\n.p-button {\nbackground: var(--primary);\nborder: 1px solid var(--primary);\n\n&:enabled {\n&:hover {\nbackground: var(--secondary);\nborder-color: var(--secondary);\n}\n}\n}\n</style>\n
Si ahora refrescamos la p\u00e1gina web, veremos que el listado ya tiene datos con los que vamos a interactuar.
"},{"location":"develop/basic/vuejsold/#simulando-las-otras-peticiones","title":"Simulando las otras peticiones","text":"Para terminar, vamos a simular las otras dos peticiones, la de editar y la de borrar para cuando tengamos que utilizarlas. \u00c9l composable
debe quedar m\u00e1s o menos as\u00ed:
import appApi from '@/api/app-api'\nimport MockAdapter from 'axios-mock-adapter'\nimport type { Category } from '@/views/categories/models/category-interface'\nimport { CATEGORY_DATA_MOCK } from '@/views/categories/mocks/mock-categories'\n\nconst mock = new MockAdapter(appApi)\n\nconst useCategoriesApiComposable = () => {\nmock.onAny('/category').reply(200, CATEGORY_DATA_MOCK)\n\nconst getCategories = async () => {\nconst categories = await appApi.get('/category')\nreturn categories.data\n}\n\nconst saveCategory = async (category: Category) => {\nconst categoryEdit = await appApi.post('/category', category)\nreturn categoryEdit.data\n}\n\nconst editCategory = async (category: Category) => {\nconst categoryEdit = await appApi.put('/category', category)\nreturn categoryEdit.data\n}\n\nconst deleteCategory = async (categoryId: number) => {\nconst categoryEdit = await appApi.delete(`/category/${categoryId}`)\nreturn categoryEdit.data\n}\n\nreturn {\ngetCategories,\nsaveCategory,\neditCategory,\ndeleteCategory\n}\n}\n\nexport default useCategoriesApiComposable\n
"},{"location":"develop/filtered/angular/","title":"Listado filtrado - Angular","text":"En este punto ya tenemos dos listados, uno b\u00e1sico y otro paginado. Ahora vamos a implementar un listado un poco diferente, con filtros y con una presentaci\u00f3n un tanto distinta.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/filtered/angular/#crear-componentes","title":"Crear componentes","text":"Vamos a desarrollar el listado de Juegos
. Este listado es un tanto peculiar, porque no tiene una tabla como tal, sino que tiene una tabla con \"tiles\" para cada uno de los juegos. Necesitaremos un componente para el listado y otro componente para el detalle del juego. Tambi\u00e9n necesitaremos otro componente para el dialogo de edici\u00f3n / alta.
Manos a la obra:
ng generate module game\n\nng generate component game/game-list\nng generate component game/game-list/game-item\nng generate component game/game-edit\n\nng generate service game/game\n
Y a\u00f1adimos el nuevo m\u00f3dulo al app.module.ts
como hemos hecho con el resto de m\u00f3dulos.
import { NgModule } from '@angular/core';\nimport { BrowserModule } from '@angular/platform-browser';\n\nimport { AppRoutingModule } from './app-routing.module';\nimport { AppComponent } from './app.component';\nimport { BrowserAnimationsModule } from '@angular/platform-browser/animations';\nimport { CoreModule } from './core/core.module';\nimport { CategoryModule } from './category/category.module';\nimport { AuthorModule } from './author/author.module';\nimport { GameModule } from './game/game.module';\n\n@NgModule({\ndeclarations: [\nAppComponent\n],\nimports: [\nBrowserModule,\nAppRoutingModule,\nCoreModule,\nCategoryModule,\nAuthorModule,\nGameModule,\nBrowserAnimationsModule\n],\nproviders: [],\nbootstrap: [AppComponent]\n})\nexport class AppModule { }\n
"},{"location":"develop/filtered/angular/#crear-el-modelo","title":"Crear el modelo","text":"Lo primero que vamos a hacer es crear el modelo en game/model/Game.ts
con todas las propiedades necesarias para trabajar con un juego:
import { Category } from \"src/app/category/model/Category\";\nimport { Author } from \"src/app/author/model/Author\";\n\nexport class Game {\nid: number;\ntitle: string;\nage: number;\ncategory: Category;\nauthor: Author;\n}\n
Como ves, el juego tiene dos objetos para mapear categor\u00eda y autor.
"},{"location":"develop/filtered/angular/#anadir-el-punto-de-entrada","title":"A\u00f1adir el punto de entrada","text":"A\u00f1adimos la ruta al men\u00fa para que podamos navegar a esta pantalla:
app-routing.module.tsimport { NgModule } from '@angular/core';\nimport { Routes, RouterModule } from '@angular/router';\nimport { AuthorListComponent } from './author/author-list/author-list.component';\nimport { CategoryListComponent } from './category/category-list/category-list.component';\nimport { GameListComponent } from './game/game-list/game-list.component';\nconst routes: Routes = [\n{ path: '', redirectTo: '/games', pathMatch: 'full'},\n{ path: 'categories', component: CategoryListComponent },\n{ path: 'authors', component: AuthorListComponent },\n{ path: 'games', component: GameListComponent },\n];\n\n@NgModule({\nimports: [RouterModule.forRoot(routes)],\nexports: [RouterModule]\n})\nexport class AppRoutingModule { }\n
Adem\u00e1s, hemos a\u00f1adido una regla adicional con el path vac\u00edo para indicar que si no pone ruta, por defecto la p\u00e1gina inicial redirija al path /games
, que es nuevo path que hemos a\u00f1adido.
A continuaci\u00f3n implementamos el servicio y mockeamos datos de ejemplo:
mock-games.tsgame.service.tsimport { Game } from \"./Game\";\n\nexport const GAME_DATA: Game[] = [\n{ id: 1, title: 'Juego 1', age: 6, category: { id: 1, name: 'Categor\u00eda 1' }, author: { id: 1, name: 'Autor 1', nationality: 'Nacionalidad 1' } },\n{ id: 2, title: 'Juego 2', age: 8, category: { id: 1, name: 'Categor\u00eda 1' }, author: { id: 2, name: 'Autor 2', nationality: 'Nacionalidad 2' } },\n{ id: 3, title: 'Juego 3', age: 4, category: { id: 1, name: 'Categor\u00eda 1' }, author: { id: 3, name: 'Autor 3', nationality: 'Nacionalidad 3' } },\n{ id: 4, title: 'Juego 4', age: 10, category: { id: 2, name: 'Categor\u00eda 2' }, author: { id: 1, name: 'Autor 1', nationality: 'Nacionalidad 1' } },\n{ id: 5, title: 'Juego 5', age: 16, category: { id: 2, name: 'Categor\u00eda 2' }, author: { id: 2, name: 'Autor 2', nationality: 'Nacionalidad 2' } },\n{ id: 6, title: 'Juego 6', age: 16, category: { id: 2, name: 'Categor\u00eda 2' }, author: { id: 3, name: 'Autor 3', nationality: 'Nacionalidad 3' } },\n{ id: 7, title: 'Juego 7', age: 12, category: { id: 3, name: 'Categor\u00eda 3' }, author: { id: 1, name: 'Autor 1', nationality: 'Nacionalidad 1' } },\n{ id: 8, title: 'Juego 8', age: 14, category: { id: 3, name: 'Categor\u00eda 3' }, author: { id: 2, name: 'Autor 2', nationality: 'Nacionalidad 2' } },\n]\n
import { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Game } from './model/Game';\nimport { GAME_DATA } from './model/mock-games';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class GameService {\n\nconstructor() { }\n\ngetGames(title?: String, categoryId?: number): Observable<Game[]> {\nreturn of(GAME_DATA);\n}\n\nsaveGame(game: Game): Observable<void> {\nreturn of(null);\n}\n\n}\n
"},{"location":"develop/filtered/angular/#implementar-listado","title":"Implementar listado","text":"Ya tenemos las operaciones del servicio con datoos, as\u00ed que ahora vamos a por el listado filtrado.
game-list.component.htmlgame-list.component.scssgame-list.component.ts<div class=\"container\">\n <h1>Cat\u00e1logo de juegos</h1>\n\n <div class=\"filters\">\n <form>\n <mat-form-field>\n <mat-label>T\u00edtulo del juego</mat-label>\n <input type=\"text\" matInput placeholder=\"T\u00edtulo del juego\" [(ngModel)]=\"filterTitle\" name=\"title\">\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Categor\u00eda del juego</mat-label>\n <mat-select disableRipple [(ngModel)]=\"filterCategory\" name=\"category\">\n <mat-option *ngFor=\"let category of categories\" [value]=\"category\">{{category.name}}</mat-option>\n </mat-select>\n </mat-form-field> \n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onCleanFilter()\">Limpiar</button> \n <button mat-stroked-button (click)=\"onSearch()\">Filtrar</button> \n </div> \n </div> \n\n <div class=\"game-list\">\n <app-game-item *ngFor=\"let game of games; let i = index;\" (click)=\"editGame(game)\">\n </app-game-item>\n </div>\n\n <div class=\"buttons\">\n <button mat-flat-button color=\"primary\" (click)=\"createGame()\">Nuevo juego</button> \n </div> \n</div>\n
.container {\nmargin: 20px;\n\n.filters {\ndisplay: flex;\n\nmat-form-field {\nwidth: 300px;\nmargin-right: 20px;\n}\n\n.buttons {\nflex: auto;\nalign-self: center;\n\nbutton {\nmargin-left: 15px;\n}\n}\n}\n\n.game-list { margin-top: 20px;\nmargin-bottom: 20px;\n\ndisplay: flex;\nflex-flow: wrap;\noverflow: auto; }\n\n.buttons {\ntext-align: right;\n}\n}\n\nbutton {\nwidth: 125px;\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatDialog } from '@angular/material/dialog';\nimport { CategoryService } from 'src/app/category/category.service';\nimport { Category } from 'src/app/category/model/Category';\nimport { GameEditComponent } from '../game-edit/game-edit.component';\nimport { GameService } from '../game.service';\nimport { Game } from '../model/Game';\n\n@Component({\nselector: 'app-game-list',\ntemplateUrl: './game-list.component.html',\nstyleUrls: ['./game-list.component.scss']\n})\nexport class GameListComponent implements OnInit {\n\ncategories : Category[];\ngames: Game[];\nfilterCategory: Category;\nfilterTitle: string;\n\nconstructor(\nprivate gameService: GameService,\nprivate categoryService: CategoryService,\npublic dialog: MatDialog,\n) { }\n\nngOnInit(): void {\n\nthis.gameService.getGames().subscribe(\ngames => this.games = games\n);\n\nthis.categoryService.getCategories().subscribe(\ncategories => this.categories = categories\n);\n}\n\nonCleanFilter(): void {\nthis.filterTitle = null;\nthis.filterCategory = null;\n\nthis.onSearch();\n}\n\nonSearch(): void {\n\nlet title = this.filterTitle;\nlet categoryId = this.filterCategory != null ? this.filterCategory.id : null;\n\nthis.gameService.getGames(title, categoryId).subscribe(\ngames => this.games = games\n);\n}\n\ncreateGame() { const dialogRef = this.dialog.open(GameEditComponent, {\ndata: {}\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); } editGame(game: Game) {\nconst dialogRef = this.dialog.open(GameEditComponent, {\ndata: { game: game }\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.onSearch();\n});\n}\n}\n
Recuerda, de nuevo, que todos los componentes de Angular que utilicemos hay que importarlos en el m\u00f3dulo padre correspondiente para que se puedan precargar correctamente.
game.module.tsimport { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { GameListComponent } from './game-list/game-list.component';\nimport { GameEditComponent } from './game-edit/game-edit.component';\nimport { GameItemComponent } from './game-list/game-item/game-item.component';\nimport { FormsModule, ReactiveFormsModule } from '@angular/forms';\nimport { MatButtonModule } from '@angular/material/button';\nimport { MatOptionModule } from '@angular/material/core';\nimport { MatDialogModule } from '@angular/material/dialog';\nimport { MatFormFieldModule } from '@angular/material/form-field';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatInputModule } from '@angular/material/input';\nimport { MatPaginatorModule } from '@angular/material/paginator';\nimport { MatSelectModule } from '@angular/material/select';\nimport { MatTableModule } from '@angular/material/table';\nimport { MatCardModule } from '@angular/material/card';\n\n\n@NgModule({\ndeclarations: [\nGameListComponent,\nGameEditComponent,\nGameItemComponent\n],\nimports: [\nCommonModule,\nMatTableModule,\nMatIconModule, MatButtonModule,\nMatDialogModule,\nMatFormFieldModule,\nMatInputModule,\nFormsModule,\nReactiveFormsModule,\nMatPaginatorModule,\nMatOptionModule,\nMatSelectModule,\nMatCardModule,\n]\n})\nexport class GameModule { }\n
Con todos estos cambios y si refrescamos el navegador, deber\u00eda verse una pantalla similar a esta:
Tenemos una pantalla con una secci\u00f3n de filtros en la parte superior, donde podemos introducir un texto o seleccionar una categor\u00eda de un dropdown, un listado que de momento tiene todos los componentes b\u00e1sicos en una fila uno detr\u00e1s del otro, y un bot\u00f3n para crear juegos nuevos.
Dropdown
El componente Dropdown
es uno de los componentes m\u00e1s utilizados en las pantallas y formularios de Angular. Ves familiariz\u00e1ndote con \u00e9l porque lo vas a usar mucho. Es bastante potente y medianamente sencillo de utilizar. Los datos del listado pueden ser din\u00e1micos (desde servidor) o est\u00e1ticos (si los valores ya los tienes prefijados).
Ahora vamos a implementar el detalle de cada uno de los items que forman el listado. Para ello lo primero que haremos ser\u00e1 pasarle la informaci\u00f3n del juego a cada componente como un dato de entrada Input
hacia el componente.
<div class=\"container\">\n <h1>Cat\u00e1logo de juegos</h1>\n\n <div class=\"filters\">\n <form>\n <mat-form-field>\n <mat-label>T\u00edtulo del juego</mat-label>\n <input type=\"text\" matInput placeholder=\"T\u00edtulo del juego\" [(ngModel)]=\"filterTitle\" name=\"title\">\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Categor\u00eda del juego</mat-label>\n <mat-select disableRipple [(ngModel)]=\"filterCategory\" name=\"category\">\n <mat-option *ngFor=\"let category of categories\" [value]=\"category\">{{category.name}}</mat-option>\n </mat-select>\n </mat-form-field> \n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onCleanFilter()\">Limpiar</button> \n <button mat-stroked-button (click)=\"onSearch()\">Filtrar</button> \n </div> \n </div> \n\n <div class=\"game-list\">\n<app-game-item *ngFor=\"let game of games; let i = index;\" (click)=\"editGame(game)\" [game]=\"game\">\n</app-game-item>\n </div>\n\n <div class=\"buttons\">\n <button mat-flat-button color=\"primary\" (click)=\"createGame()\">Nuevo juego</button> \n </div> \n</div>\n
Tambi\u00e9n vamos a necesitar una foto de ejemplo para poner dentro de la tarjeta detalle de los juegos. Vamos a utilizar esta imagen:
Desc\u00e1rgala y d\u00e9jala dentro del proyecto en assets/foto.png
. Y ya para terminar, implementamos el componente de detalle:
<div class=\"container\">\n <mat-card>\n <div class=\"photo\">\n <img src=\"./assets/foto.png\">\n </div>\n <div class=\"detail\">\n <div class=\"title\">{{game.title}}</div>\n <div class=\"properties\">\n <div><i>Edad recomendada: </i>+{{game.age}}</div>\n <div><i>Categor\u00eda: </i>{{game.category.name}}</div>\n <div><i>Autor: </i>{{game.author.name}}</div>\n <div><i>Nacionalidad: </i>{{game.author.nationality}}</div>\n </div>\n </div>\n </mat-card>\n</div>\n
.container {\ndisplay: flex;\nwidth: 325px;\n\nmat-card {\nwidth: 100%;\nmargin: 10px;\ndisplay: flex;\n\n.photo {\nmargin-right: 10px;\n\nimg {\nwidth: 80px;\nheight: 80px;\n}\n}\n\n.detail {\n.title {\nfont-size: 14px;\nfont-weight: bold;\n}\n\n.properties {\nfont-size: 11px;\n\ndiv {\nheight: 15px;\n} }\n}\n}\n}
import { Component, OnInit, Input } from '@angular/core';\nimport { Game } from '../../model/Game';\n\n@Component({\nselector: 'app-game-item',\ntemplateUrl: './game-item.component.html',\nstyleUrls: ['./game-item.component.scss']\n})\nexport class GameItemComponent implements OnInit {\n\n@Input() game: Game;\nconstructor() { }\n\nngOnInit(): void {\n}\n\n}\n
Ahora si que deber\u00eda quedar algo similar a esta pantalla:
"},{"location":"develop/filtered/angular/#implementar-dialogo-de-edicion","title":"Implementar dialogo de edici\u00f3n","text":"Ya solo nos falta el \u00faltimo paso, implementar el cuadro de edici\u00f3n / alta de un nuevo juego. Pero tenemos un peque\u00f1o problema, y es que al crear o editar un juego debemos seleccionar una Categor\u00eda
y un Autor
.
Para la Categor\u00eda
no tenemos ning\u00fan problema, pero para el Autor
no tenemos un servicio que nos devuelva todos los autores, solo tenemos un servicio que nos devuelve una Page
de autores.
As\u00ed que lo primero que haremos ser\u00e1 implementar una operaci\u00f3n getAllAuthors
para poder recuperar una lista.
import { Author } from \"./Author\";\n\nexport const AUTHOR_DATA_LIST : Author[] = [\n{ id: 1, name: 'Klaus Teuber', nationality: 'Alemania' },\n{ id: 2, name: 'Matt Leacock', nationality: 'Estados Unidos' },\n{ id: 3, name: 'Keng Leong Yeo', nationality: 'Singapur' },\n{ id: 4, name: 'Gil Hova', nationality: 'Estados Unidos'},\n{ id: 5, name: 'Kelly Adams', nationality: 'Estados Unidos' },\n]
import { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Pageable } from '../core/model/page/Pageable';\nimport { Author } from './model/Author';\nimport { AuthorPage } from './model/AuthorPage';\nimport { AUTHOR_DATA_LIST } from './model/mock-authors-list';\n@Injectable({\nprovidedIn: 'root'\n})\nexport class AuthorService {\n\nconstructor(\nprivate http: HttpClient\n) { }\n\ngetAuthors(pageable: Pageable): Observable<AuthorPage> {\nreturn this.http.post<AuthorPage>('http://localhost:8080/author', {pageable:pageable});\n}\n\nsaveAuthor(author: Author): Observable<void> {\n\nlet url = 'http://localhost:8080/author';\nif (author.id != null) url += '/'+author.id;\n\nreturn this.http.put<void>(url, author);\n}\n\ndeleteAuthor(idAuthor : number): Observable<void> {\nreturn this.http.delete<void>('http://localhost:8080/author/'+idAuthor);\n} getAllAuthors(): Observable<Author[]> {\nreturn of(AUTHOR_DATA_LIST);\n}\n}\n
Ahora s\u00ed que tenemos todo listo para implementar el cuadro de dialogo para dar de alta o editar juegos.
game-edit.component.htmlgame-edit.component.scssgame-edit.component.ts<div class=\"container\">\n <h1 *ngIf=\"game.id == null\">Crear juego</h1>\n <h1 *ngIf=\"game.id != null\">Modificar juego</h1>\n\n <form>\n <mat-form-field>\n <mat-label>Identificador</mat-label>\n <input type=\"text\" matInput placeholder=\"Identificador\" [(ngModel)]=\"game.id\" name=\"id\" disabled>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>T\u00edtulo</mat-label>\n <input type=\"text\" matInput placeholder=\"T\u00edtulo del juego\" [(ngModel)]=\"game.title\" name=\"title\" required>\n <mat-error>El t\u00edtulo no puede estar vac\u00edo</mat-error>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Edad recomendada</mat-label>\n <input type=\"number\" matInput placeholder=\"Edad recomendada\" [(ngModel)]=\"game.age\" name=\"age\" required>\n <mat-error>La edad no puede estar vac\u00eda</mat-error>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Categor\u00eda</mat-label>\n <mat-select disableRipple [(ngModel)]=\"game.category\" name=\"category\" required>\n <mat-option *ngFor=\"let category of categories\" [value]=\"category\">{{category.name}}</mat-option>\n </mat-select>\n <mat-error>La categor\u00eda no puede estar vac\u00eda</mat-error>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Autor</mat-label>\n <mat-select disableRipple [(ngModel)]=\"game.author\" name=\"author\" required>\n <mat-option *ngFor=\"let author of authors\" [value]=\"author\">{{author.name}}</mat-option>\n </mat-select>\n <mat-error>El autor no puede estar vac\u00edo</mat-error>\n </mat-form-field>\n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onClose()\">Cerrar</button>\n <button mat-flat-button color=\"primary\" (click)=\"onSave()\">Guardar</button>\n </div>\n</div>\n
.container {\nmin-width: 350px;\nmax-width: 500px;\npadding: 20px;\n\nform {\ndisplay: flex;\nflex-direction: column;\nmargin-bottom:20px;\n}\n\n.buttons {\ntext-align: right;\n\nbutton {\nmargin-left: 10px;\n}\n}\n}\n
import { Component, Inject, OnInit } from '@angular/core';\nimport { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';\nimport { AuthorService } from 'src/app/author/author.service';\nimport { Author } from 'src/app/author/model/Author';\nimport { CategoryService } from 'src/app/category/category.service';\nimport { Category } from 'src/app/category/model/Category';\nimport { GameService } from '../game.service';\nimport { Game } from '../model/Game';\n\n@Component({\nselector: 'app-game-edit',\ntemplateUrl: './game-edit.component.html',\nstyleUrls: ['./game-edit.component.scss']\n})\nexport class GameEditComponent implements OnInit {\n\ngame: Game; authors: Author[];\ncategories: Category[];\n\nconstructor(\npublic dialogRef: MatDialogRef<GameEditComponent>,\n@Inject(MAT_DIALOG_DATA) public data: any,\nprivate gameService: GameService,\nprivate categoryService: CategoryService,\nprivate authorService: AuthorService,\n) { }\n\nngOnInit(): void {\nif (this.data.game != null) {\nthis.game = Object.assign({}, this.data.game);\n}\nelse {\nthis.game = new Game();\n}\n\nthis.categoryService.getCategories().subscribe(\ncategories => {\nthis.categories = categories;\n\nif (this.game.category != null) {\nlet categoryFilter: Category[] = categories.filter(category => category.id == this.data.game.category.id);\nif (categoryFilter != null) {\nthis.game.category = categoryFilter[0];\n}\n}\n}\n);\n\nthis.authorService.getAllAuthors().subscribe(\nauthors => {\nthis.authors = authors\n\nif (this.game.author != null) {\nlet authorFilter: Author[] = authors.filter(author => author.id == this.data.game.author.id);\nif (authorFilter != null) {\nthis.game.author = authorFilter[0];\n}\n}\n}\n);\n}\n\nonSave() {\nthis.gameService.saveGame(this.game).subscribe(result => {\nthis.dialogRef.close();\n}); } onClose() {\nthis.dialogRef.close();\n}\n\n}\n
Como puedes ver, para rellenar los componentes seleccionables de dropdown, hemos realizado una consulta al servicio para recuperar todos los autores y categorias, y en la respuesta de cada uno de ellos, hemos buscado en los resultados cual es el que coincide con el ID enviado desde el listado, y ese es el que hemos fijado en el objeto Game
.
De esta forma, no estamos cogiendo directamente los datos del listado, sino que no estamos asegurando que los datos de autor y de categor\u00eda son los que vienen del servicio, siempre filtrando por su ID.
"},{"location":"develop/filtered/angular/#conectar-con-backend","title":"Conectar con Backend","text":"Antes de seguir
Antes de seguir con este punto, debes implementar el c\u00f3digo de backend en la tecnolog\u00eda que quieras (Springboot o Nodejs). Si has empezado este cap\u00edtulo implementando el frontend, por favor accede a la secci\u00f3n correspondiente de backend para poder continuar con el tutorial. Una vez tengas implementadas todas las operaciones para este listado, puedes volver a este punto y continuar con Angular.
Una vez implementado front y back, lo que nos queda es modificar el servicio del front para que conecte directamente con las operaciones ofrecidas por el back.
author-service.tsgame-service.tsimport { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Pageable } from '../core/model/page/Pageable';\nimport { Author } from './model/Author';\nimport { AuthorPage } from './model/AuthorPage';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class AuthorService {\n\nconstructor(\nprivate http: HttpClient\n) { }\n\ngetAuthors(pageable: Pageable): Observable<AuthorPage> {\nreturn this.http.post<AuthorPage>('http://localhost:8080/author', {pageable:pageable});\n}\n\nsaveAuthor(author: Author): Observable<void> {\n\nlet url = 'http://localhost:8080/author';\nif (author.id != null) url += '/'+author.id;\n\nreturn this.http.put<void>(url, author);\n}\n\ndeleteAuthor(idAuthor : number): Observable<void> {\nreturn this.http.delete<void>('http://localhost:8080/author/'+idAuthor);\n} getAllAuthors(): Observable<Author[]> {\nreturn this.http.get<Author[]>('http://localhost:8080/author');\n}\n}\n
import { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Game } from './model/Game';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class GameService {\n\nconstructor(\nprivate http: HttpClient\n) { }\n\ngetGames(title?: String, categoryId?: number): Observable<Game[]> { return this.http.get<Game[]>(this.composeFindUrl(title, categoryId));\n}\n\nsaveGame(game: Game): Observable<void> {\nlet url = 'http://localhost:8080/game';\nif (game.id != null) {\nurl += '/'+game.id;\n}\nreturn this.http.put<void>(url, game);\n}\n\nprivate composeFindUrl(title?: String, categoryId?: number) : string {\nlet params = '';\nif (title != null) {\nparams += 'title='+title;\n}\nif (categoryId != null) {\nif (params != '') params += \"&\";\nparams += \"idCategory=\"+categoryId;\n}\nlet url = 'http://localhost:8080/game'\nif (params == '') return url;\nelse return url + '?'+params;\n}\n}\n
Y ahora si, podemos navegar por la web y ver el resultado completo.
"},{"location":"develop/filtered/nodejs/","title":"Listado simple - Nodejs","text":"En este punto ya tenemos dos listados, uno b\u00e1sico y otro paginado. Ahora vamos a implementar un listado un poco diferente, este listado va a tener filtros de b\u00fasqueda.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/filtered/nodejs/#crear-modelos","title":"Crear Modelos","text":"Lo primero que vamos a hacer es crear el modelo de author para trabajar con BBDD. En la carpeta schemas creamos el archivo game.schema.js
:
import mongoose from \"mongoose\";\nconst { Schema, model } = mongoose;\nimport normalize from 'normalize-mongoose';\n\nconst gameSchema = new Schema({\ntitle: {\ntype: String,\nrequire: true\n},\nage: {\ntype: Number,\nrequire: true,\nmax: 99,\nmin: 0\n},\ncategory: {\ntype: Schema.Types.ObjectId,\nref: 'Category',\nrequired: true\n},\nauthor: {\ntype: Schema.Types.ObjectId,\nref: 'Author',\nrequired: true\n}\n});\n\ngameSchema.plugin(normalize);\nconst GameModel = model('Game', gameSchema);\n\nexport default GameModel;\n
Lo m\u00e1s novedoso aqu\u00ed es que ahora cada juego va a tener una categor\u00eda y un autor asociados. Para ello simplemente en el tipo del dato Category
y Author
tenemos que hacer referencia al id del esquema deseado.
Creamos el service correspondiente game.service.js
:
import GameModel from '../schemas/game.schema.js';\n\nexport const getGames = async (title, category) => {\ntry {\nconst regexTitle = new RegExp(title, 'i');\nconst find = category? { $and: [{ title: regexTitle }, { category: category }] } : { title: regexTitle };\nreturn await GameModel.find(find).sort('id').populate('category').populate('author');\n} catch(e) {\nthrow Error('Error fetching games');\n}\n}\n\nexport const createGame = async (data) => {\ntry {\nconst game = new GameModel({\n...data,\ncategory: data.category.id,\nauthor: data.author.id,\n});\nreturn await game.save();\n} catch (e) {\nthrow Error('Error creating game');\n}\n}\n\nexport const updateGame = async (id, data) => {\ntry {\nconst game = await GameModel.findById(id);\nif (!game) {\nthrow Error('There is no game with that Id');\n} const gameToUpdate = {\n...data,\ncategory: data.category.id,\nauthor: data.author.id,\n};\nreturn await GameModel.findByIdAndUpdate(id, gameToUpdate, { new: false });\n} catch (e) {\nthrow Error(e);\n}\n}\n
En este caso recibimos en el m\u00e9todo para recuperar juegos dos par\u00e1metros, el titulo del juego y la categor\u00eda. Aqu\u00ed vamos a utilizar una expresi\u00f3n regular para que podamos encontrar cualquier juego que contenga el titulo que pasemos en su nombre. Con la categor\u00eda tiene que ser el valor exacto de su id. El m\u00e9todo populate lo que hace es traernos toda la informaci\u00f3n de la categor\u00eda y del autor. Sino lo us\u00e1semos solo nos recuperar\u00eda el id.
"},{"location":"develop/filtered/nodejs/#implementar-el-controller","title":"Implementar el Controller","text":"Creamos el controlador game.controller.js
:
import * as GameService from '../services/game.service.js';\n\nexport const getGames = async (req, res) => {\ntry {\nconst titleToFind = req.query?.title || '';\nconst categoryToFind = req.query?.idCategory || null;\nconst games = await GameService.getGames(titleToFind, categoryToFind);\nres.status(200).json(games);\n} catch(err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const createGame = async (req, res) => {\ntry {\nconst game = await GameService.createGame(req.body);\nres.status(200).json({\ngame\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const updateGame = async (req, res) => {\nconst gameId = req.params.id;\ntry {\nawait GameService.updateGame(gameId, req.body);\nres.status(200).json(1);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Los m\u00e9todos son muy parecidos al resto de los controllers. En este caso para recuperar los datos del filtro tendremos que hacerlo con req.query
para leer los datos que nos lleguen como query params en la url. Por ejemplo: http://localhost:8080/game?title=trivial&category=1
Y por \u00faltimo creamos nuestro archivo de rutas game.routes.js
:
import { Router } from 'express';\nimport { check } from 'express-validator';\nimport validateFields from '../middlewares/validateFields.js';\nimport { createGame, getGames, updateGame } from '../controllers/game.controller.js';\nconst gameRouter = Router();\n\ngameRouter.put('/:id', [\ncheck('title').not().isEmpty(),\ncheck('age').not().isEmpty(),\ncheck('age').isNumeric(),\ncheck('category.id').not().isEmpty(),\ncheck('author.id').not().isEmpty(),\nvalidateFields\n], updateGame);\n\ngameRouter.put('/', [\ncheck('title').not().isEmpty(),\ncheck('age').not().isEmpty(),\ncheck('age').isNumeric(),\ncheck('category.id').not().isEmpty(),\ncheck('author.id').not().isEmpty(),\nvalidateFields\n], createGame);\n\ngameRouter.get('/', getGames);\ngameRouter.get('/:query', getGames);\n\nexport default gameRouter;\n
En este caso hemos tenido que meter dos rutas para get
, una para cuando se informen los filtros y otra para cuando no vayan informados. Si lo hici\u00e9ramos con una \u00fanica ruta nos fallar\u00eda en el otro caso.
Finalmente en nuestro archivo index.js
vamos a a\u00f1adir el nuevo router:
...\n\nimport gameRouter from './src/routes/game.routes.js';\n\n...\n\napp.use('/game', gameRouter);\n\n...\n
"},{"location":"develop/filtered/nodejs/#probar-las-operaciones","title":"Probar las operaciones","text":"Y ahora que tenemos todo creado, ya podemos probarlo con Postman:
Por un lado creamos juegos con:
** PUT /game **
** PUT /game/{id} **
{\n\"title\": \"Nuevo juego\",\n\"age\": \"18\",\n\"category\": {\n\"id\": \"63e8b795f7dae4b980b63202\"\n},\n\"author\": {\n\"id\": \"63e8bda064c208e065667bfa\"\n}\n}\n
Tambi\u00e9n podemos filtrar y recuperar informaci\u00f3n:
** GET /game **
** GET /game?title=xxx **
** GET /game?idCategory=xxx **
"},{"location":"develop/filtered/nodejs/#implementar-validaciones","title":"Implementar validaciones","text":"Ahora que ya tenemos todos nuestros CRUDs creados vamos a introducir unas peque\u00f1as validaciones.
"},{"location":"develop/filtered/nodejs/#validacion-en-borrado","title":"Validaci\u00f3n en borrado","text":"La primera validaci\u00f3n sera para que no podamos borrar categor\u00edas ni autores que tengan un juego asociado. Para ello primero tendremos que crear un m\u00e9todo en el servicio de juegos para buscar los juegos que correspondan con un campo dado. En game.service.js
a\u00f1adimos:
...\nexport const getGame = async (field) => {\ntry {\nreturn await GameModel.find(field);\n} catch (e) {\nthrow Error('Error fetching games');\n}\n}\n...\n
Y ahora en category.service.js
importamos el m\u00e9todo creado y modificamos el m\u00e9todo para borrar categor\u00edas:
...\nimport { getGame } from './game.service.js';\n...\n\n...\nexport const deleteCategory = async (id) => {\ntry {\nconst category = await CategoryModel.findById(id);\nif (!category) {\nthrow 'There is no category with that Id';\n}\nconst games = await getGame({category});\nif(games.length > 0) {\nthrow 'There are games related to this category';\n}\nreturn await CategoryModel.findByIdAndDelete(id);\n} catch (e) {\nthrow Error(e);\n}\n}\n...\n
De este modo si encontramos alg\u00fan juego con esta categor\u00eda no nos dejar\u00e1 borrarla.
Por \u00faltimo, hacemos lo mismo en author.service.js
:
...\nimport { getGame } from './game.service.js';\n...\n\n...\nexport const deleteAuthor = async (id) => {\ntry {\nconst author = await AuthorModel.findById(id);\nif (!author) {\nthrow 'There is no author with that Id';\n}\nconst games = await getGame({author});\nif(games.length > 0) {\nthrow 'There are games related to this author';\n}\nreturn await AuthorModel.findByIdAndDelete(id);\n} catch (e) {\nthrow Error(e);\n}\n}\n...\n
"},{"location":"develop/filtered/nodejs/#validacion-en-creacion","title":"Validaci\u00f3n en creaci\u00f3n","text":"En las creaciones es conveniente validad la existencia de las entidades relacionadas para garantizar la integridad de la BBDD.
Para esto vamos a introducir una validaci\u00f3n en la creaci\u00f3n y edici\u00f3n de los juegos para garantizar que la categor\u00eda y el autor proporcionados existen.
En primer lugar vamos a crear los servicios de consulta de categor\u00eda y autor:
category.service.js...\nexport const getCategory = async (id) => {\ntry {\nreturn await CategoryModel.findById(id);\n} catch (e) {\nthrow Error('There is no category with that Id');\n}\n}\n...\n
author.service.js ...\nexport const getAuthor = async (id) => {\ntry {\nreturn await AuthorModel.findById(id);\n} catch (e) {\nthrow Error('There is no author with that Id');\n}\n}\n...\n
Teniendo los servicios ya disponibles, vamos a a\u00f1adir las validaciones a los servicios de creaci\u00f3n y edici\u00f3n:
game.service.js...\nimport { getCategory } from './category.service.js';\nimport { getAuthor } from './author.service.js';\n...\n\n...\nexport const createGame = async (data) => {\ntry {\nconst category = await getCategory(data.category.id);\nif (!category) {\nthrow Error('There is no category with that Id');\n}\n\nconst author = await getAuthor(data.author.id);\nif (!author) {\nthrow Error('There is no author with that Id');\n}\n\nconst game = new GameModel({\n...data,\ncategory: data.category.id,\nauthor: data.author.id,\n});\nreturn await game.save();\n} catch (e) {\nthrow Error(e);\n}\n}\n...\n\n...\nexport const updateGame = async (id, data) => {\ntry {\nconst game = await GameModel.findById(id);\nif (!game) {\nthrow Error('There is no game with that Id');\n}\n\nconst category = await getCategory(data.category.id);\nif (!category) {\nthrow Error('There is no category with that Id');\n}\n\nconst author = await getAuthor(data.author.id);\nif (!author) {\nthrow Error('There is no author with that Id');\n}\n\nconst gameToUpdate = {\n...data,\ncategory: data.category.id,\nauthor: data.author.id,\n};\nreturn await GameModel.findByIdAndUpdate(id, gameToUpdate, { new: false });\n} catch (e) {\nthrow Error(e);\n}\n}\n...\n
Con esto ya tendr\u00edamos acabado nuestro CRUD.
"},{"location":"develop/filtered/react/","title":"Listado filtrado - Angular","text":"En este punto ya tenemos dos listados, uno b\u00e1sico y otro paginado. Ahora vamos a implementar un listado un poco diferente, con filtros y con una presentaci\u00f3n un tanto distinta.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
Vamos a desarrollar el listado de Juegos
. Este listado es un tanto peculiar, porque no tiene una tabla como tal, sino que vamos a mostrar los juegos como cards. Ya tenemos creado nuestro componentes pagina pero vamos a necesitar un componente para mostrar cada uno de los juegos y otro para crear y editar los juegos.
Manos a la obra:
Creamos el fichero Game.ts
dentro de la carpeta types
:
import { Category } from \"./Category\";\nimport { Author } from \"./Author\";\n\nexport interface Game {\nid: string;\ntitle: string;\nage: number;\ncategory?: Category;\nauthor?: Author;\n}\n
Modificamos nuestra api de Toolkit
para a\u00f1adir los endpoints
de juegos y aparte creamos un endpoint
para recuperar los autores que necesitaremos para crear un nuevo juego, el fichero completo quedar\u00eda de esta manera:
import { createApi, fetchBaseQuery } from \"@reduxjs/toolkit/query/react\";\nimport { Game } from \"../../types/Game\";\nimport { Category } from \"../../types/Category\";\nimport { Author, AuthorResponse } from \"../../types/Author\";\n\nexport const ludotecaAPI = createApi({\nreducerPath: \"ludotecaApi\",\nbaseQuery: fetchBaseQuery({\nbaseUrl: \"http://localhost:8080\",\n}),\ntagTypes: [\"Category\", \"Author\", \"Game\"],\nendpoints: (builder) => ({\ngetCategories: builder.query<Category[], null>({\nquery: () => \"category\",\nprovidesTags: [\"Category\"],\n}),\ncreateCategory: builder.mutation({\nquery: (payload) => ({\nurl: \"/category\",\nmethod: \"PUT\",\nbody: payload,\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Category\"],\n}),\ndeleteCategory: builder.mutation({\nquery: (id: string) => ({\nurl: `/category/${id}`,\nmethod: \"DELETE\",\n}),\ninvalidatesTags: [\"Category\"],\n}),\nupdateCategory: builder.mutation({\nquery: (payload: Category) => ({\nurl: `category/${payload.id}`,\nmethod: \"PUT\",\nbody: payload,\n}),\ninvalidatesTags: [\"Category\"],\n}),\ngetAllAuthors: builder.query<Author[], null>({\nquery: () => \"author\",\nprovidesTags: [\"Author\"],\n}),\ngetAuthors: builder.query<\nAuthorResponse,\n{ pageNumber: number; pageSize: number }\n>({\nquery: ({ pageNumber, pageSize }) => {\nreturn {\nurl: \"author/\",\nmethod: \"POST\",\nbody: {\npageable: {\npageNumber,\npageSize,\n},\n},\n};\n},\nprovidesTags: [\"Author\"],\n}),\ncreateAuthor: builder.mutation({\nquery: (payload) => ({\nurl: \"/author\",\nmethod: \"PUT\",\nbody: payload,\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Author\"],\n}),\ndeleteAuthor: builder.mutation({\nquery: (id: string) => ({\nurl: `/author/${id}`,\nmethod: \"DELETE\",\n}),\ninvalidatesTags: [\"Author\"],\n}),\nupdateAuthor: builder.mutation({\nquery: (payload: Author) => ({\nurl: `author/${payload.id}`,\nmethod: \"PUT\",\nbody: payload,\n}),\ninvalidatesTags: [\"Author\", \"Game\"],\n}),\ngetGames: builder.query<Game[], { title: string; idCategory: string }>({\nquery: ({ title, idCategory }) => {\nreturn {\nurl: \"game/\",\nparams: { title, idCategory },\n};\n},\nprovidesTags: [\"Game\"],\n}),\ncreateGame: builder.mutation({\nquery: (payload: Game) => ({\nurl: \"/game\",\nmethod: \"PUT\",\nbody: { ...payload },\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Game\"],\n}),\nupdateGame: builder.mutation({\nquery: (payload: Game) => ({\nurl: `game/${payload.id}`,\nmethod: \"PUT\",\nbody: { ...payload },\n}),\ninvalidatesTags: [\"Game\"],\n}),\n\n}),\n});\n\nexport const {\nuseGetCategoriesQuery,\nuseCreateCategoryMutation,\nuseDeleteCategoryMutation,\nuseUpdateCategoryMutation,\nuseCreateAuthorMutation,\nuseDeleteAuthorMutation,\nuseGetAllAuthorsQuery,\nuseGetAuthorsQuery,\nuseUpdateAuthorMutation,\nuseCreateGameMutation,\nuseGetGamesQuery,\nuseUpdateGameMutation\n} = ludotecaAPI;\n
Creamos una nueva carpeta components
dentro de src/pages/Game
y dentro creamos un archivo llamado CreateGame.tsx
con el siguiente contenido:
import { ChangeEvent, useContext, useEffect, useState } from \"react\";\nimport Button from \"@mui/material/Button\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport TextField from \"@mui/material/TextField\";\nimport Dialog from \"@mui/material/Dialog\";\nimport DialogActions from \"@mui/material/DialogActions\";\nimport DialogContent from \"@mui/material/DialogContent\";\nimport DialogTitle from \"@mui/material/DialogTitle\";\nimport {\nuseGetAllAuthorsQuery,\nuseGetCategoriesQuery,\n} from \"../../../redux/services/ludotecaApi\";\nimport { LoaderContext } from \"../../../context/LoaderProvider\";\nimport { Game } from \"../../../types/Game\";\nimport { Category } from \"../../../types/Category\";\nimport { Author } from \"../../../types/Author\";\n\ninterface Props {\ngame: Game | null;\ncloseModal: () => void;\ncreate: (game: Game) => void;\n}\n\nconst initialState = {\nid: \"\",\ntitle: \"\",\nage: 0,\ncategory: undefined,\nauthor: undefined,\n};\n\nexport default function CreateGame(props: Props) {\nconst [form, setForm] = useState<Game>(initialState);\nconst loader = useContext(LoaderContext);\nconst { data: categories, isLoading: isLoadingCategories } =\nuseGetCategoriesQuery(null);\nconst { data: authors, isLoading: isLoadingAuthors } =\nuseGetAllAuthorsQuery(null);\n\nuseEffect(() => {\nsetForm({\nid: props.game?.id || \"\",\ntitle: props.game?.title || \"\",\nage: props.game?.age || 0,\ncategory: props.game?.category,\nauthor: props.game?.author,\n});\n}, [props?.game]);\n\nuseEffect(() => {\nloader.showLoading(isLoadingCategories || isLoadingAuthors);\n}, [isLoadingCategories, isLoadingAuthors]);\n\nconst handleChangeForm = (\nevent: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>\n) => {\nsetForm({\n...form,\n[event.target.id]: event.target.value,\n});\n};\n\nconst handleChangeSelect = (\nevent: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>\n) => {\nconst values = event.target.name === \"category\" ? categories : authors;\nsetForm({\n...form,\n[event.target.name]: values?.find((val) => val.id === event.target.value),\n});\n};\n\nreturn (\n<div>\n<Dialog open={true} onClose={props.closeModal}>\n<DialogTitle>\n{props.game ? \"Actualizar Juego\" : \"Crear Juego\"}\n</DialogTitle>\n<DialogContent>\n{props.game && (\n<TextField\nmargin=\"dense\"\ndisabled\nid=\"id\"\nlabel=\"Id\"\nfullWidth\nvalue={props.game.id}\nvariant=\"standard\"\n/>\n)}\n<TextField\nmargin=\"dense\"\nid=\"title\"\nlabel=\"Titulo\"\nfullWidth\nvariant=\"standard\"\nonChange={handleChangeForm}\nvalue={form.title}\n/>\n<TextField\nmargin=\"dense\"\nid=\"age\"\nlabel=\"Edad Recomendada\"\nfullWidth\ntype=\"number\"\nvariant=\"standard\"\nonChange={handleChangeForm}\nvalue={form.age}\n/>\n<TextField\nid=\"category\"\nselect\nlabel=\"Categor\u00eda\"\ndefaultValue=\"''\"\nfullWidth\nvariant=\"standard\"\nname=\"category\"\nvalue={form.category ? form.category.id : \"\"}\nonChange={handleChangeSelect}\n>\n{categories &&\ncategories.map((option: Category) => (\n<MenuItem key={option.id} value={option.id}>\n{option.name}\n</MenuItem>\n))}\n</TextField>\n<TextField\nid=\"author\"\nselect\nlabel=\"Autor\"\ndefaultValue=\"''\"\nfullWidth\nvariant=\"standard\"\nname=\"author\"\nvalue={form.author ? form.author.id : \"\"}\nonChange={handleChangeSelect}\n>\n{authors &&\nauthors.map((option: Author) => (\n<MenuItem key={option.id} value={option.id}>\n{option.name}\n</MenuItem>\n))}\n</TextField>\n</DialogContent>\n<DialogActions>\n<Button onClick={props.closeModal}>Cancelar</Button>\n<Button\nonClick={() =>\nprops.create({\nid: \"\",\ntitle: form.title,\nage: form.age,\ncategory: form.category,\nauthor: form.author,\n})\n}\ndisabled={\n!form.title || !form.age || !form.category || !form.author\n}\n>\n{props.game ? \"Actualizar\" : \"Crear\"}\n</Button>\n</DialogActions>\n</Dialog>\n</div>\n);\n}\n
Ahora en esa misma carpeta crearemos el componente GameCard.tsx
para mostrar nuestros juegos con un dise\u00f1o de carta:
import Card from \"@mui/material/Card\";\nimport CardContent from \"@mui/material/CardContent\";\nimport CardMedia from \"@mui/material/CardMedia\";\nimport CardHeader from \"@mui/material/CardHeader\";\nimport List from \"@mui/material/List\";\nimport ListItem from \"@mui/material/ListItem\";\nimport ListItemAvatar from \"@mui/material/ListItemAvatar\";\nimport ListItemText from \"@mui/material/ListItemText\";\nimport Avatar from \"@mui/material/Avatar\";\nimport PersonIcon from \"@mui/icons-material/Person\";\nimport LanguageIcon from \"@mui/icons-material/Language\";\nimport CardActionArea from \"@mui/material/CardActionArea\";\nimport red from \"@mui/material/colors/red\";\nimport imageGame from \"./../../../assets/foto.png\";\nimport { Game } from \"../../../types/Game\";\n\ninterface GameCardProps {\ngame: Game;\n}\n\nexport default function GameCard(props: GameCardProps) {\nconst { title, age, category, author } = props.game;\nreturn (\n<Card sx={{ maxWidth: 265 }}>\n<CardHeader\nsx={{\n\".MuiCardHeader-title\": {\nfontSize: \"20px\",\n},\n}}\navatar={\n<Avatar sx={{ bgcolor: red[500] }} aria-label=\"age\">\n+{age}\n</Avatar>\n}\ntitle={title}\nsubheader={category?.name}\n/>\n<CardActionArea>\n<CardMedia\ncomponent=\"img\"\nheight=\"140\"\nimage={imageGame}\nalt=\"game image\"\n/>\n<CardContent>\n<List dense={true}>\n<ListItem>\n<ListItemAvatar>\n<Avatar>\n<PersonIcon />\n</Avatar>\n</ListItemAvatar>\n<ListItemText primary={`Autor: ${author?.name}`} />\n</ListItem>\n<ListItem>\n<ListItemAvatar>\n<Avatar>\n<LanguageIcon />\n</Avatar>\n</ListItemAvatar>\n<ListItemText primary={`Nacionalidad: ${author?.nationality}`} />\n</ListItem>\n</List>\n</CardContent>\n</CardActionArea>\n</Card>\n);\n}\n
En la carpeta src/pages/game
vamos a crear un fichero para los estilos llamado Game.module.css
:
.filter {\ndisplay: flex;\nalign-items: center;\n}\n\n.cards {\ndisplay: flex;\ngap: 20px;\npadding: 10px;\nflex-wrap: wrap;\n}\n\n.card {\ncursor: pointer;\n}\n\n@media (max-width: 800px) {\n.cards {\ndisplay: flex;\nflex-direction: column;\nalign-items: center;\n}\n\n.filter {\ndisplay: flex;\nflex-direction: column;\n}\n}\n
Y por \u00faltimo modificamos nuestro componente p\u00e1gina Game
y lo dejamos de esta manera:
import { useState, useContext, useEffect } from \"react\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport FormControl from \"@mui/material/FormControl\";\nimport TextField from \"@mui/material/TextField\";\nimport Button from \"@mui/material/Button\";\nimport GameCard from \"./components/GameCard\";\nimport styles from \"./Game.module.css\";\nimport {\nuseCreateGameMutation,\nuseGetCategoriesQuery,\nuseGetGamesQuery,\nuseUpdateGameMutation,\n} from \"../../redux/services/ludotecaApi\";\nimport CreateGame from \"./components/CreateGame\";\nimport { LoaderContext } from \"../../context/LoaderProvider\";\nimport { useAppDispatch } from \"../../redux/hooks\";\nimport { setMessage } from \"../../redux/features/messagesSlice\";\nimport { Game as GameModel } from \"../../types/Game\";\nimport { Category } from \"../../types/Category\";\n\nexport const Game = () => {\nconst [openCreate, setOpenCreate] = useState(false);\nconst [filterTitle, setFilterTitle] = useState(\"\");\nconst [filterCategory, setFilterCategory] = useState(\"\");\nconst [gameToUpdate, setGameToUpdate] = useState<GameModel | null>(null);\nconst loader = useContext(LoaderContext);\nconst dispatch = useAppDispatch();\n\nconst { data, error, isLoading, isFetching } = useGetGamesQuery({\ntitle: filterTitle,\nidCategory: filterCategory,\n});\n\nconst [updateGameApi, { isLoading: isLoadingUpdate, error: errorUpdate }] =\nuseUpdateGameMutation();\n\nconst { data: categories } = useGetCategoriesQuery(null);\n\nconst [createGameApi, { isLoading: isLoadingCreate, error: errorCreate }] =\nuseCreateGameMutation();\n\nuseEffect(() => {\nloader.showLoading(\nisLoadingCreate || isLoadingUpdate || isLoading || isFetching\n);\n}, [isLoadingCreate, isLoadingUpdate, isLoading, isFetching]);\n\nuseEffect(() => {\nif (errorCreate || errorUpdate) {\nsetMessage({\ntext: \"Se ha producido un error al realizar la operaci\u00f3n\",\ntype: \"error\",\n});\n}\n}, [errorUpdate, errorCreate]);\n\nif (error) return <p>Error cargando!!!</p>;\n\nconst createGame = (game: GameModel) => {\nsetOpenCreate(false);\nif (gameToUpdate) {\nupdateGameApi({\n...game,\nid: gameToUpdate.id,\n})\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Juego actualizado correctamente\",\ntype: \"ok\",\n})\n);\nsetGameToUpdate(null);\n})\n.catch((err) => console.log(err));\n} else {\ncreateGameApi(game)\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Juego creado correctamente\",\ntype: \"ok\",\n})\n);\nsetGameToUpdate(null);\n})\n.catch((err) => console.log(err));\n}\n};\n\nreturn (\n<div className=\"container\">\n<h1>Cat\u00e1logo de juegos</h1>\n<div className={styles.filter}>\n<FormControl variant=\"standard\" sx={{ m: 1, minWidth: 220 }}>\n<TextField\nmargin=\"dense\"\nid=\"title\"\nlabel=\"Titulo\"\nfullWidth\nvalue={filterTitle}\nvariant=\"standard\"\nonChange={(event) => setFilterTitle(event.target.value)}\n/>\n</FormControl>\n<FormControl variant=\"standard\" sx={{ m: 1, minWidth: 220 }}>\n<TextField\nid=\"category\"\nselect\nlabel=\"Categor\u00eda\"\ndefaultValue=\"''\"\nfullWidth\nvariant=\"standard\"\nname=\"author\"\nvalue={filterCategory}\nonChange={(event) => setFilterCategory(event.target.value)}\n>\n{categories &&\ncategories.map((option: Category) => (\n<MenuItem key={option.id} value={option.id}>\n{option.name}\n</MenuItem>\n))}\n</TextField>\n</FormControl>\n<Button\nvariant=\"outlined\"\nonClick={() => {\nsetFilterCategory(\"\");\nsetFilterTitle(\"\");\n}}\n>\nLimpiar\n</Button>\n</div>\n<div className={styles.cards}>\n{data?.map((card) => (\n<div\nkey={card.id}\nclassName={styles.card}\nonClick={() => {\nsetGameToUpdate(card);\nsetOpenCreate(true);\n}}\n>\n<GameCard game={card} />\n</div>\n))}\n</div>\n<div className=\"newButton\">\n<Button variant=\"contained\" onClick={() => setOpenCreate(true)}>\nNuevo juego\n</Button>\n</div>\n{openCreate && (\n<CreateGame\ncreate={createGame}\ngame={gameToUpdate}\ncloseModal={() => {\nsetGameToUpdate(null);\nsetOpenCreate(false);\n}}\n/>\n)}\n</div>\n);\n};\n
Y por \u00faltimo descargamos la siguiente imagen y la guardamos en la carpeta src/assets
.
En este listado realizamos el filtro de manera din\u00e1mica, en el momento en que cambiamos el valor de la categor\u00eda o el t\u00edtulo a filtrar, como estas variables est\u00e1n asociadas al estado de nuestro componente, se vuelve a renderizar y por lo tanto se actualiza el valor de \"data\" modificando as\u00ed los resultados.
El resto es muy parecido a lo que ya hemos realizado antes. Aqu\u00ed no tenemos una tabla, sino que mostramos nuestros juegos como Cards y si pulsamos sobre cualquier Card se mostrar\u00e1 el formulario de edici\u00f3n del juego.
Si ahora arrancamos el proyecto y nos vamos a la pagina de juegos podremos crear y ver nuestros juegos.
"},{"location":"develop/filtered/springboot/","title":"Listado filtrado - Spring Boot","text":"En este punto ya tenemos dos listados, uno b\u00e1sico y otro paginado. Ahora vamos a implementar un listado un poco diferente, este listado va a tener filtros de b\u00fasqueda.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/filtered/springboot/#crear-modelos","title":"Crear Modelos","text":"Lo primero que vamos a hacer es crear los modelos para trabajar con BBDD y con peticiones hacia el front. Adem\u00e1s, tambi\u00e9n tenemos que a\u00f1adir datos al script de inicializaci\u00f3n de BBDD.
Game.javaGameDto.javadata.sqlpackage com.ccsw.tutorial.game.model;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.category.model.Category;\n\nimport jakarta.persistence.*;\n\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"game\")\npublic class Game {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"title\", nullable = false)\nprivate String title;\n\n@Column(name = \"age\", nullable = false)\nprivate String age;\n\n@ManyToOne\n@JoinColumn(name = \"category_id\", nullable = false)\nprivate Category category;\n\n@ManyToOne\n@JoinColumn(name = \"author_id\", nullable = false)\nprivate Author author;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return category\n */\npublic Category getCategory() {\n\nreturn this.category;\n}\n\n/**\n * @param category new value of {@link #getCategory}.\n */\npublic void setCategory(Category category) {\n\nthis.category = category;\n}\n\n/**\n * @return author\n */\npublic Author getAuthor() {\n\nreturn this.author;\n}\n\n/**\n * @param author new value of {@link #getAuthor}.\n */\npublic void setAuthor(Author author) {\n\nthis.author = author;\n}\n\n}\n
package com.ccsw.tutorial.game.model;\n\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\n/**\n * @author ccsw\n *\n */\npublic class GameDto {\n\nprivate Long id;\n\nprivate String title;\n\nprivate String age;\n\nprivate CategoryDto category;\n\nprivate AuthorDto author;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return category\n */\npublic CategoryDto getCategory() {\n\nreturn this.category;\n}\n\n/**\n * @param category new value of {@link #getCategory}.\n */\npublic void setCategory(CategoryDto category) {\n\nthis.category = category;\n}\n\n/**\n * @return author\n */\npublic AuthorDto getAuthor() {\n\nreturn this.author;\n}\n\n/**\n * @param author new value of {@link #getAuthor}.\n */\npublic void setAuthor(AuthorDto author) {\n\nthis.author = author;\n}\n\n}\n
INSERT INTO category(name) VALUES ('Eurogames');\nINSERT INTO category(name) VALUES ('Ameritrash');\nINSERT INTO category(name) VALUES ('Familiar');\n\nINSERT INTO author(name, nationality) VALUES ('Alan R. Moon', 'US');\nINSERT INTO author(name, nationality) VALUES ('Vital Lacerda', 'PT');\nINSERT INTO author(name, nationality) VALUES ('Simone Luciani', 'IT');\nINSERT INTO author(name, nationality) VALUES ('Perepau Llistosella', 'ES');\nINSERT INTO author(name, nationality) VALUES ('Michael Kiesling', 'DE');\nINSERT INTO author(name, nationality) VALUES ('Phil Walker-Harding', 'US');\n\nINSERT INTO game(title, age, category_id, author_id) VALUES ('On Mars', '14', 1, 2);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Aventureros al tren', '8', 3, 1);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('1920: Wall Street', '12', 1, 4);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Barrage', '14', 1, 3);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Los viajes de Marco Polo', '12', 1, 3);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Azul', '8', 3, 5);\n
Relaciones anidadas
F\u00edjate que tanto la Entity
como el Dto
tienen relaciones con Author
y Category
. Gracias a Spring JPA se pueden resolver de esta forma y tener toda la informaci\u00f3n de las relaciones hijas dentro del objeto padre. Muy importante recordar que en el mundo entity las relaciones ser\u00e1n con objetos Entity
mientras que en el mundo dto las relaciones deben ser siempre con objetos Dto
. La utilidad beanMapper ya har\u00e1 las conversiones necesarias, siempre que tengan el mismo nombre de propiedades.
Para desarrollar todas las operaciones, empezaremos primero dise\u00f1ando las pruebas y luego implementando el c\u00f3digo necesario que haga funcionar correctamente esas pruebas. Para ir m\u00e1s r\u00e1pido vamos a poner todas las pruebas de golpe, pero realmente se deber\u00edan crear una a una e ir implementando el c\u00f3digo necesario para esa prueba. Para evitar tantas iteraciones en el tutorial las haremos todas de golpe.
Vamos a pararnos a pensar un poco que necesitamos en la pantalla. En este caso solo tenemos dos operaciones:
De nuevo tendremos que desglosar esto en varios casos de prueba:
Tambi\u00e9n crearemos una clase GameController
dentro del package de com.ccsw.tutorial.game
con la implementaci\u00f3n de los m\u00e9todos vac\u00edos, para que no falle la compilaci\u00f3n.
\u00a1Vamos a implementar test!
GameController.javaGameIT.javapackage com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Game\", description = \"API of Game\")\n@RequestMapping(value = \"/game\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class GameController {\n\n/**\n * M\u00e9todo para recuperar una lista de {@link Game}\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link GameDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a filtered list of Games\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<GameDto> find(@RequestParam(value = \"title\", required = false) String title,\n@RequestParam(value = \"idCategory\", required = false) Long idCategory) {\n\nreturn null;\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Game}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Game\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody GameDto dto) {\n\n}\n\n}\n
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.web.client.TestRestTemplate;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.http.HttpEntity;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.test.annotation.DirtiesContext;\nimport org.springframework.web.util.UriComponentsBuilder;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)\n@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)\npublic class GameIT {\n\npublic static final String LOCALHOST = \"http://localhost:\";\npublic static final String SERVICE_PATH = \"/game\";\n\npublic static final Long EXISTS_GAME_ID = 1L;\npublic static final Long NOT_EXISTS_GAME_ID = 0L;\nprivate static final String NOT_EXISTS_TITLE = \"NotExists\";\nprivate static final String EXISTS_TITLE = \"Aventureros\";\nprivate static final String NEW_TITLE = \"Nuevo juego\";\nprivate static final Long NOT_EXISTS_CATEGORY = 0L;\nprivate static final Long EXISTS_CATEGORY = 3L;\n\nprivate static final String TITLE_PARAM = \"title\";\nprivate static final String CATEGORY_ID_PARAM = \"idCategory\";\n\n@LocalServerPort\nprivate int port;\n\n@Autowired\nprivate TestRestTemplate restTemplate;\n\nParameterizedTypeReference<List<GameDto>> responseType = new ParameterizedTypeReference<List<GameDto>>(){};\n\nprivate String getUrlWithParams(){\nreturn UriComponentsBuilder.fromHttpUrl(LOCALHOST + port + SERVICE_PATH)\n.queryParam(TITLE_PARAM, \"{\" + TITLE_PARAM +\"}\")\n.queryParam(CATEGORY_ID_PARAM, \"{\" + CATEGORY_ID_PARAM +\"}\")\n.encode()\n.toUriString();\n}\n\n@Test\npublic void findWithoutFiltersShouldReturnAllGamesInDB() {\n\nint GAMES_WITH_FILTER = 6;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, null);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findExistsTitleShouldReturnGames() {\n\nint GAMES_WITH_FILTER = 1;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findExistsCategoryShouldReturnGames() {\n\nint GAMES_WITH_FILTER = 2;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, null);\nparams.put(CATEGORY_ID_PARAM, EXISTS_CATEGORY);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findExistsTitleAndCategoryShouldReturnGames() {\n\nint GAMES_WITH_FILTER = 1;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, EXISTS_CATEGORY);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findNotExistsTitleShouldReturnEmpty() {\n\nint GAMES_WITH_FILTER = 0;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, NOT_EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findNotExistsCategoryShouldReturnEmpty() {\n\nint GAMES_WITH_FILTER = 0;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, null);\nparams.put(CATEGORY_ID_PARAM, NOT_EXISTS_CATEGORY);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findNotExistsTitleOrCategoryShouldReturnEmpty() {\n\nint GAMES_WITH_FILTER = 0;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, NOT_EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, NOT_EXISTS_CATEGORY);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n\nparams.put(TITLE_PARAM, NOT_EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, EXISTS_CATEGORY);\n\nresponse = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n\nparams.put(TITLE_PARAM, EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, NOT_EXISTS_CATEGORY);\n\nresponse = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void saveWithoutIdShouldCreateNewGame() {\n\nGameDto dto = new GameDto();\nAuthorDto authorDto = new AuthorDto();\nauthorDto.setId(1L);\n\nCategoryDto categoryDto = new CategoryDto();\ncategoryDto.setId(1L);\n\ndto.setTitle(NEW_TITLE);\ndto.setAge(\"18\");\ndto.setAuthor(authorDto);\ndto.setCategory(categoryDto);\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, NEW_TITLE);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(0, response.getBody().size());\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nresponse = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(1, response.getBody().size());\n}\n\n@Test\npublic void modifyWithExistIdShouldModifyGame() {\n\nGameDto dto = new GameDto();\nAuthorDto authorDto = new AuthorDto();\nauthorDto.setId(1L);\n\nCategoryDto categoryDto = new CategoryDto();\ncategoryDto.setId(1L);\n\ndto.setTitle(NEW_TITLE);\ndto.setAge(\"18\");\ndto.setAuthor(authorDto);\ndto.setCategory(categoryDto);\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, NEW_TITLE);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(0, response.getBody().size());\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + EXISTS_GAME_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nresponse = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(1, response.getBody().size());\nassertEquals(EXISTS_GAME_ID, response.getBody().get(0).getId());\n}\n\n@Test\npublic void modifyWithNotExistIdShouldThrowException() {\n\nGameDto dto = new GameDto();\ndto.setTitle(NEW_TITLE);\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + NOT_EXISTS_GAME_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n\n}\n
B\u00fasquedas en BBDD
Siempre deber\u00edamos buscar a los hijos por primary keys, nunca hay que hacerlo por una descripci\u00f3n libre, ya que el usuario podr\u00eda teclear el mismo nombre de diferentes formas y no habr\u00eda manera de buscar correctamente el resultado. As\u00ed que siempre que haya un dropdown, se debe filtrar por su ID.
Si ahora ejecutas los jUnits, ver\u00e1s que en este caso hemos construido 10 pruebas, para cubrir los casos b\u00e1sicos del Controller
, y todas ellas fallan la ejecuci\u00f3n. Vamos a seguir implementando el resto de capas para hacer que los test funcionen.
De nuevo para poder compilar esta capa, nos hace falta delegar sus operaciones de l\u00f3gica de negocio en un Service
as\u00ed que lo crearemos al mismo tiempo que lo vamos necesitando.
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameService {\n\n/**\n * Recupera los juegos filtrando opcionalmente por t\u00edtulo y/o categor\u00eda\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link Game}\n */\nList<Game> find(String title, Long idCategory);\n\n/**\n * Guarda o modifica un juego, dependiendo de si el identificador est\u00e1 o no informado\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, GameDto dto);\n\n}\n
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Game\", description = \"API of Game\")\n@RequestMapping(value = \"/game\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class GameController {\n\n@Autowired\nGameService gameService;\n@Autowired\nModelMapper mapper;\n/**\n * M\u00e9todo para recuperar una lista de {@link Game}\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link GameDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a filtered list of Games\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<GameDto> find(@RequestParam(value = \"title\", required = false) String title,\n@RequestParam(value = \"idCategory\", required = false) Long idCategory) {\n\nList<Game> games = gameService.find(title, idCategory);\nreturn games.stream().map(e -> mapper.map(e, GameDto.class)).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Game}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Game\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody GameDto dto) {\n\ngameService.save(id, dto);\n}\n\n}\n
En esta ocasi\u00f3n, para el m\u00e9todo de b\u00fasqueda hemos decidido utilizar par\u00e1metros en la URL de tal forma que nos quedar\u00e1 algo as\u00ed http://localhost:8080/game/?title=xxx&idCategoria=yyy
. Queremos recuperar el recurso Game
que es el raiz de la ruta, pero filtrado por cero o varios par\u00e1metros.
Siguiente paso, la capa de l\u00f3gica de negocio, es decir el Service
, que por tanto har\u00e1 uso de un Repository
.
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class GameServiceImpl implements GameService {\n\n@Autowired\nGameRepository gameRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Game> find(String title, Long idCategory) {\n\nreturn (List<Game>) this.gameRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, GameDto dto) {\n\nGame game;\n\nif (id == null) {\ngame = new Game();\n} else {\ngame = this.gameRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(dto, game, \"id\", \"author\", \"category\");\nthis.gameRepository.save(game);\n}\n\n}\n
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameRepository extends CrudRepository<Game, Long> {\n\n}\n
Este servicio tiene dos peculiaridades, remarcadas en amarillo en la clase anterior. Por un lado tenemos la consulta, que no es un listado completo ni un listado paginado, sino que es un listado con filtros. Luego veremos como se hace eso, de momento lo dejaremos como un m\u00e9todo que recibe los dos filtros.
La segunda peculiaridad es que de cliente nos est\u00e1 llegando un GameDto
, que internamente tiene un AuthorDto
y un CategoryDto
, pero nosotros lo tenemos que traducir a entidades de BBDD. No sirve con copiar las propiedades tal cual, ya que entonces Spring lo que har\u00e1 ser\u00e1 crear un objeto nuevo y persistir ese objeto nuevo de Author
y de Category
. Adem\u00e1s, de cliente generalmente tan solo nos llega el ID de esos objetos hijo, y no el resto de informaci\u00f3n de la entidad. Por esos motivos lo hemos ignorado del copyProperties.
Pero de alguna forma tendremos que asignarle esos valores a la entidad Game
. Si conocemos sus ID que es lo que generalmente llega, podemos recuperar esos objetos de BBDD y asignarlos en el objeto Game
. Si recuerdas las reglas b\u00e1sicas, un Repository
debe pertenecer a un solo Service
, por lo que en lugar de llamar a m\u00e9todos de los AuthorRepository
y CategoryRepository
desde nuestro GameServiceImpl
, debemos llamar a m\u00e9todos expuestos en AuthorService
y CategoryService
, que son los que gestionan sus repositorios. Para ello necesitaremos crear esos m\u00e9todos get en los otros Services
.
Y ya sabes, para implementar nuevos m\u00e9todos, antes se deben hacer las pruebas jUnit, que en este caso, por variar, cubriremos con pruebas unitarias. Recuerda que los test van en src/test/java
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\npublic class AuthorTest {\n\npublic static final Long EXISTS_AUTHOR_ID = 1L;\npublic static final Long NOT_EXISTS_AUTHOR_ID = 0L;\n\n@Mock\nprivate AuthorRepository authorRepository;\n\n@InjectMocks\nprivate AuthorServiceImpl authorService;\n\n@Test\npublic void getExistsAuthorIdShouldReturnAuthor() {\n\nAuthor author = mock(Author.class);\nwhen(author.getId()).thenReturn(EXISTS_AUTHOR_ID);\nwhen(authorRepository.findById(EXISTS_AUTHOR_ID)).thenReturn(Optional.of(author));\n\nAuthor authorResponse = authorService.get(EXISTS_AUTHOR_ID);\n\nassertNotNull(authorResponse);\n\nassertEquals(EXISTS_AUTHOR_ID, authorResponse.getId());\n}\n\n@Test\npublic void getNotExistsAuthorIdShouldReturnNull() {\n\nwhen(authorRepository.findById(NOT_EXISTS_AUTHOR_ID)).thenReturn(Optional.empty());\n\nAuthor author = authorService.get(NOT_EXISTS_AUTHOR_ID);\n\nassertNull(author);\n}\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport org.springframework.data.domain.Page;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorService {\n\n/**\n * Recupera un {@link Author} a trav\u00e9s de su ID\n *\n * @param id PK de la entidad\n * @return {@link Author}\n */\nAuthor get(Long id);\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findPage(AuthorSearchDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, AuthorDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class AuthorServiceImpl implements AuthorService {\n\n@Autowired\nAuthorRepository authorRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic Author get(Long id) {\nreturn this.authorRepository.findById(id).orElse(null);\n}\n/**\n * {@inheritDoc}\n */\n@Override\npublic Page<Author> findPage(AuthorSearchDto dto) {\n\nreturn this.authorRepository.findAll(dto.getPageable().getPageable());\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, AuthorDto data) {\n\nAuthor author;\n\nif (id == null) {\nauthor = new Author();\n} else {\nauthor = this.get(id);\n}\n\nBeanUtils.copyProperties(data, author, \"id\");\n\nthis.authorRepository.save(author);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.get(id) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.authorRepository.deleteById(id);\n}\n\n}\n
Y lo mismo para categor\u00edas.
CategoryTest.javaCategoryService.javaCategoryServiceImpl.javapublic static final Long NOT_EXISTS_CATEGORY_ID = 0L;\n\n@Test\npublic void getExistsCategoryIdShouldReturnCategory() {\n\nCategory category = mock(Category.class);\nwhen(category.getId()).thenReturn(EXISTS_CATEGORY_ID);\nwhen(categoryRepository.findById(EXISTS_CATEGORY_ID)).thenReturn(Optional.of(category));\n\nCategory categoryResponse = categoryService.get(EXISTS_CATEGORY_ID);\n\nassertNotNull(categoryResponse);\nassertEquals(EXISTS_CATEGORY_ID, category.getId());\n}\n\n@Test\npublic void getNotExistsCategoryIdShouldReturnNull() {\n\nwhen(categoryRepository.findById(NOT_EXISTS_CATEGORY_ID)).thenReturn(Optional.empty());\n\nCategory category = categoryService.get(NOT_EXISTS_CATEGORY_ID);\n\nassertNull(category);\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface CategoryService {\n\n/**\n * Recupera una {@link Category} a partir de su ID\n *\n * @param id PK de la entidad\n * @return {@link Category}\n */\nCategory get(Long id);\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link Category}\n */\nList<Category> findAll();\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, CategoryDto dto);\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class CategoryServiceImpl implements CategoryService {\n\n@Autowired\nCategoryRepository categoryRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic Category get(Long id) {\nreturn this.categoryRepository.findById(id).orElse(null);\n}\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Category> findAll() {\n\nreturn (List<Category>) this.categoryRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, CategoryDto dto) {\n\nCategory category;\n\nif (id == null) {\ncategory = new Category();\n} else {\ncategory = this.get(id);\n}\n\ncategory.setName(dto.getName());\n\nthis.categoryRepository.save(category);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.get(id) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.categoryRepository.deleteById(id);\n}\n\n}\n
Clean Code
A la hora de implementar m\u00e9todos nuevos, ten siempre presente el Clean Code
. \u00a1No dupliques c\u00f3digo!, es muy importante de cara al futuro mantenimiento. Si en nuestro m\u00e9todo save
hac\u00edamos uso de una operaci\u00f3n findById
y ahora hemos creado una nueva operaci\u00f3n get
, hagamos uso de esta nueva operaci\u00f3n y no repitamos el c\u00f3digo.
Y ahora que ya tenemos los m\u00e9todos necesarios, ya podemos implementar correctamente nuestro GameServiceImpl
.
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.author.AuthorService;\nimport com.ccsw.tutorial.category.CategoryService;\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class GameServiceImpl implements GameService {\n\n@Autowired\nGameRepository gameRepository;\n\n@Autowired\nAuthorService authorService;\n@Autowired\nCategoryService categoryService;\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Game> find(String title, Long idCategory) {\n\nreturn this.gameRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, GameDto dto) {\n\nGame game;\n\nif (id == null) {\ngame = new Game();\n} else {\ngame = this.gameRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(dto, game, \"id\", \"author\", \"category\");\n\ngame.setAuthor(authorService.get(dto.getAuthor().getId()));\ngame.setCategory(categoryService.get(dto.getCategory().getId()));\nthis.gameRepository.save(game);\n}\n\n}\n
Ahora si que tenemos la capa de l\u00f3gica de negocio terminada, podemos pasar a la siguiente capa.
"},{"location":"develop/filtered/springboot/#repository","title":"Repository","text":"Y llegamos a la \u00faltima capa donde, si recordamos, ten\u00edamos un m\u00e9todo que recibe dos par\u00e1metros. Necesitamos traducir esto en una consulta a la BBDD.
Vamos a necesitar un listado filtrado por t\u00edtulo o por categor\u00eda, as\u00ed que necesitaremos pasarle esos datos y filtrar la query. Para el t\u00edtulo vamos a buscar por una cadena contenida, as\u00ed que el par\u00e1metro ser\u00e1 de tipo String
, mientras que para la categor\u00eda vamos a buscar por su primary key, as\u00ed que el par\u00e1metro ser\u00e1 de tipo Long
.
Existen varias estrategias para abordar esta implementaci\u00f3n. Podr\u00edamos utilizar los QueryMethods para que Spring JPA haga su magia, pero en esta ocasi\u00f3n ser\u00eda bastante complicado encontrar un predicado correcto.
Tambi\u00e9n podr\u00edamos hacer una implementaci\u00f3n de la interface y hacer la consulta directamente con Criteria.
Por otro lado se podr\u00eda hacer uso de la anotaci\u00f3n @Query. Esta anotaci\u00f3n nos permite definir una consulta en SQL nativo o en JPQL (Java Persistence Query Language) y Spring JPA se encargar\u00e1 de realizar todo el mapeo y conversi\u00f3n de los datos de entrada y salida. Pero esta opci\u00f3n no es la m\u00e1s recomendable.
"},{"location":"develop/filtered/springboot/#specifications","title":"Specifications","text":"En este caso vamos a hacer uso de las Specifications que es la opci\u00f3n m\u00e1s robusta y no presenta acoplamientos con el tipo de BBDD.
Haciendo un resumen muy r\u00e1pido y con poco detalle, las Specifications
sirven para generar de forma robusta las clausulas where
de una consulta SQL. Estas clausulas se generar\u00e1n mediante Predicate
(predicados) que realizar\u00e1n operaciones de comparaci\u00f3n entre un campo y un valor.
En el siguiente ejemplo podemos verlo m\u00e1s claro: en la sentencia select * from
Table
where
name = 'b\u00fasqueda'
tenemos un solo predicado que es name = 'b\u00fasqueda'
. En ese predicado diferenciamos tres etiquetas:
name
\u2192 es el campo sobre el que hacemos el predicado=
\u2192 es la operaci\u00f3n que realizamos'b\u00fasqueda'
\u2192 es el valor con el que realizamos la operaci\u00f3nLo que trata de hacer Specifications
es agregar varios predicados con AND
o con OR
de forma tipada en c\u00f3digo. Y \u00bfqu\u00e9 intentamos conseguir con esta forma de programar?, pues f\u00e1cil, intentamos hacer que si cambiamos alg\u00fan tipo o el nombre de alguna propiedad involucrada en la query, nos salte un fallo en tiempo de compilaci\u00f3n y nos demos cuenta de donde est\u00e1 el error. Si utiliz\u00e1ramos queries construidas directamente con String
, al cambiar alg\u00fan tipo o el nombre de alguna propiedad involucrada, no nos dar\u00edamos cuenta hasta que saltara un fallo en tiempo de ejecuci\u00f3n.
Por este motivo hay que programar con Specifications
, porque son robustas ante cambios de c\u00f3digo y tenemos que tratar de evitar las construcciones a trav\u00e9s de cadenas de texto.
Dicho esto, \u00a1vamos a implementar!
Lo primero que necesitaremos ser\u00e1 una clase que nos permita guardar la informaci\u00f3n de un Predicate
para luego generar facilmente la construcci\u00f3n. Para ello vamos a crear una clase que guarde informaci\u00f3n de los criterios de filtrado (campo, operaci\u00f3n y valor), por suerte esta clase ser\u00e1 gen\u00e9rica y la podremos usar en toda la aplicaci\u00f3n, as\u00ed que la vamos a crear en el paquete com.ccsw.tutorial.common.criteria
package com.ccsw.tutorial.common.criteria;\n\npublic class SearchCriteria {\n\nprivate String key;\nprivate String operation;\nprivate Object value;\n\npublic SearchCriteria(String key, String operation, Object value) {\n\nthis.key = key;\nthis.operation = operation;\nthis.value = value;\n}\n\npublic String getKey() {\nreturn key;\n}\n\npublic void setKey(String key) {\nthis.key = key;\n}\n\npublic String getOperation() {\nreturn operation;\n}\n\npublic void setOperation(String operation) {\nthis.operation = operation;\n}\n\npublic Object getValue() {\nreturn value;\n}\n\npublic void setValue(Object value) {\nthis.value = value;\n}\n\n}\n
Hecho esto pasamos a definir el Specification
de nuestra clase la cual contendr\u00e1 la construcci\u00f3n de la consulta en funci\u00f3n de los criterios que se le proporcionan. No queremos construir los predicados directamente en nuestro Service
ya que duplicariamos mucho c\u00f3digo, mucho mejor si hacemos una clase para centralizar la construcci\u00f3n de predicados.
De esta forma vamos a crear una clase Specification
por cada una de las Entity
que queramos consultar. En nuestro caso solo vamos a generar queries
para Game
, as\u00ed que solo crearemos un GameSpecification
donde construirmos los predicados.
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.common.criteria.SearchCriteria;\nimport com.ccsw.tutorial.game.model.Game;\nimport jakarta.persistence.criteria.*;\nimport org.springframework.data.jpa.domain.Specification;\n\n\npublic class GameSpecification implements Specification<Game> {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate final SearchCriteria criteria;\npublic GameSpecification(SearchCriteria criteria) {\n\nthis.criteria = criteria;\n}\n\n@Override\npublic Predicate toPredicate(Root<Game> root, CriteriaQuery<?> query, CriteriaBuilder builder) {\nif (criteria.getOperation().equalsIgnoreCase(\":\") && criteria.getValue() != null) {\nPath<String> path = getPath(root);\nif (path.getJavaType() == String.class) {\nreturn builder.like(path, \"%\" + criteria.getValue() + \"%\");\n} else {\nreturn builder.equal(path, criteria.getValue());\n}\n}\nreturn null;\n}\n\nprivate Path<String> getPath(Root<Game> root) {\nString key = criteria.getKey();\nString[] split = key.split(\"[.]\", 0);\n\nPath<String> expression = root.get(split[0]);\nfor (int i = 1; i < split.length; i++) {\nexpression = expression.get(split[i]);\n}\n\nreturn expression;\n}\n\n}\n
Voy a tratar de explicar con calma cada una de las l\u00edneas marcadas, ya que son conceptos dificiles de entender hasta que no se utilizan.
Las dos primeras l\u00edneas marcadas hacen referencia a que cuando se crea un Specification
, esta debe generar un predicado, con lo que necesita unos criterios de filtrado para poder generarlo. En el constructor le estamos pasando esos criterios de filtrado que luego utilizaremos.
La tercera l\u00ednea marcada est\u00e1 seleccionando el tipo de operaci\u00f3n. En nuestro caso solo vamos a utilizar operaciones de comparaci\u00f3n. Por convenio las operaciones de comparaci\u00f3n se marcan como \":\" ya que el s\u00edmbolo = est\u00e1 reservado. Aqu\u00ed es donde podr\u00edamos a\u00f1adir otro tipo de operaciones como \">\" o \"<>\" o cualquiera que queramos implementar. Gu\u00e1rdate esa informaci\u00f3n que te servir\u00e1 en el ejercicio final .
Las dos siguientes l\u00edneas, las de return
est\u00e1n construyendo un Predicate
al ser de tipo comparaci\u00f3n, si es un texto har\u00e1 un like
y si no es texto (que es un n\u00famero o fecha) har\u00e1 un equals
.
Por \u00faltimo, tenemos un m\u00e9todo getPath
que invocamos dentro la generaci\u00f3n del predicado y que implementamos m\u00e1s abajo. Esta funci\u00f3n nos permite explorar las sub-entidades para realizar consultas sobre los atributos de estas. Por ejemplo, si queremos navegar hasta game.author.name
, lo que har\u00e1 la exploraci\u00f3n ser\u00e1 recuperar el atributo name
del objeto author
de la entidad game
.
Una vez implementada nuestra clase de Specification
, que lo \u00fanico que hace es recoger un criterio de filtrado y construir un predicado, y que en principio solo permite generar comparaciones de igualdad, vamos a utilizarlo dentro de nuestro Service
:
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.author.AuthorService;\nimport com.ccsw.tutorial.category.CategoryService;\nimport com.ccsw.tutorial.common.criteria.SearchCriteria;\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.jpa.domain.Specification;\nimport org.springframework.stereotype.Service;\n\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class GameServiceImpl implements GameService {\n\n@Autowired\nGameRepository gameRepository;\n\n@Autowired\nAuthorService authorService;\n\n@Autowired\nCategoryService categoryService;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Game> find(String title, Long idCategory) {\n\nGameSpecification titleSpec = new GameSpecification(new SearchCriteria(\"title\", \":\", title));\nGameSpecification categorySpec = new GameSpecification(new SearchCriteria(\"category.id\", \":\", idCategory));\nSpecification<Game> spec = Specification.where(titleSpec).and(categorySpec);\nreturn this.gameRepository.findAll(spec);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, GameDto dto) {\n\nGame game;\n\nif (id == null) {\ngame = new Game();\n} else {\ngame = this.gameRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(dto, game, \"id\", \"author\", \"category\");\n\ngame.setAuthor(authorService.get(dto.getAuthor().getId()));\ngame.setCategory(categoryService.get(dto.getCategory().getId()));\n\nthis.gameRepository.save(game);\n}\n\n}\n
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport org.springframework.data.jpa.domain.Specification;\nimport org.springframework.data.jpa.repository.EntityGraph;\nimport org.springframework.data.jpa.repository.JpaSpecificationExecutor;\nimport org.springframework.data.repository.CrudRepository;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameRepository extends CrudRepository<Game, Long>, JpaSpecificationExecutor<Game> {\n}\n
Lo que hemos hecho es crear los dos criterios de filtrado que necesit\u00e1bamos. En nuestro caso eran title
, que es un atributo de la entidad Game
y por otro lado el identificador de categor\u00eda, que en este caso, ya no es un atributo directo de la entidad, si no, de la categor\u00eda asociada, por lo que debemos navegar hasta el atributo id
a trav\u00e9s del atributo category
(para esto utilizamos el getPath
que hemos visto anteriormente).
A partir de estos dos predicados, podemos generar el Specification
global para la consulta, uniendo los dos predicados mediante el operador AND
.
Una vez construido el Specification
ya podemos usar el m\u00e9todo por defecto que nos proporciona Spring Data para dicho fin, tan solo tenemos que decirle a nuestro GameRepository
que adem\u00e1s extender de CrudRepository
debe extender de JpaSpecificationExecutor
, para que pueda ejecutarlas.
Finalmente, de cara a mejorar el rendimiento de nuestros servicios vamos a hacer foco en la generaci\u00f3n de transacciones con la base de datos. Si ejecut\u00e1ramos esta petici\u00f3n tal cual lo tenemos implementado ahora mismo, en la consola ver\u00edamos lo siguiente:
Hibernate: select g1_0.id,g1_0.age,g1_0.author_id,g1_0.category_id,g1_0.title from game g1_0\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\nHibernate: select c1_0.id,c1_0.name from category c1_0 where c1_0.id=?\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\nHibernate: select c1_0.id,c1_0.name from category c1_0 where c1_0.id=?\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\n
Esto es debido a que no le hemos dado indicaciones a Spring Data de como queremos que construya las consultas con relaciones y por defecto est\u00e1 configurado para generar sub-consultas cuando tenemos tablas relacionadas.
En nuestro caso la tabla Game
est\u00e1 relacionada con Author
y Category
. Al realizar la consulta a Game
realiza las sub-consultas por cada uno de los registros relacionados con los resultados Game
.
Para evitar tantas consultas contra la BBDD y realizar esto de una forma mucho m\u00e1s \u00f3ptima, podemos decirle a Spring Data el comportamiento que queremos, que en nuestro caso ser\u00e1 que haga una \u00fanica consulta y haga las sub-consultas mediante los join
correspondientes.
Para ello a\u00f1adimos una sobre-escritura del m\u00e9todo findAll
, que ya ten\u00edamos implementado en JpaSpecificationExecutor
y que utlizamos de forma heredada, pero en este caso le a\u00f1adimos la anotaci\u00f3n @EntityGraph
con los atributos que queremos que se incluyan dentro de la consulta principal mediante join
:
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport org.springframework.data.jpa.domain.Specification;\nimport org.springframework.data.jpa.repository.EntityGraph;\nimport org.springframework.data.jpa.repository.JpaSpecificationExecutor;\nimport org.springframework.data.repository.CrudRepository;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameRepository extends CrudRepository<Game, Long>, JpaSpecificationExecutor<Game> {\n\n@Override\n@EntityGraph(attributePaths = {\"category\", \"author\"})\nList<Game> findAll(Specification<Game> spec);\n}\n
Tras realizar este cambio, podemos observar que la nueva consulta generada es la siguiente:
Hibernate: select g1_0.id,g1_0.age,a1_0.id,a1_0.name,a1_0.nationality,c1_0.id,c1_0.name,g1_0.title from game g1_0 join author a1_0 on a1_0.id=g1_0.author_id join category c1_0 on c1_0.id=g1_0.category_id\n
Como podemos observar, ahora se realiza una \u00fanica consulta con la correspondiente transacci\u00f3n con la BBDD, y se trae todos los datos necesarios de Game
, Author
y Category
sin lanzar m\u00faltiples queries.
Si ahora ejecutamos de nuevo los jUnits, vemos que todos los que hemos desarrollado en GameIT
ya funcionan correctamente, e incluso el resto de test de la aplicaci\u00f3n tambi\u00e9n funcionan correctamente.
Pruebas jUnit
Cada vez que desarrollemos un caso de uso nuevo, debemos relanzar todas las pruebas autom\u00e1ticas que tenga la aplicaci\u00f3n. Es muy com\u00fan que al implementar alg\u00fan desarrollo nuevo, interfiramos de alguna forma en el funcionamiento de otra funcionalidad. Si lanzamos toda la bater\u00eda de pruebas, nos daremos cuenta si algo ha dejado de funcionar y podremos solucionarlo antes de llevar ese error a Producci\u00f3n. Las pruebas jUnit son nuestra red de seguridad.
Adem\u00e1s de las pruebas autom\u00e1ticas, podemos ver como se comporta la aplicaci\u00f3n y que respuesta nos ofrece, lanzando peticiones Rest con Postman, como hemos hecho en los casos anteriores. As\u00ed que podemos levantar la aplicaci\u00f3n y lanzar las operaciones:
** GET http://localhost:8080/game **
** GET http://localhost:8080/game?title=xxx **
** GET http://localhost:8080/game?idCategory=xxx **
Nos devuelve un listado filtrado de Game
. F\u00edjate bien en la petici\u00f3n donde enviamos los filtros y la respuesta que tiene los objetos Category
y Author
inclu\u00eddos.
** PUT http://localhost:8080/game ** ** PUT http://localhost:8080/game/{id} **
{\n \"title\": \"Nuevo juego\",\n \"age\": \"18\",\n \"category\": {\n \"id\": 3\n },\n \"author\": {\n \"id\": 1\n }\n}\n
Nos sirve para insertar un Game
nuevo (si no tienen el id informado) o para actualizar un Game
(si tienen el id informado). F\u00edjate que para enlazar Category
y Author
tan solo hace falta el id de cada no de ellos, ya que en el m\u00e9todo save
se hace una consulta get
para recuperarlos por su id. Adem\u00e1s que no tendr\u00eda sentido enviar toda la informaci\u00f3n de esas entidades ya que no est\u00e1s dando de alta una Category
ni un Author
.
Rendimiento en las consultas JPA
En este punto te recomiendo que visites el Anexo. Funcionamiento JPA para conocer un poco m\u00e1s como funciona por dentro JPA y alg\u00fan peque\u00f1o truco que puede mejorar el rendimiento.
"},{"location":"develop/filtered/springboot/#implementar-listado-autores","title":"Implementar listado Autores","text":"Antes de poder conectar front con back, si recuerdas, en la edici\u00f3n de un Game
, nos hac\u00eda falta un listado de Author
y un listado de Category
. El segundo ya lo tenemos ya que lo reutilizaremos del listado de categor\u00edas que implementamos. Pero el primero no lo tenemos, porque en la pantalla que hicimos, se mostraban de forma paginada.
As\u00ed que necesitamos implementar esa funcionalidad, y como siempre vamos de la capa de testing hacia las siguientes capas. Deber\u00edamos a\u00f1adir los siguientes m\u00e9todos:
AuthorIT.javaAuthorController.javaAuthorService.javaAuthorServiceImpl.java...\n\nParameterizedTypeReference<List<AuthorDto>> responseTypeList = new ParameterizedTypeReference<List<AuthorDto>>(){};\n\n@Test\npublic void findAllShouldReturnAllAuthor() {\n\nResponseEntity<List<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseTypeList);\n\nassertNotNull(response);\nassertEquals(TOTAL_AUTHORS, response.getBody().size());\n}\n\n...\n
...\n\n/**\n * Recupera un listado de autores {@link Author}\n *\n * @return {@link List} de {@link AuthorDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<AuthorDto> findAll() {\n\nList<Author> authors = this.authorService.findAll();\n\nreturn authors.stream().map(e -> mapper.map(e, AuthorDto.class)).collect(Collectors.toList());\n}\n\n...\n
...\n\n/**\n * Recupera un listado de autores {@link Author}\n *\n * @return {@link List} de {@link Author}\n */\nList<Author> findAll();\n\n...\n
...\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Author> findAll() {\n\nreturn (List<Author>) this.authorRepository.findAll();\n}\n\n\n...\n
"},{"location":"develop/filtered/vuejs/","title":"Listado filtrado - VUE","text":"Aqu\u00ed vamos a volver a la pantalla de cat\u00e1logo para realizar un filtrado en la propia tabla.
Empezaremos por modificar el template de la tabla que modificamos para a\u00f1adir el bot\u00f3n de a\u00f1adir nueva fila para a\u00f1adir tambi\u00e9n tres inputs: uno de texto para el nombre del juego y dos seleccionables para la categor\u00eda y el autor (les tendremos que asignar las opciones que haya en ese momento).
Tambi\u00e9n a\u00f1adiremos un bot\u00f3n para que no se lance la petici\u00f3n cada vez que el usuario introduce una letra en el input de texto. Esto quedar\u00eda as\u00ed:
<template v-slot:top>\n <div class=\"q-table__title\">Cat\u00e1logo</div>\n <q-btn flat round color=\"primary\" icon=\"add\" @click=\"showAdd = true\" />\n <q-space />\n <q-input dense v-model=\"filter.title\" placeholder=\"T\u00edtulo\">\n <template v-slot:append>\n <q-icon name=\"search\" />\n </template>\n </q-input>\n <q-separator inset />\n <div style=\"width: 10%\">\n <q-select\n dense\n name=\"category\"\n v-model=\"filter.category\"\n :options=\"categories\"\n emit-value\n map-options\n option-value=\"id\"\n option-label=\"name\"\n label=\"Categor\u00eda\"\n />\n </div>\n <q-separator inset />\n <div style=\"width: 10%\">\n <q-select\n dense\n name=\"author\"\n v-model=\"filter.author\"\n :options=\"authors\"\n emit-value\n map-options\n option-value=\"id\"\n option-label=\"name\"\n label=\"Autor\"\n />\n </div>\n <q-separator inset />\n <q-btn flat round color=\"primary\" icon=\"filter_alt\" @click=\"getGames\" />\n </template>\n
Adem\u00e1s, tambi\u00e9n vamos a a\u00f1adir un estado para todos los filtros juntos:
const filter = ref({ title: '', category: '', author: '' });\n
Por \u00faltimo, para no estar haciendo las tres peticiones (juegos, categor\u00edas y autores) las hemos extra\u00eddo en funciones diferentes de la siguiente manera:
const getGames = () => {\n const { data } = useFetch(url.value).get().json();\n whenever(data, () => (catalogData.value = data.value));\n};\n\nconst getCategories = () => {\n const { data: categoriesData } = useFetch('http://localhost:8080/category')\n .get()\n .json();\n whenever(categoriesData, () => (categories.value = categoriesData.value));\n};\n\nconst getAuthors = () => {\n const { data: authorsData } = useFetch('http://localhost:8080/author')\n .get()\n .json();\n whenever(authorsData, () => (authors.value = authorsData.value));\n};\n\nconst firstLoad = () => {\n getGames();\n getCategories();\n getAuthors();\n};\nfirstLoad();\n
Y como podemos ver, ahora la petici\u00f3n de juegos no tiene la url. Esto es porque hemos hecho que sea una variable computada para a\u00f1adirle los par\u00e1metros de filtrado y ha quedado as\u00ed:
const url = computed(() => {\n const _url = new URL('http://localhost:8080/game');\n _url.search = new URLSearchParams({\n title: filter.value.title,\n idCategory: filter.value.category ?? '',\n idAuthor: filter.value.author ?? '',\n });\n return _url.toString();\n});\n
"},{"location":"develop/paginated/angular/","title":"Listado paginado - Angular","text":"Ya tienes tu primer CRUD desarrollado. \u00bfHa sido sencillo, verdad?.
Ahora vamos a implementar un CRUD un poco m\u00e1s complejo, este tiene datos paginados en servidor, esto quiere decir que no nos sirve un array de datos como en el anterior ejemplo. Para que un listado paginado en servidor funcione, el cliente debe enviar en cada petici\u00f3n que p\u00e1gina est\u00e1 solicitando y cual es el tama\u00f1o de la p\u00e1gina, para que el servidor devuelva solamente un subconjunto de datos, en lugar de devolver el listado completo.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/paginated/angular/#crear-modulo-y-componentes","title":"Crear modulo y componentes","text":"Vamos a desarrollar el listado de Autores
as\u00ed que, debemos crear los componentes:
ng generate module author\nng generate component author/author-list\nng generate component author/author-edit\n\nng generate service author/author\n
Este m\u00f3dulo lo vamos a a\u00f1adir a la aplicaci\u00f3n para que se cargue en el arranque. Abrimos el fichero app.module.ts
y a\u00f1adimos el m\u00f3dulo:
import { NgModule } from '@angular/core';\nimport { BrowserModule } from '@angular/platform-browser';\n\nimport { AppRoutingModule } from './app-routing.module';\nimport { AppComponent } from './app.component';\nimport { BrowserAnimationsModule } from '@angular/platform-browser/animations';\nimport { CoreModule } from './core/core.module';\nimport { CategoryModule } from './category/category.module';\nimport { AuthorModule } from './author/author.module';\n@NgModule({\ndeclarations: [\nAppComponent\n],\nimports: [\nBrowserModule,\nAppRoutingModule,\nCoreModule,\nCategoryModule,\nAuthorModule,\nBrowserAnimationsModule\n],\nproviders: [],\nbootstrap: [AppComponent]\n})\nexport class AppModule { }\n
"},{"location":"develop/paginated/angular/#crear-el-modelo","title":"Crear el modelo","text":"Creamos el modelo en author/model/Author.ts
con las propiedades necesarias para trabajar con la informaci\u00f3n de un autor:
export class Author {\nid: number;\nname: string;\nnationality: string;\n}\n
"},{"location":"develop/paginated/angular/#anadir-el-punto-de-entrada","title":"A\u00f1adir el punto de entrada","text":"A\u00f1adimos la ruta al men\u00fa para que podamos acceder a la pantalla:
app-routing.module.tsimport { NgModule } from '@angular/core';\nimport { Routes, RouterModule } from '@angular/router';\nimport { CategoryListComponent } from './category/category-list/category-list.component';\nimport { AuthorListComponent } from './author/author-list/author-list.component';\nconst routes: Routes = [\n{ path: 'categories', component: CategoriesComponent },\n{ path: 'authors', component: AuthorListComponent },\n];\n\n@NgModule({\nimports: [RouterModule.forRoot(routes)],\nexports: [RouterModule]\n})\nexport class AppRoutingModule { }\n
"},{"location":"develop/paginated/angular/#implementar-servicio","title":"Implementar servicio","text":"Y realizamos las diferentes implementaciones. Empezaremos por el servicio. En este caso, hay un cambio sustancial con el anterior ejemplo. Al tratarse de un listado paginado, la operaci\u00f3n getAuthors
necesita informaci\u00f3n extra acerca de que p\u00e1gina de datos debe mostrar, adem\u00e1s de que el resultado ya no ser\u00e1 un listado sino una p\u00e1gina.
Por defecto el esquema de datos de Spring para la paginaci\u00f3n es como el siguiente:
Esquema de datos de paginaci\u00f3n{\n\"content\": [ ... <listado con los resultados paginados> ... ],\n\"pageable\": {\n\"pageNumber\": <n\u00famero de p\u00e1gina empezando por 0>,\n\"pageSize\": <tama\u00f1o de p\u00e1gina>,\n\"sort\": [\n{ \"property\": <nombre de la propiedad a ordenar>, \"direction\": <direcci\u00f3n de la ordenaci\u00f3n ASC / DESC> }\n]\n},\n\"totalElements\": <numero total de elementos en la tabla>\n}\n
As\u00ed que necesitamos poder enviar y recuperar esa informaci\u00f3n desde Angular, nos hace falta crear esos objetos. Los objetos de paginaci\u00f3n al ser comunes a toda la aplicaci\u00f3n, vamos a crearlos en core/model/page
, mientras que la paginaci\u00f3n de AuthorPage.ts
la crear\u00e9 en su propio model dentro de author/model
.
export class SortPage {\nproperty: String;\ndirection: String;\n}\n
import { SortPage } from './SortPage';\n\nexport class Pageable {\npageNumber: number;\npageSize: number;\nsort: SortPage[];\n}\n
import { Pageable } from \"src/app/core/model/page/Pageable\";\nimport { Author } from \"./Author\";\n\nexport class AuthorPage {\ncontent: Author[];\npageable: Pageable;\ntotalElements: number;\n}\n
Con estos objetos creados ya podemos implementar el servicio y sus datos mockeados.
mock-authors.tsauthor.service.tsimport { AuthorPage } from \"./AuthorPage\";\n\nexport const AUTHOR_DATA: AuthorPage = {\ncontent: [\n{ id: 1, name: 'Klaus Teuber', nationality: 'Alemania' },\n{ id: 2, name: 'Matt Leacock', nationality: 'Estados Unidos' },\n{ id: 3, name: 'Keng Leong Yeo', nationality: 'Singapur' },\n{ id: 4, name: 'Gil Hova', nationality: 'Estados Unidos'},\n{ id: 5, name: 'Kelly Adams', nationality: 'Estados Unidos' },\n{ id: 6, name: 'J. Alex Kavern', nationality: 'Estados Unidos' },\n{ id: 7, name: 'Corey Young', nationality: 'Estados Unidos' },\n], pageable : {\npageSize: 5,\npageNumber: 0,\nsort: [\n{property: \"id\", direction: \"ASC\"}\n]\n},\ntotalElements: 7\n}\n
import { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Pageable } from '../core/model/page/Pageable';\nimport { Author } from './model/Author';\nimport { AuthorPage } from './model/AuthorPage';\nimport { AUTHOR_DATA } from './model/mock-authors';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class AuthorService {\n\nconstructor() { }\n\ngetAuthors(pageable: Pageable): Observable<AuthorPage> {\nreturn of(AUTHOR_DATA);\n}\n\nsaveAuthor(author: Author): Observable<void> {\nreturn of(null);\n}\n\ndeleteAuthor(idAuthor : number): Observable<void> {\nreturn of(null);\n} }\n
"},{"location":"develop/paginated/angular/#implementar-listado","title":"Implementar listado","text":"Ya tenemos el servicio con los datos, ahora vamos a por el listado paginado.
author-list.component.htmlauthor-list.component.scssauthor-list.component.ts<div class=\"container\">\n <h1>Listado de Autores</h1>\n\n <mat-table [dataSource]=\"dataSource\"> \n <ng-container matColumnDef=\"id\">\n <mat-header-cell *matHeaderCellDef> Identificador </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.id}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"name\">\n <mat-header-cell *matHeaderCellDef> Nombre autor </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.name}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"nationality\">\n <mat-header-cell *matHeaderCellDef> Nacionalidad </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.nationality}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <mat-header-cell *matHeaderCellDef></mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n <button mat-icon-button color=\"primary\" (click)=\"editAuthor(element)\">\n <mat-icon>edit</mat-icon>\n </button>\n <button mat-icon-button color=\"accent\" (click)=\"deleteAuthor(element)\">\n <mat-icon>clear</mat-icon>\n </button>\n </mat-cell>\n </ng-container>\n\n <mat-header-row *matHeaderRowDef=\"displayedColumns; sticky: true\"></mat-header-row>\n <mat-row *matRowDef=\"let row; columns: displayedColumns;\"></mat-row>\n </mat-table> \n\n<mat-paginator (page)=\"loadPage($event)\" [pageSizeOptions]=\"[5, 10, 20]\" [pageIndex]=\"pageNumber\" [pageSize]=\"pageSize\" [length]=\"totalElements\" showFirstLastButtons></mat-paginator>\n<div class=\"buttons\">\n <button mat-flat-button color=\"primary\" (click)=\"createAuthor()\">Nuevo autor</button> \n </div> \n</div>\n
.container {\nmargin: 20px;\n\nmat-table {\nmargin-top: 10px;\nmargin-bottom: 20px;\n\n.mat-header-row {\nbackground-color:#f5f5f5;\n\n.mat-header-cell {\ntext-transform: uppercase;\nfont-weight: bold;\ncolor: #838383;\n} }\n\n.mat-column-id {\nflex: 0 0 20%;\njustify-content: center;\n}\n\n.mat-column-action {\nflex: 0 0 10%;\njustify-content: center;\n}\n}\n\n.buttons {\ntext-align: right;\n}\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatDialog } from '@angular/material/dialog';\nimport { PageEvent } from '@angular/material/paginator';\nimport { MatTableDataSource } from '@angular/material/table';\nimport { DialogConfirmationComponent } from 'src/app/core/dialog-confirmation/dialog-confirmation.component';\nimport { Pageable } from 'src/app/core/model/page/Pageable';\nimport { AuthorEditComponent } from '../author-edit/author-edit.component';\nimport { AuthorService } from '../author.service';\nimport { Author } from '../model/Author';\n\n@Component({\nselector: 'app-author-list',\ntemplateUrl: './author-list.component.html',\nstyleUrls: ['./author-list.component.scss']\n})\nexport class AuthorListComponent implements OnInit {\n\npageNumber: number = 0;\npageSize: number = 5;\ntotalElements: number = 0;\ndataSource = new MatTableDataSource<Author>();\ndisplayedColumns: string[] = ['id', 'name', 'nationality', 'action'];\n\nconstructor(\nprivate authorService: AuthorService,\npublic dialog: MatDialog,\n) { }\n\nngOnInit(): void {\nthis.loadPage();\n}\n\nloadPage(event?: PageEvent) {\nlet pageable : Pageable = {\npageNumber: this.pageNumber,\npageSize: this.pageSize,\nsort: [{\nproperty: 'id',\ndirection: 'ASC'\n}]\n}\nif (event != null) {\npageable.pageSize = event.pageSize\npageable.pageNumber = event.pageIndex;\n}\nthis.authorService.getAuthors(pageable).subscribe(data => {\nthis.dataSource.data = data.content;\nthis.pageNumber = data.pageable.pageNumber;\nthis.pageSize = data.pageable.pageSize;\nthis.totalElements = data.totalElements;\n});\n} createAuthor() { const dialogRef = this.dialog.open(AuthorEditComponent, {\ndata: {}\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); } editAuthor(author: Author) { const dialogRef = this.dialog.open(AuthorEditComponent, {\ndata: { author: author }\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); }\n\ndeleteAuthor(author: Author) { const dialogRef = this.dialog.open(DialogConfirmationComponent, {\ndata: { title: \"Eliminar autor\", description: \"Atenci\u00f3n si borra el autor se perder\u00e1n sus datos.<br> \u00bfDesea eliminar el autor?\" }\n});\n\ndialogRef.afterClosed().subscribe(result => {\nif (result) {\nthis.authorService.deleteAuthor(author.id).subscribe(result => {\nthis.ngOnInit();\n}); }\n});\n} }\n
F\u00edjate como hemos a\u00f1adido la paginaci\u00f3n.
mat-paginator
, lo que nos va a obligar a a\u00f1adirlo al m\u00f3dulo tambi\u00e9n como dependencia. Ese componente le hemos definido un m\u00e9todo page
que se ejecuta cada vez que la p\u00e1gina cambia, y unas propiedades con las que calcular\u00e1 la p\u00e1gina, el tama\u00f1o y el n\u00famero total de p\u00e1ginas.pageable
con los valores actuales del componente paginador y lanza la petici\u00f3n con esos datos en el body. Obviamente al ser un mock no funcionar\u00e1 el cambio de p\u00e1gina y dem\u00e1s.Como siempre, a\u00f1adimos las dependencias al m\u00f3dulo, vamos a intentar a\u00f1adir todas las que vamos a necesitar a futuro.
author.module.tsimport { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { AuthorListComponent } from './author-list/author-list.component';\nimport { AuthorEditComponent } from './author-edit/author-edit.component';\nimport { MatTableModule } from '@angular/material/table';\nimport { FormsModule, ReactiveFormsModule } from '@angular/forms';\nimport { MatButtonModule } from '@angular/material/button';\nimport { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';\nimport { MatFormFieldModule } from '@angular/material/form-field';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatInputModule } from '@angular/material/input';\nimport { MatPaginatorModule } from '@angular/material/paginator';\n\n\n\n@NgModule({\ndeclarations: [\nAuthorListComponent,\nAuthorEditComponent\n],\nimports: [\nCommonModule,\nMatTableModule,\nMatIconModule, MatButtonModule,\nMatDialogModule,\nMatFormFieldModule,\nMatInputModule,\nFormsModule,\nReactiveFormsModule,\nMatPaginatorModule,\n],\nproviders: [\n{\nprovide: MAT_DIALOG_DATA,\nuseValue: {},\n},\n]\n})\nexport class AuthorModule { }\n
Deber\u00eda verse algo similar a esto:
"},{"location":"develop/paginated/angular/#implementar-dialogo-edicion","title":"Implementar dialogo edici\u00f3n","text":"El \u00faltimo paso, es definir la pantalla de dialogo que realizar\u00e1 el alta y modificado de los datos de un Autor
.
<div class=\"container\">\n <h1 *ngIf=\"author.id == null\">Crear autor</h1>\n <h1 *ngIf=\"author.id != null\">Modificar autor</h1>\n\n <form>\n <mat-form-field>\n <mat-label>Identificador</mat-label>\n <input type=\"text\" matInput placeholder=\"Identificador\" [(ngModel)]=\"author.id\" name=\"id\" disabled>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Nombre</mat-label>\n <input type=\"text\" matInput placeholder=\"Nombre del autor\" [(ngModel)]=\"author.name\" name=\"name\" required>\n <mat-error>El nombre no puede estar vac\u00edo</mat-error>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Nacionalidad</mat-label>\n <input type=\"text\" matInput placeholder=\"Nacionalidad del autor\" [(ngModel)]=\"author.nationality\" name=\"nationality\" required>\n <mat-error>La nacionalidad no puede estar vac\u00eda</mat-error>\n </mat-form-field>\n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onClose()\">Cerrar</button>\n <button mat-flat-button color=\"primary\" (click)=\"onSave()\">Guardar</button>\n </div>\n</div>\n
.container {\nmin-width: 350px;\nmax-width: 500px;\npadding: 20px;\n\nform {\ndisplay: flex;\nflex-direction: column;\nmargin-bottom:20px;\n}\n\n.buttons {\ntext-align: right;\n\nbutton {\nmargin-left: 10px;\n}\n}\n}\n
import { Component, Inject, OnInit } from '@angular/core';\nimport { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';\nimport { AuthorService } from '../author.service';\nimport { Author } from '../model/Author';\n\n@Component({\nselector: 'app-author-edit',\ntemplateUrl: './author-edit.component.html',\nstyleUrls: ['./author-edit.component.scss']\n})\nexport class AuthorEditComponent implements OnInit {\n\nauthor : Author;\n\nconstructor(\npublic dialogRef: MatDialogRef<AuthorEditComponent>,\n@Inject(MAT_DIALOG_DATA) public data: any,\nprivate authorService: AuthorService\n) { }\n\nngOnInit(): void {\nif (this.data.author != null) {\nthis.author = Object.assign({}, this.data.author);\n}\nelse {\nthis.author = new Author();\n}\n}\n\nonSave() {\nthis.authorService.saveAuthor(this.author).subscribe(result => {\nthis.dialogRef.close();\n}); } onClose() {\nthis.dialogRef.close();\n}\n\n}\n
Que deber\u00eda quedar algo as\u00ed:
"},{"location":"develop/paginated/angular/#conectar-con-backend","title":"Conectar con Backend","text":"Antes de seguir
Antes de seguir con este punto, debes implementar el c\u00f3digo de backend en la tecnolog\u00eda que quieras (Springboot o Nodejs). Si has empezado este cap\u00edtulo implementando el frontend, por favor accede a la secci\u00f3n correspondiente de backend para poder continuar con el tutorial. Una vez tengas implementadas todas las operaciones para este listado, puedes volver a este punto y continuar con Angular.
Una vez implementado front y back, lo que nos queda es modificar el servicio del front para que conecte directamente con las operaciones ofrecidas por el back.
author.service.tsimport { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Pageable } from '../core/model/page/Pageable';\nimport { Author } from './model/Author';\nimport { AuthorPage } from './model/AuthorPage';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class AuthorService {\n\nconstructor(\nprivate http: HttpClient\n) { }\n\ngetAuthors(pageable: Pageable): Observable<AuthorPage> {\nreturn this.http.post<AuthorPage>('http://localhost:8080/author', {pageable:pageable});\n}\n\nsaveAuthor(author: Author): Observable<void> {\nlet url = 'http://localhost:8080/author';\nif (author.id != null) url += '/'+author.id;\nreturn this.http.put<void>(url, author);\n}\n\ndeleteAuthor(idAuthor : number): Observable<void> {\nreturn this.http.delete<void>('http://localhost:8080/author/'+idAuthor);\n} }\n
"},{"location":"develop/paginated/nodejs/","title":"Listado paginado - Nodejs","text":"Ahora vamos a implementar las operaciones necesarias para ayudar al front a cubrir la funcionalidad del CRUD paginado en servidor. Recuerda que para que un listado paginado en servidor funcione, el cliente debe enviar en cada petici\u00f3n que p\u00e1gina est\u00e1 solicitando y cual es el tama\u00f1o de la p\u00e1gina, para que el servidor devuelva solamente un subconjunto de datos, en lugar de devolver el listado completo.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/paginated/nodejs/#crear-modelos","title":"Crear modelos","text":"Lo primero que vamos a hacer es crear el modelo de author para trabajar con BBDD. En la carpeta schemas creamos el archivo author.schema.js
:
import mongoose from \"mongoose\";\nimport normalize from 'normalize-mongoose';\nimport mongoosePaginate from 'mongoose-paginate-v2';\nconst { Schema, model } = mongoose;\n\nconst authorSchema = new Schema({\nname: {\ntype: String,\nrequire: true\n},\nnationality: {\ntype: String,\nrequire: true\n}\n});\nauthorSchema.plugin(normalize);\nauthorSchema.plugin(mongoosePaginate);\n\nconst AuthorModel = model('Author', authorSchema);\n\nexport default AuthorModel;\n
"},{"location":"develop/paginated/nodejs/#implementar-el-service","title":"Implementar el Service","text":"Creamos el service correspondiente author.service.js
:
import AuthorModel from '../schemas/author.schema.js';\n\nexport const getAuthors = async () => {\ntry {\nreturn await AuthorModel.find().sort('id');\n} catch (e) {\nthrow Error('Error fetching authors');\n}\n}\n\nexport const createAuthor = async (data) => {\nconst { name, nationality } = data;\ntry {\nconst author = new AuthorModel({ name, nationality });\nreturn await author.save();\n} catch (e) {\nthrow Error('Error creating author');\n}\n}\n\nexport const updateAuthor = async (id, data) => {\ntry {\nconst author = await AuthorModel.findById(id);\nif (!author) {\nthrow Error('There is no author with that Id');\n} return await AuthorModel.findByIdAndUpdate(id, data);\n} catch (e) {\nthrow Error(e);\n}\n}\n\nexport const deleteAuthor = async (id) => {\ntry {\nconst author = await AuthorModel.findById(id);\nif (!author) {\nthrow Error('There is no author with that Id');\n}\nreturn await AuthorModel.findByIdAndDelete(id);\n} catch (e) {\nthrow Error(e);\n}\n}\n\nexport const getAuthorsPageable = async (page, limit, sort) => {\nconst sortObj = {\n[sort?.property || 'name']: sort?.direction === 'DESC' ? 'DESC' : 'ASC'\n};\ntry {\nconst options = {\npage: parseInt(page) + 1,\nlimit,\nsort: sortObj\n};\n\nreturn await AuthorModel.paginate({}, options);\n} catch (e) {\nthrow Error('Error fetching authors page');\n} }\n
Como podemos observar es muy parecido al servicio de categor\u00edas, pero hemos incluido un nuevo m\u00e9todo getAuthorsPageable
. Este m\u00e9todo tendr\u00e1 como par\u00e1metros de entrada la p\u00e1gina que queramos mostrar, el tama\u00f1o de esta y las propiedades de ordenaci\u00f3n. Moongose nos proporciona el m\u00e9todo paginate que es muy parecido a find salvo que adem\u00e1s podemos pasar las opciones de paginaci\u00f3n y el solo realizar\u00e1 todo el trabajo.
Creamos el controlador author.controller.js
:
import * as AuthorService from '../services/author.service.js';\n\nexport const getAuthors = async (req, res) => {\ntry {\nconst authors = await AuthorService.getAuthors();\nres.status(200).json(\nauthors\n);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const createAuthor = async (req, res) => {\ntry {\nconst author = await AuthorService.createAuthor(req.body);\nres.status(200).json({\nauthor\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const updateAuthor = async (req, res) => {\nconst authorId = req.params.id;\ntry {\nawait AuthorService.updateAuthor(authorId, req.body);\nres.status(200).json(1);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const deleteAuthor = async (req, res) => {\nconst authorId = req.params.id;\ntry {\nconst deletedAuthor = await AuthorService.deleteAuthor(authorId);\nres.status(200).json({\nauthor: deletedAuthor\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const getAuthorsPageable = async (req, res) => {\nconst page = req.body.pageable.pageNumber || 0;\nconst limit = req.body.pageable.pageSize || 5;\nconst sort = req.body.pageable.sort || null;\n\ntry {\nconst response = await AuthorService.getAuthorsPageable(page, limit, sort);\nres.status(200).json({\ncontent: response.docs,\npageable: {\npageNumber: response.page - 1,\npageSize: response.limit\n},\ntotalElements: response.totalDocs\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Y vemos que el m\u00e9todo getAuthorsPageable lee los datos de la request, se los pasa al servicio y por \u00faltimo transforma la response con los datos obtenidos.
"},{"location":"develop/paginated/nodejs/#implementar-las-rutas","title":"Implementar las Rutas","text":"Creamos nuestro archivo de rutas author.routes.js
:
import { Router } from 'express';\nimport { check } from 'express-validator';\nimport validateFields from '../middlewares/validateFields.js';\nimport { createAuthor, deleteAuthor, getAuthors, updateAuthor, getAuthorsPageable } from '../controllers/author.controller.js';\nconst authorRouter = Router();\n\nauthorRouter.put('/:id', [\ncheck('name').not().isEmpty(),\ncheck('nationality').not().isEmpty(),\nvalidateFields\n], updateAuthor);\n\nauthorRouter.put('/', [\ncheck('name').not().isEmpty(),\ncheck('nationality').not().isEmpty(),\nvalidateFields\n], createAuthor);\n\nauthorRouter.get('/', getAuthors);\nauthorRouter.delete('/:id', deleteAuthor);\n\nauthorRouter.post('/', [\ncheck('pageable').not().isEmpty(),\ncheck('pageable.pageSize').not().isEmpty(),\ncheck('pageable.pageNumber').not().isEmpty(),\nvalidateFields\n], getAuthorsPageable)\n\nexport default authorRouter;\n
Podemos observar que si hacemos una petici\u00f3n con get a /author
nos devolver\u00e1 todos los autores. Pero si hacemos una petici\u00f3n post con el objeto pageable en el body realizaremos el listado paginado.
Finalmente en nuestro archivo index.js
vamos a a\u00f1adir el nuevo router:
...\n\nimport authorRouter from './src/routes/author.routes.js';\n\n...\n\napp.use('/author', authorRouter);\n\n...\n
"},{"location":"develop/paginated/nodejs/#probar-las-operaciones","title":"Probar las operaciones","text":"Y ahora que tenemos todo creado, ya podemos probarlo con Postman:
Por un lado creamos autores con:
** PUT /author **
** PUT /author/{id} **
{\n\"name\" : \"Nuevo autor\",\n\"nationality\" : \"Nueva nacionalidad\"\n}\n
Nos sirve para insertar Autores
nuevas (si no tienen el id informado) o para actualizar Autores
(si tienen el id informado en la URL). F\u00edjate que los datos que se env\u00edan est\u00e1n en el body como formato JSON (parte izquierda de la imagen). Si no te dar\u00e1 un error.
** DELETE /author/{id} ** nos sirve eliminar Autores
. F\u00edjate que el dato del ID que se env\u00eda est\u00e1 en el path.
Luego recuperamos los autores con el m\u00e9todo GET
(antes tienes que crear unos cuantos para poder ver un listado):
Y por \u00faltimo listamos los autores paginados:
** POST /author **
{\n\"pageable\": {\n\"pageSize\" : 4,\n\"pageNumber\" : 0,\n\"sort\" : [\n{\n\"property\": \"name\",\n\"direction\": \"ASC\"\n}\n]\n}\n}\n
"},{"location":"develop/paginated/react/","title":"Listado paginado - React","text":"Ya tienes tu primer CRUD desarrollado. \u00bfHa sido sencillo, verdad?.
Ahora vamos a implementar un CRUD un poco m\u00e1s complejo, este tiene datos paginados en servidor, esto quiere decir que no nos sirve un array de datos como en el anterior ejemplo. Para que un listado paginado en servidor funcione, el cliente debe enviar en cada petici\u00f3n que p\u00e1gina est\u00e1 solicitando y cual es el tama\u00f1o de la p\u00e1gina, para que el servidor devuelva solamente un subconjunto de datos, en lugar de devolver el listado completo.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/paginated/react/#crear-componente-author","title":"Crear componente author","text":"Lo primero que vamos a hacer es crear una carpeta llamada types dentro de src
. Aqu\u00ed crearemos los tipos de typescript. Creamos un nuevo fichero llamado Author.ts
cuyo contenido ser\u00e1 el siguiente:
export interface Author {\nid: string,\nname: string,\nnationality: string\n}\n\nexport interface AuthorResponse {\ncontent: Author[];\ntotalElements: number;\n}\n
Ahora vamos a crear un archivo de estilos que ser\u00e1 solo utilizado por la p\u00e1gina de author. Para ello dentro de la carpeta Author
creamos un archivo llamado Author.module.css
. Al llamar al archivo de esta manera React reconoce este archivo como un archivo \u00fanico para un componente y hace que sus reglas css sean m\u00e1s prioritarias, aunque por ejemplo exista una clase con el mismo nombre en el archivo index.css
.
El contenido de nuestro archivo css ser\u00e1 el siguiente:
index.css.tableActions {\nmargin-right: 20px;\ndisplay: flex;\njustify-content: flex-end;\nalign-content: flex-start;\ngap: 19px;\n}\n
Al igual que hicimos con categor\u00edas vamos a crear un nuevo componente para el formulario de alta y edici\u00f3n, para ello creamos una nueva carpeta llamada components en src/pages/Author
y dentro de esta carpeta crearemos un fichero llamado CreateAuthor.tsx
:
import { ChangeEvent, useEffect, useState } from \"react\";\nimport Button from \"@mui/material/Button\";\nimport TextField from \"@mui/material/TextField\";\nimport Dialog from \"@mui/material/Dialog\";\nimport DialogActions from \"@mui/material/DialogActions\";\nimport DialogContent from \"@mui/material/DialogContent\";\nimport DialogTitle from \"@mui/material/DialogTitle\";\nimport { Author } from \"../../../types/Author\";\n\ninterface Props {\nauthor: Author | null;\ncloseModal: () => void;\ncreate: (author: Author) => void;\n}\n\nconst initialState = {\nname: \"\",\nnationality: \"\",\n};\n\nexport default function CreateAuthor(props: Props) {\nconst [form, setForm] = useState(initialState);\n\nuseEffect(() => {\nsetForm(props?.author || initialState);\n}, [props?.author]);\n\nconst handleChangeForm = (\nevent: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>\n) => {\nsetForm({\n...form,\n[event.target.id]: event.target.value,\n});\n};\n\nreturn (\n<div>\n<Dialog open={true} onClose={props.closeModal}>\n<DialogTitle>\n{props.author ? \"Actualizar Autor\" : \"Crear Autor\"}\n</DialogTitle>\n<DialogContent>\n{props.author && (\n<TextField\nmargin=\"dense\"\ndisabled\nid=\"id\"\nlabel=\"Id\"\nfullWidth\nvalue={props.author.id}\nvariant=\"standard\"\n/>\n)}\n<TextField\nmargin=\"dense\"\nid=\"name\"\nlabel=\"Nombre\"\nfullWidth\nvariant=\"standard\"\nonChange={handleChangeForm}\nvalue={form.name}\n/>\n<TextField\nmargin=\"dense\"\nid=\"nationality\"\nlabel=\"Nacionalidad\"\nfullWidth\nvariant=\"standard\"\nonChange={handleChangeForm}\nvalue={form.nationality}\n/>\n</DialogContent>\n<DialogActions>\n<Button onClick={props.closeModal}>Cancelar</Button>\n<Button\nonClick={() =>\nprops.create({\nid: props.author ? props.author.id : \"\",\nname: form.name,\nnationality: form.nationality,\n})\n}\ndisabled={!form.name || !form.nationality}\n>\n{props.author ? \"Actualizar\" : \"Crear\"}\n</Button>\n</DialogActions>\n</Dialog>\n</div>\n);\n}\n
Como los autores tienen m\u00e1s campos hemos a\u00f1adido un poco de funcionalidad extra que no ten\u00edamos en el formulario de categor\u00edas, pero no es demasiado complicada.
Vamos a a\u00f1adir los m\u00e9todos necesarios para el crud de autores en el fichero src/redux/services/ludotecaApi.ts
:
getAllAuthors: builder.query<Author[], null>({\nquery: () => \"author\",\nprovidesTags: [\"Author\" ],\n}),\ngetAuthors: builder.query<\nAuthorResponse,\n{ pageNumber: number; pageSize: number }\n>({\nquery: ({ pageNumber, pageSize }) => {\nreturn {\nurl: \"author/\",\nmethod: \"POST\",\nbody: {\npageable: {\npageNumber,\npageSize,\n},\n},\n};\n},\nprovidesTags: [\"Author\"],\n}),\ncreateAuthor: builder.mutation({\nquery: (payload) => ({\nurl: \"/author\",\nmethod: \"PUT\",\nbody: payload,\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Author\"],\n}),\ndeleteAuthor: builder.mutation({\nquery: (id: string) => ({\nurl: `/author/${id}`,\nmethod: \"DELETE\",\n}),\ninvalidatesTags: [\"Author\"],\n}),\nupdateAuthor: builder.mutation({\nquery: (payload: Author) => ({\nurl: `author/${payload.id}`,\nmethod: \"PUT\",\nbody: payload,\n}),\ninvalidatesTags: [\"Author\", \"Game\"],\n}),\n
A\u00f1adimos tambi\u00e9n los imports, tags y exports necesarios y guardamos.
import { Author, AuthorResponse } from \"../../types/Author\";\n\ntagTypes: [\"Category\", \"Author\", \"Game\"],\n\nexport const {\nuseGetCategoriesQuery,\nuseCreateCategoryMutation,\nuseDeleteCategoryMutation,\nuseUpdateCategoryMutation,\nuseCreateAuthorMutation,\nuseDeleteAuthorMutation,\nuseGetAllAuthorsQuery,\nuseGetAuthorsQuery,\nuseUpdateAuthorMutation,\n} = ludotecaAPI;\n
Y por \u00faltimo el contenido de nuestro fichero Author.tsx
quedar\u00eda as\u00ed:
import { useEffect, useState, useContext } from \"react\";\nimport Button from \"@mui/material/Button\";\nimport TableHead from \"@mui/material/TableHead\";\nimport Table from \"@mui/material/Table\";\nimport TableBody from \"@mui/material/TableBody\";\nimport TableCell from \"@mui/material/TableCell\";\nimport TableContainer from \"@mui/material/TableContainer\";\nimport TableFooter from \"@mui/material/TableFooter\";\nimport TablePagination from \"@mui/material/TablePagination\";\nimport TableRow from \"@mui/material/TableRow\";\nimport Paper from \"@mui/material/Paper\";\nimport IconButton from \"@mui/material/IconButton\";\nimport EditIcon from \"@mui/icons-material/Edit\";\nimport ClearIcon from \"@mui/icons-material/Clear\";\nimport styles from \"./Author.module.css\";\nimport CreateAuthor from \"./components/CreateAuthor\";\nimport { ConfirmDialog } from \"../../components/ConfirmDialog\";\nimport { useAppDispatch } from \"../../redux/hooks\";\nimport { setMessage } from \"../../redux/features/messageSlice\";\nimport { BackError } from \"../../types/appTypes\";\nimport { Author as AuthorModel } from \"../../types/Author\";\nimport {\nuseDeleteAuthorMutation,\nuseGetAuthorsQuery,\nuseCreateAuthorMutation,\nuseUpdateAuthorMutation,\n} from \"../../redux/services/ludotecaApi\";\nimport { LoaderContext } from \"../../context/LoaderProvider\";\n\nexport const Author = () => {\nconst [pageNumber, setPageNumber] = useState(0);\nconst [pageSize, setPageSize] = useState(5);\nconst [total, setTotal] = useState(0);\nconst [authors, setAuthors] = useState<AuthorModel[]>([]);\nconst [openCreate, setOpenCreate] = useState(false);\nconst [idToDelete, setIdToDelete] = useState(\"\");\nconst [authorToUpdate, setAuthorToUpdate] = useState<AuthorModel | null>(\nnull\n);\n\nconst dispatch = useAppDispatch();\nconst loader = useContext(LoaderContext);\n\nconst handleChangePage = (\n_event: React.MouseEvent<HTMLButtonElement> | null,\nnewPage: number\n) => {\nsetPageNumber(newPage);\n};\n\nconst handleChangeRowsPerPage = (\nevent: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>\n) => {\nsetPageNumber(0);\nsetPageSize(parseInt(event.target.value, 10));\n};\n\nconst { data, error, isLoading } = useGetAuthorsQuery({\npageNumber,\npageSize,\n});\n\nconst [deleteAuthorApi, { isLoading: isLoadingDelete, error: errorDelete }] =\nuseDeleteAuthorMutation();\n\nconst [createAuthorApi, { isLoading: isLoadingCreate }] =\nuseCreateAuthorMutation();\n\nconst [updateAuthorApi, { isLoading: isLoadingUpdate }] =\nuseUpdateAuthorMutation();\n\nuseEffect(() => {\nloader.showLoading(\nisLoadingCreate || isLoading || isLoadingDelete || isLoadingUpdate\n);\n}, [isLoadingCreate, isLoading, isLoadingDelete, isLoadingUpdate]);\n\nuseEffect(() => {\nif (data) {\nsetAuthors(data.content);\nsetTotal(data.totalElements);\n}\n}, [data]);\n\nuseEffect(() => {\nif (errorDelete) {\nif (\"status\" in errorDelete) {\ndispatch(\nsetMessage({\ntext: (errorDelete?.data as BackError).msg,\ntype: \"error\",\n})\n);\n}\n}\n}, [errorDelete, dispatch]);\n\nuseEffect(() => {\nif (error) {\ndispatch(setMessage({ text: \"Se ha producido un error\", type: \"error\" }));\n}\n}, [error]);\n\nconst createAuthor = (author: AuthorModel) => {\nsetOpenCreate(false);\nif (author.id) {\nupdateAuthorApi(author)\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Autor actualizado correctamente\",\ntype: \"ok\",\n})\n);\nsetAuthorToUpdate(null);\n})\n.catch((err) => console.log(err));\n} else {\ncreateAuthorApi(author)\n.then(() => {\ndispatch(\nsetMessage({ text: \"Autor creado correctamente\", type: \"ok\" })\n);\nsetAuthorToUpdate(null);\n})\n.catch((err) => console.log(err));\n}\n};\n\nconst deleteAuthor = () => {\ndeleteAuthorApi(idToDelete)\n.then(() => {\nsetIdToDelete(\"\");\n})\n.catch((err) => console.log(err));\n};\n\nreturn (\n<div className=\"container\">\n<h1>Listado de Autores</h1>\n<TableContainer component={Paper}>\n<Table sx={{ minWidth: 500 }} aria-label=\"custom pagination table\">\n<TableHead\nsx={{\n\"& th\": {\nbackgroundColor: \"lightgrey\",\n},\n}}\n>\n<TableRow>\n<TableCell>Identificador</TableCell>\n<TableCell>Nombre Autor</TableCell>\n<TableCell>Nacionalidad</TableCell>\n<TableCell align=\"right\"></TableCell>\n</TableRow>\n</TableHead>\n<TableBody>\n{authors.map((author: AuthorModel) => (\n<TableRow key={author.id}>\n<TableCell component=\"th\" scope=\"row\">\n{author.id}\n</TableCell>\n<TableCell style={{ width: 160 }}>{author.name}</TableCell>\n<TableCell style={{ width: 160 }}>\n{author.nationality}\n</TableCell>\n<TableCell align=\"right\">\n<div className={styles.tableActions}>\n<IconButton\naria-label=\"update\"\ncolor=\"primary\"\nonClick={() => {\nsetAuthorToUpdate(author);\nsetOpenCreate(true);\n}}\n>\n<EditIcon />\n</IconButton>\n<IconButton\naria-label=\"delete\"\ncolor=\"error\"\nonClick={() => {\nsetIdToDelete(author.id);\n}}\n>\n<ClearIcon />\n</IconButton>\n</div>\n</TableCell>\n</TableRow>\n))}\n</TableBody>\n<TableFooter>\n<TableRow>\n<TablePagination\nrowsPerPageOptions={[5, 10, 25]}\ncolSpan={4}\ncount={total}\nrowsPerPage={pageSize}\npage={pageNumber}\nSelectProps={{\ninputProps: {\n\"aria-label\": \"rows per page\",\n},\nnative: true,\n}}\nonPageChange={handleChangePage}\nonRowsPerPageChange={handleChangeRowsPerPage}\n/>\n</TableRow>\n</TableFooter>\n</Table>\n</TableContainer>\n<div className=\"newButton\">\n<Button variant=\"contained\" onClick={() => setOpenCreate(true)}>\nNuevo autor\n</Button>\n</div>\n{openCreate && (\n<CreateAuthor\ncreate={createAuthor}\nauthor={authorToUpdate}\ncloseModal={() => {\nsetAuthorToUpdate(null);\nsetOpenCreate(false);\n}}\n/>\n)}\n{!!idToDelete && (\n<ConfirmDialog\ntitle=\"Eliminar Autor\"\ntext=\"Atenci\u00f3n si borra el autor se perder\u00e1n sus datos. \u00bfDesea eliminar el autor?\"\nconfirm={deleteAuthor}\ncloseModal={() => setIdToDelete(\"\")}\n/>\n)}\n</div>\n);\n};\n
Al tratarse de un listado paginado hemos creado dos nuevas variables en nuestro estado para almacenar la p\u00e1gina y el n\u00famero de registros a mostrar en la p\u00e1gina. Cuando cambiamos estos valores en el navegador como estas variables van como par\u00e1metro en nuestro hook
para recuperar datos autom\u00e1ticamente el listado se va a modificar.
El resto de funcionalidad es muy parecida a la de categor\u00edas.
"},{"location":"develop/paginated/springboot/","title":"Listado paginado - Spring Boot","text":"Ahora vamos a implementar las operaciones necesarias para ayudar al front a cubrir la funcionalidad del CRUD paginado en servidor. Recuerda que para que un listado paginado en servidor funcione, el cliente debe enviar en cada petici\u00f3n que p\u00e1gina est\u00e1 solicitando y cu\u00e1l es el tama\u00f1o de la p\u00e1gina, para que el servidor devuelva solamente un subconjunto de datos, en lugar de devolver el listado completo.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/paginated/springboot/#crear-modelos","title":"Crear modelos","text":"Lo primero que vamos a hacer es crear los modelos para trabajar con BBDD y con peticiones hacia el front. Adem\u00e1s, tambi\u00e9n tenemos que a\u00f1adir datos al script de inicializaci\u00f3n de BBDD, siempre respetando la nomenclatura que le hemos dado a la tabla y columnas de BBDD.
Author.javaAuthorDto.javadata.sqlpackage com.ccsw.tutorial.author.model;\n\nimport jakarta.persistence.*;\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"author\")\npublic class Author {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"name\", nullable = false)\nprivate String name;\n\n@Column(name = \"nationality\")\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
package com.ccsw.tutorial.author.model;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorDto {\n\nprivate Long id;\n\nprivate String name;\n\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
INSERT INTO category(name) VALUES ('Eurogames');\nINSERT INTO category(name) VALUES ('Ameritrash');\nINSERT INTO category(name) VALUES ('Familiar');\n\nINSERT INTO author(name, nationality) VALUES ('Alan R. Moon', 'US');\nINSERT INTO author(name, nationality) VALUES ('Vital Lacerda', 'PT');\nINSERT INTO author(name, nationality) VALUES ('Simone Luciani', 'IT');\nINSERT INTO author(name, nationality) VALUES ('Perepau Llistosella', 'ES');\nINSERT INTO author(name, nationality) VALUES ('Michael Kiesling', 'DE');\nINSERT INTO author(name, nationality) VALUES ('Phil Walker-Harding', 'US');\n
"},{"location":"develop/paginated/springboot/#implementar-tdd-pruebas","title":"Implementar TDD - Pruebas","text":"Para desarrollar todas las operaciones, empezaremos primero dise\u00f1ando las pruebas y luego implementando el c\u00f3digo necesario que haga funcionar correctamente esas pruebas. Para ir m\u00e1s r\u00e1pido vamos a poner todas las pruebas de golpe, pero realmente se deber\u00edan crear una a una e ir implementando el c\u00f3digo necesario para esa prueba. Para evitar tantas iteraciones en el tutorial las haremos todas de golpe.
Vamos a pararnos a pensar un poco que necesitamos en la pantalla. Ahora mismo nos sirve con:
Para la primera prueba que hemos descrito (consulta paginada) se necesita un objeto que contenga los datos de la p\u00e1gina a consultar. As\u00ed que crearemos una clase AuthorSearchDto
para utilizarlo como 'paginador'.
Para ello, en primer lugar, deberemos a\u00f1adir una clase que vamos a utilizar como envoltorio para las peticiones de paginaci\u00f3n en el proyecto. Hacemos esto para desacoplar la interface de Spring Boot de nuestro contrato de entrada. Crearemos esta clase en el paquete com.ccsw.tutorial.common.pagination
.
package com.ccsw.tutorial.common.pagination;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport org.springframework.data.domain.*;\n\nimport java.io.Serializable;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class PageableRequest implements Serializable {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate int pageNumber;\n\nprivate int pageSize;\n\nprivate List<SortRequest> sort;\n\npublic PageableRequest() {\n\nsort = new ArrayList<>();\n}\n\npublic PageableRequest(int pageNumber, int pageSize) {\n\nthis();\nthis.pageNumber = pageNumber;\nthis.pageSize = pageSize;\n}\n\npublic PageableRequest(int pageNumber, int pageSize, List<SortRequest> sort) {\n\nthis();\nthis.pageNumber = pageNumber;\nthis.pageSize = pageSize;\nthis.sort = sort;\n}\n\npublic int getPageNumber() {\nreturn pageNumber;\n}\n\npublic void setPageNumber(int pageNumber) {\nthis.pageNumber = pageNumber;\n}\n\npublic int getPageSize() {\nreturn pageSize;\n}\n\npublic void setPageSize(int pageSize) {\nthis.pageSize = pageSize;\n}\n\npublic List<SortRequest> getSort() {\nreturn sort;\n}\n\npublic void setSort(List<SortRequest> sort) {\nthis.sort = sort;\n}\n\n@JsonIgnore\npublic Pageable getPageable() {\n\nreturn PageRequest.of(this.pageNumber, this.pageSize, Sort.by(sort.stream().map(e -> new Sort.Order(e.getDirection(), e.getProperty())).collect(Collectors.toList())));\n}\n\npublic static class SortRequest implements Serializable {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate String property;\n\nprivate Sort.Direction direction;\n\nprotected String getProperty() {\nreturn property;\n}\n\nprotected void setProperty(String property) {\nthis.property = property;\n}\n\nprotected Sort.Direction getDirection() {\nreturn direction;\n}\n\nprotected void setDirection(Sort.Direction direction) {\nthis.direction = direction;\n}\n}\n\n}\n
Adicionalmente necesitaremos una clase para deserializar las respuestas de Page recibidas en los test que vamos a implementar. Para ello creamos la clase necesaria dentro de la fuente de la carpeta de los test
en el paquete com.ccsw.tutorial.config
. Esto solo hace falta porque necesitamos leer la respuesta paginada en el test, si no hicieramos test, no har\u00eda falta.
package com.ccsw.tutorial.config;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.data.domain.PageRequest;\nimport org.springframework.data.domain.Pageable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class ResponsePage<T> extends PageImpl<T> {\n\nprivate static final long serialVersionUID = 1L;\n\n@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)\npublic ResponsePage(@JsonProperty(\"content\") List<T> content,\n@JsonProperty(\"number\") int number,\n@JsonProperty(\"size\") int size,\n@JsonProperty(\"totalElements\") Long totalElements,\n@JsonProperty(\"pageable\") JsonNode pageable,\n@JsonProperty(\"last\") boolean last,\n@JsonProperty(\"totalPages\") int totalPages,\n@JsonProperty(\"sort\") JsonNode sort,\n@JsonProperty(\"first\") boolean first,\n@JsonProperty(\"numberOfElements\") int numberOfElements) {\n\nsuper(content, PageRequest.of(number, size), totalElements);\n}\n\npublic ResponsePage(List<T> content, Pageable pageable, long total) {\nsuper(content, pageable, total);\n}\n\npublic ResponsePage(List<T> content) {\nsuper(content);\n}\n\npublic ResponsePage() {\nsuper(new ArrayList<>());\n}\n\n}\n
Paginaci\u00f3n en Springframework
Cuando utilicemos paginaci\u00f3n en Springframework, debemos recordar que ya vienen implementados algunos objetos que podemos utilizar y que nos facilitan la vida. Es el caso de Pageable
y Page
.
Pageable
no es m\u00e1s que una interface que le permite a Spring JPA saber que p\u00e1gina se quiere buscar, cual es el tama\u00f1o de p\u00e1gina y cuales son las propiedades de ordenaci\u00f3n que se debe lanzar en la consulta.PageRequest
es una utilidad que permite crear objetos de tipo Pageable
de forma sencilla. Se utiliza mucho para codificaci\u00f3n de test.Page
no es m\u00e1s que un contenedor que engloba la informaci\u00f3n b\u00e1sica de la p\u00e1gina que se est\u00e1 consultando (n\u00famero de p\u00e1gina, tama\u00f1o de p\u00e1gina, n\u00famero total de resultados) y el conjunto de datos de la BBDD que contiene esa p\u00e1gina una vez han sido buscados y ordenados.Tambi\u00e9n crearemos una clase AuthorController
dentro del package de com.ccsw.tutorial.author
con la implementaci\u00f3n de los m\u00e9todos vac\u00edos, para que no falle la compilaci\u00f3n.
\u00a1Vamos a implementar test!
AuthorSearchDto.javaAuthorController.javaAuthorIT.javapackage com.ccsw.tutorial.author.model;\n\nimport com.ccsw.tutorial.common.pagination.PageableRequest;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorSearchDto {\n\nprivate PageableRequest pageable;\n\npublic PageableRequest getPageable() {\nreturn pageable;\n}\n\npublic void setPageable(PageableRequest pageable) {\nthis.pageable = pageable;\n}\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.data.domain.Page;\nimport org.springframework.web.bind.annotation.*;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Author\", description = \"API of Author\")\n@RequestMapping(value = \"/author\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class AuthorController {\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link AuthorDto}\n */\n@Operation(summary = \"Find Page\", description = \"Method that return a page of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.POST)\npublic Page<AuthorDto> findPage(@RequestBody AuthorSearchDto dto) {\n\nreturn null;\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Author\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody AuthorDto dto) {\n\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Author\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\n}\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport com.ccsw.tutorial.common.pagination.PageableRequest;\nimport com.ccsw.tutorial.config.ResponsePage;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.web.client.TestRestTemplate;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.http.*;\nimport org.springframework.test.annotation.DirtiesContext;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)\n@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)\npublic class AuthorIT {\n\npublic static final String LOCALHOST = \"http://localhost:\";\npublic static final String SERVICE_PATH = \"/author\";\n\npublic static final Long DELETE_AUTHOR_ID = 6L;\npublic static final Long MODIFY_AUTHOR_ID = 3L;\npublic static final String NEW_AUTHOR_NAME = \"Nuevo Autor\";\npublic static final String NEW_NATIONALITY = \"Nueva Nacionalidad\";\n\nprivate static final int TOTAL_AUTHORS = 6;\nprivate static final int PAGE_SIZE = 5;\n\n@LocalServerPort\nprivate int port;\n\n@Autowired\nprivate TestRestTemplate restTemplate;\n\nParameterizedTypeReference<ResponsePage<AuthorDto>> responseTypePage = new ParameterizedTypeReference<ResponsePage<AuthorDto>>(){};\n\n@Test\npublic void findFirstPageWithFiveSizeShouldReturnFirstFiveResults() {\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(0, PAGE_SIZE));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(TOTAL_AUTHORS, response.getBody().getTotalElements());\nassertEquals(PAGE_SIZE, response.getBody().getContent().size());\n}\n\n@Test\npublic void findSecondPageWithFiveSizeShouldReturnLastResult() {\n\nint elementsCount = TOTAL_AUTHORS - PAGE_SIZE;\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(1, PAGE_SIZE));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(TOTAL_AUTHORS, response.getBody().getTotalElements());\nassertEquals(elementsCount, response.getBody().getContent().size());\n}\n\n@Test\npublic void saveWithoutIdShouldCreateNewAuthor() {\n\nlong newAuthorId = TOTAL_AUTHORS + 1;\nlong newAuthorSize = TOTAL_AUTHORS + 1;\n\nAuthorDto dto = new AuthorDto();\ndto.setName(NEW_AUTHOR_NAME);\ndto.setNationality(NEW_NATIONALITY);\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(0, (int) newAuthorSize));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(newAuthorSize, response.getBody().getTotalElements());\n\nAuthorDto author = response.getBody().getContent().stream().filter(item -> item.getId().equals(newAuthorId)).findFirst().orElse(null);\nassertNotNull(author);\nassertEquals(NEW_AUTHOR_NAME, author.getName());\n}\n\n@Test\npublic void modifyWithExistIdShouldModifyAuthor() {\n\nAuthorDto dto = new AuthorDto();\ndto.setName(NEW_AUTHOR_NAME);\ndto.setNationality(NEW_NATIONALITY);\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + MODIFY_AUTHOR_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(0, PAGE_SIZE));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(TOTAL_AUTHORS, response.getBody().getTotalElements());\n\nAuthorDto author = response.getBody().getContent().stream().filter(item -> item.getId().equals(MODIFY_AUTHOR_ID)).findFirst().orElse(null);\nassertNotNull(author);\nassertEquals(NEW_AUTHOR_NAME, author.getName());\nassertEquals(NEW_NATIONALITY, author.getNationality());\n}\n\n@Test\npublic void modifyWithNotExistIdShouldThrowException() {\n\nlong authorId = TOTAL_AUTHORS + 1;\n\nAuthorDto dto = new AuthorDto();\ndto.setName(NEW_AUTHOR_NAME);\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + authorId, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n\n@Test\npublic void deleteWithExistsIdShouldDeleteCategory() {\n\nlong newAuthorsSize = TOTAL_AUTHORS - 1;\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + DELETE_AUTHOR_ID, HttpMethod.DELETE, null, Void.class);\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(0, TOTAL_AUTHORS));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(newAuthorsSize, response.getBody().getTotalElements());\n}\n\n@Test\npublic void deleteWithNotExistsIdShouldThrowException() {\n\nlong deleteAuthorId = TOTAL_AUTHORS + 1;\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + deleteAuthorId, HttpMethod.DELETE, null, Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n\n}\n
Cuidado con las clases de Test
Recuerda que el c\u00f3digo de aplicaci\u00f3n debe ir en src/main/java
, mientras que las clases de test deben ir en src/test/java
para que no se mezclen unas con otras y se empaquete todo en el artefacto final. En este caso AuthorIT.java
va en el directorio de test src/test/java
.
Si ejecutamos los test, el resultado ser\u00e1 7 maravillosos test que fallan su ejecuci\u00f3n. Es normal, puesto que no hemos implementado nada de c\u00f3digo de aplicaci\u00f3n para corresponder esos test.
"},{"location":"develop/paginated/springboot/#implementar-controller","title":"Implementar Controller","text":"Si recuerdas, esta capa de Controller
es la que tiene los endpoints de entrada a la aplicaci\u00f3n. Nosotros ya tenemos definidas 3 operaciones, que hemos dise\u00f1ado directamente desde los tests. Ahora vamos a implementar esos m\u00e9todos con el c\u00f3digo necesario para que los test funcionen correctamente, y teniendo en mente que debemos apoyarnos en las capas inferiores Service
y Repository
para repartir l\u00f3gica de negocio y acceso a datos.
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Author\", description = \"API of Author\")\n@RequestMapping(value = \"/author\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class AuthorController {\n\n@Autowired\nAuthorService authorService;\n@Autowired\nModelMapper mapper;\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link AuthorDto}\n */\n@Operation(summary = \"Find Page\", description = \"Method that return a page of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.POST)\npublic Page<AuthorDto> findPage(@RequestBody AuthorSearchDto dto) {\n\nPage<Author> page = this.authorService.findPage(dto);\n\nreturn new PageImpl<>(page.getContent().stream().map(e -> mapper.map(e, AuthorDto.class)).collect(Collectors.toList()), page.getPageable(), page.getTotalElements());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Author\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody AuthorDto dto) {\n\nthis.authorService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Author\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\nthis.authorService.delete(id);\n}\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport org.springframework.data.domain.Page;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorService {\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findPage(AuthorSearchDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, AuthorDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
Si te fijas, hemos trasladado toda la l\u00f3gica a llamadas al AuthorService
que hemos inyectado, y para que no falle la compilaci\u00f3n hemos creado una interface con los m\u00e9todos necesarios.
En la clase AuthorController
es donde se hacen las conversiones de cara al cliente, pasaremos de un Page<Author>
(modelo entidad) a un Page<AuthorDto>
(modelo DTO) con la ayuda del beanMapper. Recuerda que al cliente no le deben llegar modelos entidades sino DTOs.
Adem\u00e1s, el m\u00e9todo de carga findPage
ya no es un m\u00e9todo de tipo GET
, ahora es de tipo POST
porque le tenemos que enviar los datos de la paginaci\u00f3n para que Spring JPA pueda hacer su magia.
Ahora debemos implementar la siguiente capa.
"},{"location":"develop/paginated/springboot/#implementar-service","title":"Implementar Service","text":"La siguiente capa que vamos a implementar es justamente la capa que contiene toda la l\u00f3gica de negocio, hace uso del Repository
para acceder a los datos, y recibe llamadas generalmente de los Controller
.
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class AuthorServiceImpl implements AuthorService {\n\n@Autowired\nAuthorRepository authorRepository;\n/**\n * {@inheritDoc}\n */\n@Override\npublic Page<Author> findPage(AuthorSearchDto dto) {\n\nreturn this.authorRepository.findAll(dto.getPageable().getPageable());\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, AuthorDto data) {\n\nAuthor author;\n\nif (id == null) {\nauthor = new Author();\n} else {\nauthor = this.authorRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(data, author, \"id\");\nthis.authorRepository.save(author);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.authorRepository.findById(id).orElse(null) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.authorRepository.deleteById(id);\n}\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorRepository extends CrudRepository<Author, Long> {\n}\n
De nuevo pasa lo mismo que con la capa anterior, aqu\u00ed delegamos muchas operaciones de consulta y guardado de datos en AuthorRepository
. Hemos tenido que crearlo como interface para que no falle la compilaci\u00f3n. Recuerda que cuando creamos un Repository
es de gran ayuda hacerlo extender de CrudRepository<T, ID>
ya que tiene muchos m\u00e9todos implementados de base que nos pueden servir, como el delete
o el save
.
F\u00edjate tambi\u00e9n que cuando queremos copiar m\u00e1s de un dato de una clase a otra, tenemos una utilidad llamada BeanUtils
que nos permite realizar esa copia (siempre que las propiedades de ambas clases se llamen igual). Adem\u00e1s, en nuestro ejemplo hemos ignorado el 'id' para que no nos copie un null a la clase destino.
Y llegamos a la \u00faltima capa, la que est\u00e1 m\u00e1s cerca de los datos finales. Tenemos la siguiente interface:
AuthorRepository.javapackage com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorRepository extends CrudRepository<Author, Long> {\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param pageable pageable\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findAll(Pageable pageable);\n\n}\n
Si te fijas, este Repository
ya no est\u00e1 vac\u00edo como el anterior, no nos sirve con las operaciones b\u00e1sicas del CrudRepository
en este caso hemos tenido que a\u00f1adir un m\u00e9todo nuevo al que pasandole un objeto de tipo Pageable
nos devuelva una Page
.
Pues bien, resulta que la m\u00e1gina de Spring JPA en este caso har\u00e1 su trabajo y nosotros no necesitamos implementar ninguna query, Spring ya entiende que un findAll
significa que debe recuperar todos los datos de la tabla Author
(que es la tabla que tiene como generico
en CrudRepository
) y adem\u00e1s deben estar paginados ya que el m\u00e9todo devuelve un objeto tipo Page
. Nos ahorra tener que generar una sql para buscar una p\u00e1gina concreta de datos y hacer un count
de la tabla para obtener el total de resultados. Para ver otros ejemplos y m\u00e1s informaci\u00f3n, visita la p\u00e1gina de QueryMethods. Realmente se puede hacer much\u00edsimas cosas con solo escribir el nombre del m\u00e9todo, sin tener que pensar ni teclear ninguna sql.
Con esto ya lo tendr\u00edamos todo.
"},{"location":"develop/paginated/springboot/#probar-las-operaciones","title":"Probar las operaciones","text":"Si ahora ejecutamos los test jUnit, veremos que todos funcionan y est\u00e1n en verde. Hemos implementado todas nuestras pruebas y la aplicaci\u00f3n es correcta.
Aun as\u00ed, debemos realizar pruebas con el postman para ver los resultados que nos ofrece el back. Para ello, tienes que levantar la aplici\u00f3n y ejecutar las siguientes operaciones:
** POST /author **
{\n \"pageable\": {\n \"pageSize\" : 4,\n \"pageNumber\" : 0,\n \"sort\" : [\n {\n \"property\": \"name\",\n \"direction\": \"ASC\"\n }\n ]\n }\n}\n
Nos devuelve un listado paginado de Autores
. F\u00edjate que los datos que se env\u00edan est\u00e1n en el body como formato JSON (parte izquierda de la imagen). Si no env\u00edas datos con formato Pageable
, te dar\u00e1 un error. Tambi\u00e9n f\u00edjate que la respuesta es de tipo Page
. Prueba a jugar con los datos de paginaci\u00f3n e incluso de ordenaci\u00f3n. No hemos programado ninguna SQL pero Spring hace su magia. ** PUT /author **
** PUT /author/{id} **
{\n \"name\" : \"Nuevo autor\",\n \"nationality\" : \"Nueva nacionalidad\"\n}\n
Nos sirve para insertar Autores
nuevas (si no tienen el id informado) o para actualizar Autores
(si tienen el id informado en la URL). F\u00edjate que los datos que se env\u00edan est\u00e1n en el body como formato JSON (parte izquierda de la imagen). Si no te dar\u00e1 un error.
** DELETE /author/{id} ** nos sirve eliminar Autores
. F\u00edjate que el dato del ID que se env\u00eda est\u00e1 en el path.
Ahora nos ponemos con la pantalla de autores y vamos a realizar los cambios para poder realizar un paginado en la tabla de autores, adem\u00e1s de realizar los cambios oportunos para poder a\u00f1adir, editar y borrar autores.
"},{"location":"develop/paginated/vuejs/#acciones-posibles","title":"Acciones posibles","text":""},{"location":"develop/paginated/vuejs/#anadir-una-fila","title":"A\u00f1adir una fila","text":"Para poder a\u00f1adir una fila, vamos a tener que a\u00f1adir al componente de dialog de adici\u00f3n un nuevo campo que ser\u00e1 la nacionalidad habiendo quitado los que hab\u00edamos copiado del cat\u00e1logo dejando finalmente solo dos: el nombre y la nacionalidad.
Veremos el estado del c\u00f3digo en el apartado de borrado.
"},{"location":"develop/paginated/vuejs/#editar-una-fila","title":"Editar una fila","text":"A la hora de editar una fila, modificaremos la columna de \u201cedad\u201d para reutilizarla con la nacionalidad, reutilizaremos la columna de \u201cnombre\u201d tal cual est\u00e1 y borraremos las dem\u00e1s exceptuando la de opciones que ah\u00ed pondremos el bot\u00f3n para el borrado.
Veremos el estado del c\u00f3digo en el apartado de borrado.
"},{"location":"develop/paginated/vuejs/#borrar-una-fila","title":"Borrar una fila","text":"Y, por \u00faltimo, haremos lo mismo que hicimos en la pantalla de categor\u00edas, que es a\u00f1adir la funci\u00f3n delete despu\u00e9s del dialog de confirmaci\u00f3n.
El estado del c\u00f3digo ahora mismo quedar\u00eda as\u00ed:
<template>\n <q-page padding>\n <q-table\n hide-bottom\n :rows=\"authorsData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Cat\u00e1logo\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n >\n <template v-slot:top>\n <q-btn flat round color=\"primary\" icon=\"add\" @click=\"showAdd = true\" />\n </template>\n <template v-slot:body=\"props\">\n <q-tr :props=\"props\">\n <q-td key=\"id\" :props=\"props\">{{ props.row.id }}</q-td>\n <q-td key=\"name\" :props=\"props\">\n {{ props.row.name }}\n <q-popup-edit\n v-model=\"props.row.name\"\n title=\"Cambiar nombre\"\n v-slot=\"scope\"\n >\n <q-input\n v-model=\"scope.value\"\n dense\n autofocus\n counter\n @keyup.enter=\"editRow(props, scope, 'name')\"\n >\n <template v-slot:append>\n <q-icon name=\"edit\" />\n </template>\n </q-input>\n </q-popup-edit>\n </q-td>\n <q-td key=\"nationality\" :props=\"props\">\n {{ props.row.nationality }}\n <q-popup-edit\n v-model=\"props.row.nationality\"\n title=\"Cambiar nacionalidad\"\n v-slot=\"scope\"\n >\n <q-input\n v-model=\"scope.value\"\n dense\n autofocus\n counter\n @keyup.enter=\"editRow(props, scope, 'nationality')\"\n >\n <template v-slot:append>\n <q-icon name=\"edit\" />\n </template>\n </q-input>\n </q-popup-edit>\n </q-td>\n <q-td key=\"options\" :props=\"props\">\n <q-btn\n flat\n round\n color=\"negative\"\n icon=\"delete\"\n @click=\"showDeleteDialog(props.row)\"\n />\n </q-td>\n </q-tr>\n </template>\n </q-table>\n <q-dialog v-model=\"showDelete\" persistent>\n <q-card>\n <q-card-section class=\"row items-center\">\n <q-icon\n name=\"delete\"\n size=\"sm\"\n color=\"negative\"\n @click=\"showDelete = true\"\n />\n <span class=\"q-ml-sm\">\n \u00bfEst\u00e1s seguro de que quieres borrar este elemento?\n </span>\n </q-card-section>\n\n <q-card-actions align=\"right\">\n <q-btn flat label=\"Cancelar\" color=\"primary\" v-close-popup />\n <q-btn\n flat\n label=\"Confirmar\"\n color=\"primary\"\n v-close-popup\n @click=\"deleteAuthor\"\n />\n </q-card-actions>\n </q-card>\n </q-dialog>\n <q-dialog v-model=\"showAdd\">\n <q-card style=\"width: 300px\" class=\"q-px-sm q-pb-md\">\n <q-card-section>\n <div class=\"text-h6\">Nuevo autor</div>\n </q-card-section>\n\n <q-item-label header>Nombre</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"badge\" />\n </q-item-section>\n <q-item-section>\n <q-input dense v-model=\"authorToAdd.name\" autofocus />\n </q-item-section>\n </q-item>\n\n <q-item-label header>Nacionalidad</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"flag\" />\n </q-item-section>\n <q-item-section>\n <q-input\n dense\n v-model=\"authorToAdd.nationality\"\n autofocus\n @keyup.enter=\"addAuthor\"\n />\n </q-item-section>\n </q-item>\n\n <q-card-actions align=\"right\" class=\"text-primary\">\n <q-btn flat label=\"Cancelar\" v-close-popup />\n <q-btn flat label=\"A\u00f1adir autor\" v-close-popup @click=\"addAuthor\" />\n </q-card-actions>\n </q-card>\n </q-dialog>\n </q-page>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useFetch, whenever } from '@vueuse/core';\n\nconst columns = [\n { name: 'id', align: 'left', label: 'ID', field: 'id', sortable: true },\n {\n name: 'name',\n align: 'left',\n label: 'Nombre',\n field: 'name',\n sortable: true,\n },\n {\n name: 'nationality',\n align: 'left',\n label: 'Nacionalidad',\n field: 'nationality',\n sortable: true,\n },\n { name: 'options', align: 'left', label: 'Options', field: 'options' },\n];\nconst pagination = {\n page: 1,\n rowsPerPage: 0,\n};\nconst newAuthor = {\n name: '',\n nationality: '',\n};\n\nconst authorsData = ref([]);\nconst showDelete = ref(false);\nconst showAdd = ref(false);\nconst selectedRow = ref({});\nconst authorToAdd = ref({ ...newAuthor });\n\nconst getAuthors = () => {\n const { data } = useFetch('http://localhost:8080/author').get().json();\n whenever(data, () => (authorsData.value = data.value));\n};\ngetAuthors();\n\nconst showDeleteDialog = (item: any) => {\n selectedRow.value = item;\n showDelete.value = true;\n};\n\nconst addAuthor = async () => {\n await useFetch('http://localhost:8080/author', {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(authorToAdd.value),\n })\n .put()\n .json();\n\n getAuthors();\n authorToAdd.value = newAuthor;\n showAdd.value = false;\n};\n\nconst editRow = (props: any, scope: any, field: any) => {\n const row = {\n name: props.row.name,\n nationality: props.row.nationality,\n };\n row[field] = scope.value;\n scope.set();\n editAuthor(props.row.id, row);\n};\n\nconst editAuthor = async (id: string, reqBody: any) => {\n await useFetch(`http://localhost:8080/author/${id}`, {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(reqBody),\n })\n .put()\n .json();\n\n getAuthors();\n};\n\nconst deleteAuthor = async () => {\n await useFetch(`http://localhost:8080/author/${selectedRow.value.id}`, {\n method: 'DELETE',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n })\n .delete()\n .json();\n\n getAuthors();\n};\n</script>\n
"},{"location":"develop/paginated/vuejs/#paginado","title":"Paginado","text":"Lo primero que tenemos que hacer es usar las nuevas caracter\u00edsticas de nuestra tabla para poder a\u00f1adir datos y as\u00ed hacer funcionar el paginado correctamente.
Lo primero que vamos a hacer es cambiar el objeto de paginaci\u00f3n para que tenga lo siguiente:
const pagination = ref({\n page: 0,\n rowsPerPage: 5,\n rowsNumber: 10,\n});\n
Y debido a que la tabla y el back requieren de formatos diferentes para la paginaci\u00f3n, vamos a tener que realizar una funci\u00f3n que formatee el objeto para enviarlo al back. Esta funci\u00f3n ser\u00e1, m\u00e1s o menos, as\u00ed:
const formatPageableBody = (props: any) => {\n return {\n pageable: {\n pageSize:\n props.pagination.rowsPerPage !== 0\n ? props.pagination.rowsPerPage\n : props.pagination.rowsNumber,\n pageNumber: props.pagination.page - 1,\n sort: [\n {\n property: 'name',\n direction: 'ASC',\n },\n ],\n },\n };\n};\n
Tal y como podemos ver, se realiza una condici\u00f3n en el formato ya que, si el usuario selecciona que quiere ver todas las filas de golpe el valor de dicha variable ser\u00e1 0 y el back necesitar\u00e1 el valor del n\u00famero m\u00e1ximo de filas para que nosotros recibamos todas.
Y por \u00faltimo vamos a hacer que la funci\u00f3n de recibir los datos reciba por par\u00e1metro el paginado (siempre habr\u00e1 uno por defecto) y que cuando todo haya ido bien se actualice la paginaci\u00f3n local.
const updateLocalPagination = (props: any) => {\n pagination.value.page = props.pagination.page;\n pagination.value.rowsPerPage = props.pagination.rowsPerPage;\n};\n\nconst getAuthors = (props: any = { pagination: pagination.value }) => {\n const { data } = useFetch('http://localhost:8080/author', {\n method: 'POST',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(formatPageableBody(props)),\n })\n .post()\n .json();\n whenever(data, () => {\n updateLocalPagination(props);\n authorsData.value = data.value.content;\n pagination.value.rowsNumber = data.value.totalElements;\n });\n};\n
Importante
En la primera de las peticiones (y si quieres en las dem\u00e1s tambi\u00e9n) se ha de recoger el atributo de filas totales y setearlo en el objeto de paginaci\u00f3n con el nombre de rowsNumber
. Esto se realiza en la zona subrayada anterior.
Y por \u00faltimo, hacemos que se realicen peticiones siempre que el usuario cambie par\u00e1metros de la tabla, como el cambio de p\u00e1gina o el cambio de filas mostradas. Esto se realiza a\u00f1adiendo a la creaci\u00f3n de la tabla la siguiente l\u00ednea:
<q-table\n :rows=\"authorsData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Autores\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n @request=\"getAuthors\"\n >\n
Con estos cambios, la pantalla deber\u00eda funcionar correctamente con el paginado funcionando y todas sus funciones b\u00e1sicas.
"},{"location":"install/angular/","title":"Entorno de desarrollo - Angular","text":""},{"location":"install/angular/#instalacion-de-herramientas","title":"Instalaci\u00f3n de herramientas","text":"Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Lo primero de todo es instalar el IDE para el desarrollo front.
Te recomiendo utilizar Visual Studio Code, en un IDE que a nosotros nos gusta mucho y tiene muchos plugins configurables. Puedes entrar en su p\u00e1gina y descargarte la versi\u00f3n estable.
"},{"location":"install/angular/#nodejs","title":"Nodejs","text":"El siguiente paso ser\u00e1 instalar el motor de Nodejs. Entrando en la p\u00e1gina de descargas e instalando la \u00faltima versi\u00f3n estable. Con esta herramienta podremos compilar y ejecutar aplicaciones basadas en Javascript y Typescript, e instalar y gestionar las dependencias de las aplicaciones.
"},{"location":"install/angular/#angular-cli","title":"Angular CLI","text":"Por \u00faltimo vamos a instalar una capa de gesti\u00f3n por encima de Nodejs que nos ayudar\u00e1 en concreto con la funcionalida de Angular. En concreto instalaremos el CLI de Angular. Para poder instalarlo, tan solo hay que abrir una consola de msdos y ejecutar el comando y Nodejs ya har\u00e1 el resto:
npm install -g @angular/cli\n
Y con esto ya tendremos todo instalado, listo para empezar a crear los proyectos.
"},{"location":"install/angular/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":"La mayor\u00eda de los proyectos con Angular en los que trabajamos normalmente, suelen ser proyectos web usando las librer\u00edas mas comunes de angular, como Angular Material.
Crear un proyecto de Angular es muy sencillo si tienes instalado el CLI de Angular. Lo primero abrir una consola de msdos y posicionarte en el directorio raiz donde quieres crear tu proyecto Angular, y ejecutamos lo siguiente:
ng new tutorial --strict=false\n
El propio CLI nos ir\u00e1 realizando una serie de preguntas.
Would you like to add Angular routing? (y/N)
Preferiblemente: y
Which stylesheet format would you like to use?
Preferiblemente: SCSS
En el caso del tutorial como vamos a tener dos proyectos para nuestra aplicaci\u00f3n (front y back), para poder seguir correctamente las explicaciones, voy a renombrar la carpeta para poder diferenciarla del otro proyecto. A partir de ahora se llamar\u00e1 client
.
Info
Si durante el desarrollo del proyecto necesitas a\u00f1adir nuevos m\u00f3dulos al proyecto Angular, ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm update
y descargar\u00e1 e instalar\u00e1 las nuevas dependencias.
Para arrancar el proyecto, tan solo necesitamos ejecutar en consola el siguiente comando siempre dentro del directorio creado por Angular CLI:
ng serve\n
Angular compilar\u00e1 el c\u00f3digo fuente, levantar\u00e1 un servidor local al que podremos acceder por defecto mediante la URL: http://localhost:4200/
Y ya podemos empezar a trabajar con Angular.
Info
Cuando se trata de un proyecto nuevo recien descargado de un repositorio, recuerda que ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm update
y descargar\u00e1 e instalar\u00e1 las nuevas dependencias.
Comandos de Angular CLI
Si necesitas m\u00e1s informaci\u00f3n sobre los comandos que ofrece Angular CLI para poder crear aplicaciones, componentes, servicios, etc. los tienes disponibles en: https://angular.io/cli#command-overview
"},{"location":"install/nodejs/","title":"Entorno de desarrollo - Nodejs","text":""},{"location":"install/nodejs/#instalacion-de-herramientas","title":"Instalaci\u00f3n de herramientas","text":"Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Lo primero de todo es instalar el IDE para el desarrollo en node si no lo has hecho previamente.
Te recomiendo utilizar Visual Studio Code, en un IDE que a nosotros nos gusta mucho y tiene muchos plugins configurables. Puedes entrar en su p\u00e1gina y descargarte la versi\u00f3n estable.
"},{"location":"install/nodejs/#nodejs","title":"Nodejs","text":"El siguiente paso ser\u00e1 instalar el motor de Nodejs. Entrando en la p\u00e1gina de descargas e instalando la \u00faltima versi\u00f3n estable. Con esta herramienta podremos compilar y ejecutar aplicaciones basadas en Javascript y Typescript, e instalar y gestionar las dependencias de las aplicaciones.
"},{"location":"install/nodejs/#mongodb-atlas","title":"MongoDB Atlas","text":"Tambi\u00e9n necesitaremos crear una cuenta de MongoDB Atlas para crear nuestra base de datos MongoDB en la nube.
Accede a la URL, registrate gr\u00e1tis con cualquier cuenta de correo y elige el tipo de cuenta gratuita \ud83d\ude0a:
Configura el cluster a tu gusto (selecciona la opci\u00f3n gratuita en el cloud que m\u00e1s te guste) y ya tendr\u00edas una BBDD en cloud para hacer pruebas. Lo primero que se muestra es el dashboard que se ver\u00e1 algo similar a lo siguiente:
A continuaci\u00f3n, pulsamos en la opci\u00f3n Database
del men\u00fa y, sobre el Cluster0
, pulsamos tambi\u00e9n el bot\u00f3n Connect
. Se nos abrir\u00e1 el siguiente pop-up donde tendremos que elegir la opci\u00f3n Connect your application
:
En el siguiente paso es donde se nos muestra la url que tendremos que utilizar en nuestra aplicaci\u00f3n. La copiamos y guardamos para m\u00e1s tarde:
Pulsamos Close
y la BBDD ya estar\u00eda creada.
Nota: Al crear la base de datos te aprecer\u00e1 un aviso para introducir tu IP en la whitelist, aseg\u00farate no estar en la VPN cuando lo hagas, de lo contrario no tendr\u00e1s conexi\u00f3n posteriormente.
"},{"location":"install/nodejs/#herramientas-para-pruebas","title":"Herramientas para pruebas","text":"Para poder probar las operaciones de negocio que vamos a crear, lo mejor es utilizar una herramienta que permita realizar llamadas a API Rest. Para ello te propongo utilizar Postman, en su versi\u00f3n web o en su versi\u00f3n desktop, cualquiera de las dos sirve.
Con esta herramienta se puede generar peticiones GET, POST, PUT, DELETE contra el servidor y pasarle par\u00e1metros de forma muy sencilla y visual. Lo usaremos durante el tutorial.
"},{"location":"install/nodejs/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":"Para la creaci\u00f3n de nuestro proyecto Node nos crearemos una carpeta con el nombre que deseemos y accederemos a ella con la consola de comandos de windows. Una vez dentro ejecutaremos el siguiente comando para inicializar nuestro proyecto con npm:
npm init\n
Cuando ejecutemos este comando nos pedir\u00e1 los valores para distintos par\u00e1metros de nuestro proyecto. Aconsejo solo cambiar el nombre y el resto dejarlo por defecto pulsando enter para cada valor. Una vez que hayamos terminado se nos habr\u00e1 generado un fichero package.json
que contendr\u00e1 informaci\u00f3n b\u00e1sica de nuestro proyecto. Dentro de este fichero tendremos que a\u00f1adir un nuevo par\u00e1metro type
con el valor module
, esto nos permitir\u00e1 importar nuestros m\u00f3dulos con el est\u00e1ndar ES:
{\n\"name\": \"tutorialNode\",\n\"version\": \"1.0.0\",\n\"description\": \"\",\n\"main\": \"index.js\",\n\"scripts\": {\n\"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n},\n\"keywords\": [],\n\"author\": \"\",\n\"license\": \"ISC\",\n\"type\": \"module\"\n}\n
"},{"location":"install/nodejs/#instalar-dependencias","title":"Instalar dependencias","text":"En ese fichero aparte de la informaci\u00f3n de nuestro proyecto tambi\u00e9n tendremos que a\u00f1adir las dependencias que usara nuestra aplicaci\u00f3n.
Para a\u00f1adir las dependencias, desde la consola de comandos y situados en la misma carpeta donde se haya creado el fichero package.json
vamos a teclear los siguientes comandos:
npm i express\nnpm i express-validator\nnpm i dotenv\nnpm i mongoose\nnpm i mongoose-paginate-v2\nnpm i normalize-mongoose\nnpm i cors\nnpm i nodemon --save-dev\n
Tambi\u00e9n podr\u00edamos haber instalado todas a la vez en dos l\u00edneas:
npm i express express-validator dotenv mongoose mongoose-paginate-v2 normalize-mongoose cors\nnpm i nodemon --save-dev\n
Las dependencias que acabamos de instalar son las siguientes:
Ahora podemos fijarnos en nuestro fichero package.json
donde se habr\u00e1n a\u00f1adido dos nuevos par\u00e1metros: dependencies
y devDependencies
. La diferencia est\u00e1 en que las devDependencies
solo se utilizar en la fase de desarrollo de nuestro proyecto y las dependencies
se utilizar\u00e1n en todo momento.
A partir de aqu\u00ed ya podemos abrir Visual Studio Code, el IDE recomendado, y abrir la carpeta del proyecto para poder configurarlo y programarlo. Lo primero ser\u00e1 configurar el acceso con la BBDD.
Para ello vamos a crear en la ra\u00edz de nuestro proyecto una carpeta config
dentro de la cual crearemos un archivo llamado db.js
. Este archivo exportar\u00e1 una funci\u00f3n que recibe una url de nuestra BBDD y la conectar\u00e1 con mongoose. El contenido de este archivo debe ser el siguiente:
import mongoose from 'mongoose';\n\nconst connectDB = async (url) => {\n\ntry {\nawait mongoose.connect(url);\nconsole.log('BBDD connected');\n} catch (error) {\nthrow new Error('Error initiating BBDD:' + error);\n}\n}\n\nexport default connectDB;\n
Ahora vamos a crear en la ra\u00edz de nuestro proyecto un archivo con el nombre .env
. Este archivo tendr\u00e1 las variables de entorno de nuestro proyecto. Es aqu\u00ed donde pondremos la url que obtuvimos al crear nuestra BBDD. As\u00ed pues, crearemos una nueva variable y pegaremos la URL. Tambi\u00e9n vamos a configurar el puerto del servidor.
MONGODB_URL='mongodb+srv://<user>:<pass>@<url>.mongodb.net/?retryWrites=true&w=majority'\nPORT='8080'\n
"},{"location":"install/nodejs/#arrancar-el-proyecto","title":"Arrancar el proyecto","text":"Con toda esa configuraci\u00f3n, ahora ya podemos crear nuestra p\u00e1gina inicial. Dentro del fichero package.json
, en concreto en el contenido de main
vemos que nos indica el valor de index.js
. Este ser\u00e1 el punto de entrada a nuestra aplicaci\u00f3n, pero este fichero todav\u00eda no existe, as\u00ed que lo crearemos con el siguiente contenido:
import express from 'express';\nimport cors from 'cors';\nimport connectDB from './config/db.js';\nimport { config } from 'dotenv';\n\nconfig();\nconnectDB(process.env.MONGODB_URL);\nconst app = express();\n\napp.use(cors({\norigin: '*'\n}));\n\napp.listen(process.env.PORT, () => {\nconsole.log(`Server running on port ${process.env.PORT}`);\n});\n
El funcionamiento de este c\u00f3digo, resumiendo mucho, es el siguiente. Configurar la base de datos, configurar el CORS para que posteriormente podamos realizar peticiones desde el front y crea un servidor con express en el puerto 8080
.
Pero antes, para poder ejecutar nuestro servidor debemos modificar el fichero package.json
, y a\u00f1adir un script de arranque. A\u00f1adiremos la siguiente l\u00ednea:
\"dev\": \"nodemon ./index.js\"\n
Y ahora s\u00ed, desde la consola de comando ya podemos ejecutar el siguiente comando:
npm run dev\n
y ya podremos ver en la consola como la aplicaci\u00f3n ha arrancado correctamente con el mensaje que le hemos a\u00f1adido.
"},{"location":"install/react/","title":"Entorno de desarrollo - React","text":""},{"location":"install/react/#instalacion-de-herramientas","title":"Instalaci\u00f3n de herramientas","text":"Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Lo primero de todo es instalar el IDE para el desarrollo front.
Te recomiendo utilizar Visual Studio Code, en un IDE que a nosotros nos gusta mucho y tiene muchos plugins configurables. Puedes entrar en su p\u00e1gina y descargarte la versi\u00f3n estable.
"},{"location":"install/react/#nodejs","title":"Nodejs","text":"El siguiente paso ser\u00e1 instalar el motor de Nodejs. Entrando en la p\u00e1gina de descargas e instalando la \u00faltima versi\u00f3n estable. Con esta herramienta podremos compilar y ejecutar aplicaciones basadas en Javascript y Typescript, e instalar y gestionar las dependencias de las aplicaciones.
"},{"location":"install/react/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":"Hasta ahora para la generaci\u00f3n de un proyecto React se ha utilizado la herramienta \u201ccreate-react-app\u201d pero \u00faltimamente se usa m\u00e1s vite debido a su velocidad para desarrollar y su optimizaci\u00f3n en tiempos de construcci\u00f3n. En realidad, para realizar nuestro proyecto da igual una herramienta u otra m\u00e1s all\u00e1 de un poco de configuraci\u00f3n, pero para este proyecto elegiremos vite por su velocidad.
Para generar nuestro proyecto react con Vite abrimos una consola de Windows y escribimos lo siguiente en la carpeta donde queramos localizar nuestro proyecto:
npm create vite@latest\n
Con esto se nos lanzara un wizard para la creaci\u00f3n de nuestro proyecto donde elegiremos el nombre del proyecto (en mi caso ludoteca-react), el framework (react evidentemente) y en la variante elegiremos typescript. Tras estos pasos instalaremos las dependencias base de nuestro proyecto. Primero accedemos a la ra\u00edz y despu\u00e9s ejecutaremos el comando install de npm.
cd ludoteca-react\n
npm install\n
\u00f3
npm i\n
"},{"location":"install/react/#arrancar-el-proyecto","title":"Arrancar el proyecto","text":"Para arrancar el proyecto, tan solo necesitamos ejecutar en consola el siguiente comando siempre dentro del directorio creado por Vite:
npm run dev\n
Vite compilar\u00e1 el c\u00f3digo fuente, levantar\u00e1 un servidor local al que podremos acceder por defecto mediante la URL: http://localhost:5173/
Y ya podemos empezar a trabajar en nuestro proyecto React.
Info
Cuando se trata de un proyecto nuevo recien descargado de un repositorio, recuerda que ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm update
y descargar\u00e1 e instalar\u00e1 las nuevas dependencias.
Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Necesitamos instalar un IDE de desarrollo, en nuestro caso ser\u00e1 Eclipse IDE y la m\u00e1quina virtual de java necesaria para ejecutar el c\u00f3digo. Recomendamos Java 19, que es la versi\u00f3n con la que est\u00e1 desarrollado y probado el tutorial.
Para instalar el IDE deber\u00e1s acceder a la web de Eclipse IDE y descargarte la \u00faltima versi\u00f3n del instalador. Una vez lo ejecutes te pedir\u00e1 el tipo de instalaci\u00f3n que deseas instalar. Por lo general con la de \"Eclipse IDE for Java Developers\" es suficiente. Con esta versi\u00f3n ya tiene integrado los plugins de Maven y Git.
"},{"location":"install/springboot/#instalacion-de-java","title":"Instalaci\u00f3n de Java","text":"Una vez instalado eclipse, debes asegurarte que est\u00e1 usando por defecto la versi\u00f3n de Java 19 y para ello deber\u00e1s instalarla. Desc\u00e1rgala del siguiente enlace. Es posible que te pida un registro de correo, utiliza el email que quieras (corporativo o personal). Revisa bien el enlace para buscar y descargar la versi\u00f3n 19 para Windows:
Ya solo queda a\u00f1adir Java al Eclipse. Para ello, abre el men\u00fa Window -> Preferences
:
y dentro de la secci\u00f3n Java - Installed JREs
a\u00f1ade la versi\u00f3n que acabas de descargar, siempre pulsando el bot\u00f3n Add...
y buscando el directorio home
de la instalaci\u00f3n de Java. Adem\u00e1s, la debes marcar como default
.
Como complemento al Eclipse, con el fin de crear c\u00f3digo homog\u00e9neo y mantenible, vamos a configurar el formateador de c\u00f3digo autom\u00e1tico.
Para ello de nuevo abrimos el men\u00fa Window -> Preferences
, nos vamos a la secci\u00f3n Formatter
de Java:
Aqu\u00ed crearemos un nuevo perfil heredando la configuraci\u00f3n por defecto.
En el nuevo perfil configuramos que se use espacios en vez de tabuladores con sangrado de 4 caracteres.
Una vez cofigurado el nuevo formateador debemos activar que se aplique en el guardado. Para ello volvemos acceder a las preferencias de Eclipse y nos dirigimos a la sub secci\u00f3n Save Actions
del la secci\u00f3n Editor
nuevamente de Java.
Aqu\u00ed aplicamos la configuraci\u00f3n deseada.
"},{"location":"install/springboot/#herramientas-para-pruebas","title":"Herramientas para pruebas","text":"Para poder probar las operaciones de negocio que vamos a crear, lo mejor es utilizar una herramienta que permita realizar llamadas a API Rest. Para ello te propongo utilizar Postman, en su versi\u00f3n web o en su versi\u00f3n desktop, cualquiera de las dos sirve.
Con esta herramienta se puede generar peticiones GET, POST, PUT, DELETE contra el servidor y pasarle par\u00e1metros de forma muy sencilla y visual. Lo usaremos durante el tutorial.
"},{"location":"install/springboot/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":"La mayor\u00eda de los proyectos Spring Boot en los que trabajamos normalmente, suelen ser proyectos web sencillos con pocas dependencias de terceros o incluso proyectos basados en micro-servicios que ejecutan pocas acciones. Ahora tienes que preparar el proyecto SpringBoot,
"},{"location":"install/springboot/#crear-con-initilizr","title":"Crear con Initilizr","text":"Vamos a ver como configurar paso a paso un proyecto de cero, con las librer\u00edas que vamos a utilizar en el tutorial.
"},{"location":"install/springboot/#como-usarlo","title":"\u00bfComo usarlo?","text":"Spring ha creado una p\u00e1gina interactiva que permite crear y configurar proyectos en diferentes lenguajes, con diferentes versiones de Spring Boot y a\u00f1adi\u00e9ndole los m\u00f3dulos que nosotros queramos.
Esta p\u00e1gina est\u00e1 disponible desde Spring Initializr. Para seguir el ejemplo del tutorial, entraremos en la web y seleccionaremos los siguientes datos:
Esto nos generar\u00e1 un proyecto que ya vendr\u00e1 configurado con Spring Web, JPA y H2 para crear una BBDD en memoria de ejemplo con la que trabajaremos durante el tutorial.
"},{"location":"install/springboot/#importar-en-eclipse","title":"Importar en eclipse","text":"El siguiente paso, es descomprimir el proyecto generado e importarlo como proyecto Maven. Abrimos el eclipse, pulsamos en File \u2192 Import y seleccionamos Existing Maven Projects
. Buscamos el proyecto y le damos a importar.
Lo primero que vamos a hacer es a\u00f1adir las dependencias a algunas librer\u00edas que vamos a utilizar. Abriremos el fichero pom.xml
que nos ha generado el Spring Initilizr y a\u00f1adiremos las siguientes l\u00edneas:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\nxsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n<modelVersion>4.0.0</modelVersion>\n\n<parent>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-parent</artifactId>\n<version>3.0.4</version>\n<relativePath/> <!-- lookup parent from repository -->\n</parent>\n\n<groupId>com.ccsw</groupId>\n<artifactId>tutorial</artifactId>\n<version>0.0.1-SNAPSHOT</version>\n<name>tutorial</name>\n<description>Tutorial project for Spring Boot</description>\n\n<properties>\n<java.version>19</java.version>\n</properties>\n\n<dependencies>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-data-jpa</artifactId>\n</dependency>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-web</artifactId>\n</dependency>\n\n<dependency>\n<groupId>org.springdoc</groupId>\n<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>\n<version>2.0.3</version>\n</dependency>\n<dependency>\n<groupId>org.hibernate</groupId>\n<artifactId>hibernate-validator</artifactId>\n<version>8.0.0.Final</version>\n</dependency>\n<dependency>\n<groupId>org.modelmapper</groupId>\n<artifactId>modelmapper</artifactId>\n<version>3.1.1</version>\n</dependency>\n<dependency>\n<groupId>com.h2database</groupId>\n<artifactId>h2</artifactId>\n<scope>runtime</scope>\n</dependency>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-test</artifactId>\n<scope>test</scope>\n</dependency>\n</dependencies>\n\n<build>\n<plugins>\n<plugin>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-maven-plugin</artifactId>\n</plugin>\n</plugins>\n</build>\n\n</project>\n
Hemos a\u00f1adido las dependencias de que nos permite utilizar Open API para documentar nuestras APIs. Adem\u00e1s de esa dependencia, hemos a\u00f1adido una utilidad para hacer mapeos entre objetos y para configurar los servicios Rest. M\u00e1s adelante veremos como se utilizan.
"},{"location":"install/springboot/#configurar-librerias","title":"Configurar librer\u00edas","text":"El siguiente punto es crear las clases de configuraci\u00f3n para las librer\u00edas que hemos a\u00f1adido. Para ello vamos a crear un package de configuraci\u00f3n general de la aplicaci\u00f3n com.ccsw.tutorial.config
donde crearemos una clase que llamaremos ModelMapperConfig
y usaremos para configurar el bean de ModelMapper.
package com.ccsw.tutorial.config;\n\nimport org.modelmapper.ModelMapper;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\n/**\n * @author ccsw\n *\n */\n@Configuration\npublic class ModelMapperConfig {\n\n@Bean\npublic ModelMapper getModelMapper() {\n\nreturn new ModelMapper();\n}\n\n}\n
Esta configuraci\u00f3n nos permitir\u00e1 luego hacer transformaciones entre objetos de forma muy sencilla. Ya lo iremos viendo m\u00e1s adelante. Listo, ya podemos empezar a desarrollar nuestros servicios.
"},{"location":"install/springboot/#configurar-la-bbdd","title":"Configurar la BBDD","text":"Por \u00faltimo, vamos a dejar configurada la BBDD en memoria. Para ello crearemos un fichero, de momento en blanco, dentro de src/main/resources/
:
Este fichero no puede estar vac\u00edo, ya que si no dar\u00e1 un error al arrancar. Puedes a\u00f1adirle la siguiente query (que no hace nada) para que pueda arrancar el proyecto.
select 1 from dual;
Y ahora le vamos a decir a Spring Boot que la BBDD ser\u00e1 en memoria, que use un motor de H2 y que la cree autom\u00e1ticamente desde el modelo y que utilice el fichero data.sql
(por defecto) para cargar datos en esta. Para ello hay que configurar el fichero application.properties
que est\u00e1 dentro de src/main/resources/
:
#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n
"},{"location":"install/springboot/#arrancar-el-proyecto","title":"Arrancar el proyecto","text":"Por \u00faltimo ya solo nos queda arrancar el proyecto creado. Para ello buscaremos la clase TutorialApplication.java
(o la clase principal del proyecto) y con el bot\u00f3n derecho seleccionaremos Run As \u2192 Java Application. La aplicaci\u00f3n al estar basada en Spring Boot arrancar\u00e1 internamente un Tomcat embebido donde se despliega el proyecto.
Si hab\u00e9is seguido el tutorial la aplicaci\u00f3n estar\u00e1 disponible en http://localhost:8080, aunque de momento a\u00fan no tenemos nada accesible y nos dar\u00e1 una p\u00e1gina de error Whitelabel Error Page
, error 404. Eso significa que el Tomcat embedido nos ha contestado pero no sabe que devolvernos porque no hemos implementado todav\u00eda nada.
Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Lo primero de todo es instalar el IDE para el desarrollo front.
Te recomiendo utilizar Visual Studio Code, en un IDE que a nosotros nos gusta mucho y tiene muchos plugins configurables. Puedes entrar en su p\u00e1gina y descargarte la versi\u00f3n estable.
"},{"location":"install/vuejs/#nodejs","title":"Nodejs","text":"El siguiente paso ser\u00e1 instalar el motor de Nodejs. Entrando en la p\u00e1gina de descargas e instalando la \u00faltima versi\u00f3n estable. Con esta herramienta podremos compilar y ejecutar aplicaciones basadas en Javascript y Typescript, e instalar y gestionar las dependencias de las aplicaciones.
"},{"location":"install/vuejs/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":""},{"location":"install/vuejs/#generar-scaffolding","title":"Generar scaffolding","text":"Lo primero que haremos ser\u00e1 generar un proyecto mediante la librer\u00eda \"Quasar CLI\" para ello ejecutamos en consola el siguiente comando:
npm init quasar\n
Este comando detectar\u00e1 si tienes el CLI de Quasar instalado y en caso contrario te preguntar\u00e1 si deseas instalarlo. Debes responder que s\u00ed, que lo instale.
Una vez instalado, aparecer\u00e1 un wizzard en el que se ir\u00e1n preguntando una serie de datos para crear la aplicaci\u00f3n:
Y tendremos que elegir lo siguiente:
What would you like to build?
App with Quasar CLI, let's go!
Project folder
tutorial-vue
Pick Quasar version
Quasar v2 (Vue 3 | latest and greatest)
Pick script type
Typescript
Pick Quasar App CLI variant
Quasar App CLI with Vite
Package name
tutorial-vue
Project product name
Ludoceta Tan
Project description
Proyecto tutorial Ludoteca Tan
Author
<por defecto el email>
Pick a Vue component style
Composition API
Pick your CSS preprocessor
Sass with SCSS syntax
Check the features needed for your project
ESLint
Pick an ESLint preset
Prettier
Install project dependencies?
Yes, use npm
Cuando todo ha terminado el propio scaffolding te dice lo que tienes que hacer para poner el proyecto en marcha y ver lo que te ha generado, solo tienes que seguir esos pasos.
Accedes al directorio que acabas de crear y ejecutas
npx quasar dev\n
Esto arrancar\u00e1 el servidor y abrir\u00e1 un navegador en el puerto 9000 donde se mostrar\u00e1 la template creada.
Tambi\u00e9n podemos navegar nosotros mismos a la URL http://localhost:9000/
.
Info
Si durante el desarrollo del proyecto necesitas a\u00f1adir nuevos m\u00f3dulos al proyecto Vue.js, ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm install y descargar\u00e1 e instalar\u00e1 las nuevas dependencias..
Proyecto descargado
Cuando se trata de un proyecto nuevo recien descargado de un repositorio, recuerda que ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm install y descargar\u00e1 e instalar\u00e1 las nuevas dependencias.
"}]} \ No newline at end of file +{"config":{"lang":["es"],"separator":"[\\s\\-]+","pipeline":["stopWordFilter"]},"docs":[{"location":"","title":"Bienvenido!","text":"Si est\u00e1s leyendo esto es porque tienes mucha fuerza de voluntad y unas enormes ganas de aprender a desarrollar con el stack tecnol\u00f3gico de CCA (Java Spring Boot, Nodejs, Angular, React) o porque te han mandando hacer este tutorial en tu etapa de formaci\u00f3n. En cualquier caso, te agradecemos el esfuerzo que est\u00e1s haciendo y te deseamos suerte .
Por favor, si detectas que hay algo incorrecto en el tutorial, que no funciona o que est\u00e1 mal escrito, contacta con nosotros para que podamos solventarlo para futuras lecturas. Escr\u00edbenos un issue aqu\u00ed.
"},{"location":"#que-vamos-a-hacer","title":"\u00bfQue vamos a hacer?","text":"Durante este tutorial, vamos a crear una aplicaci\u00f3n web paso a paso con Spring Boot o Nodejs para la parte servidora y con Angular o React para la parte frontal. Intentar\u00e9 comentar todo lo m\u00e1s detallado posible, pero si echas en falta alguna explicaci\u00f3n por favor, escr\u00edbenos un issue aqu\u00ed para que podamos a\u00f1adirla.
"},{"location":"#como-lo-vamos-a-hacer","title":"\u00bfComo lo vamos a hacer?","text":"En primer lugar te comentar\u00e9 brevemente las herramientas que usaremos en el tutorial y la forma de instalarlas (altamente recomendado). Luego veremos un vistazo general de lo que vamos a construir para que tengas un contexto general de la aplicaci\u00f3n. Y por \u00faltimo desarrollaremos paso a paso el backend y el frontend de la aplicaci\u00f3n.
Durante todo el tutorial intentar\u00e9 dar unas pautas y consejos de buenas pr\u00e1cticas que todos deber\u00edamos adoptar, en la medida de lo posible, para homogeneizar el desarrollo de todos los proyectos.
Adem\u00e1s para cada uno de los cap\u00edtulos que lo requieran, voy a desdoblar el tutorial por cada una de las tecnolog\u00edas disponibles para que vayas construyendo con la que m\u00e1s c\u00f3modo te sientas.
As\u00ed que antes de empezar debes elegir bien con que tecnolog\u00edas vas a comenzar de las que tengo disponibles. Puedes volver a este tutorial m\u00e1s adelante por si he ido a\u00f1adiendo nuevas tecnolog\u00edas.
Elige UNA tecnolog\u00eda de backend y UNA tecnolog\u00eda de frontend y completa el tutorial con esas dos tecnolog\u00edas. No mezcles ni hagas todas las tecnolog\u00edas a la vez ya que si no, te vas a volver loco.
"},{"location":"#hay-pre-requisitos","title":"\u00bfHay pre-requisitos?","text":"No es obligado tener ning\u00fan conocimiento previo, pero es altamente recomendable que al menos conozcas lo b\u00e1sico de las tecnolog\u00edas que vamos a ver en el tutorial. Si no tienes ni idea, ni has oido hablar de las tecnolog\u00edas que has seleccionado para el tutorial, te sugiero que visites los itinerarios formativos y realices los cursos de nivel Esencial
. De momento tenemos estos itinerarios:
Una vez hayas hecho los cursos esenciales, ya puedes volver y continuar con este tutorial. Repito que no es obligado, si ya tienes conocimientos previos de las tecnolog\u00edas no es necesario que hagas los cursos. Cuando termines el tutorial, ya puedes realizar el resto de cursos de otros niveles.
"},{"location":"#y-luego-que","title":"\u00bfY luego qu\u00e9?","text":"Pues al final del tutorial, expondremos unos ejercicios pr\u00e1cticos para que los resuelvas tu mismo, aplicando los conocimientos adquiridos en el tutorial. Para ver si has comprendido correctamente todo lo aqu\u00ed descrito.
No te preocupes, no es un examen
"},{"location":"#recomendaciones","title":"Recomendaciones","text":"Te recomiendo que leas todo el tutorial, que no te saltes ning\u00fan punto y si se hace referencia a los anexos, que los visites y los leas tambi\u00e9n. Si tan solo copias y pegas, no ser\u00e1s capaz de hacer el \u00faltimo ejercicio por ti mismo. Debes leer y comprender lo que se est\u00e1 haciendo.
Adem\u00e1s, los anexos est\u00e1n ah\u00ed por algo, sirven para completar informaci\u00f3n y para que conozcas los motivos por los que estamos programando as\u00ed el tutorial. Por favor, \u00e9chales un ojo tambi\u00e9n cuando te lo indique.
"},{"location":"#por-ultimo-no-te-olvides","title":"Por \u00faltimo, \u00a1no te olvides!","text":"Cuando lo tengas todo listo, por favor no te olvides de subir los proyectos a alg\u00fan repositorio Github propio y av\u00edsanos para que podamos echarle un ojo y darte sugerencias y feedback .
"},{"location":"exercise/","title":"Ahora hazlo tu!","text":"Ahora vamos a ver si has comprendido bien el tutorial. Voy a poner dos ejercicios uno m\u00e1s sencillo que el otro para ver si eres capaz de llevarlos a cabo. \u00a1Vamos alla, mucha suerte!
Nuestro amigo Ernesto Esvida ya tiene disponible su web para gestionar su cat\u00e1logo de juegos, autores y categor\u00edas, pero todav\u00eda le falta un poco m\u00e1s para poder hacer buen uso de su ludoteca. As\u00ed que nos ha pedido dos funcionalidades extra.
"},{"location":"exercise/#gestion-de-clientes","title":"Gesti\u00f3n de clientes","text":""},{"location":"exercise/#requisitos","title":"Requisitos","text":"Por un lado necesita poder tener una base de datos de sus clientes. Para ello nos ha pedido que si podemos crearle una pantalla de CRUD sencilla, al igual que hicimos con las categor\u00edas donde \u00e9l pueda dar de alta a sus clientes.
Nos ha pasado un esquema muy sencillo de lo que quiere, tan solo quiere guardar un listado de los nombres de sus clientes para tenerlos fichados, y nos ha hecho un par de pantallas sencillas muy similares a Categor\u00edas.
Un listado sin filtros de ning\u00fan tipo ni paginaci\u00f3n.
Un formulario de edici\u00f3n / alta, cuyo \u00fanico dato editable sea el nombre. Adem\u00e1s, la \u00fanica restricci\u00f3n que nos ha pedido es que NO podamos dar de alta a un cliente con el mismo nombre que otro existente. As\u00ed que deberemos comprobar el nombre, antes de guardar el cliente.
"},{"location":"exercise/#consejos","title":"Consejos","text":"Para empezar te dar\u00e9 unos consejos:
Por otro lado, quiere hacer uso de su cat\u00e1logo de juegos y de sus clientes, y quiere saber que juegos ha prestado a cada cliente. Para ello nos ha pedido una p\u00e1gina bastante compleja donde se podr\u00e1 consultar diferente informaci\u00f3n y se permitir\u00e1 realizar el pr\u00e9stamo de los juegos.
Nos ha pasado el siguiente boceto y requisitos:
La pantalla tendr\u00e1 dos zonas:
Al pulsar el bot\u00f3n de Nuevo pr\u00e9stamo
se abrir\u00e1 una pantalla donde se podr\u00e1 ingresar la siguiente informaci\u00f3n, toda ella obligatoria:
Las validaciones son sencillas aunque laboriosas:
Para empezar te dar\u00e9 unos consejos:
Page
.Specifications
son muy \u00fatiles, pero en este caso deber\u00e1s implementar otro tipo de operaciones, no te sirve solo con la operaci\u00f3n de igualdad :
, que ya vimos en el tutorial.Si has llegado a este punto es porque ya tienes terminado el tutorial. Por favor no te olvides de subir los proyectos a alg\u00fan repositorio Github propio (puedes revisar el anexo Tutorial b\u00e1sico de Git) y av\u00edsarnos para que podamos echarle un ojo y darte sugerencias y feedback .
"},{"location":"thanks/","title":"Agradecimientos!","text":"Antes de empezar quer\u00edamos dar las gracias a todos los que hab\u00e9is participado de manera directa o indirecta en la elaboraci\u00f3n de este tutorial, y a todos aquellos que lo hab\u00e9is sufrido haciendolo.
De verdad
G R A C I A S\n
"},{"location":"thanks/#colaboradores","title":"Colaboradores","text":"Menci\u00f3n especial a las personas que han participado en el tutorial ya sea como testers, como promotores o como desarrolladores, por orden temporal de colaboraci\u00f3n:
Nuestro amigo Ernesto Esvida es muy aficionado a los juegos de mesa y desde muy peque\u00f1o ha ido coleccionando muchos juegos. Hasta tal punto que ha decidido regentar una Ludoteca.
Como la colecci\u00f3n de juegos era suya personal, toda la informaci\u00f3n del cat\u00e1logo de juegos la ten\u00eda perfectamente clasificado en fichas de cart\u00f3n. Pero ahora que va abrir su propio negocio, necesita digitalizar esa informaci\u00f3n y hacerla m\u00e1s accesible.
Como es un buen amigo de la infancia, hemos decidido ayudar a Ernesto y colaborar haciendo una peque\u00f1a aplicaci\u00f3n web que le sirva de cat\u00e1logo de juegos. Es m\u00e1s o menos el mismo sistema que estaba utilizando, pero esta vez en digital.
Por cierto, la Ludoteca al final se va a llamar Ludoteca T\u00e1n.
Info
Las im\u00e1genes que aparecen a continuaci\u00f3n son mockups o dise\u00f1os de alambre de las pantallas que vamos a desarrollar durante el tutorial. No quiere decir que el estilo final de las pantallas deba ser as\u00ed, ni mucho menos. Es simplemente una forma sencilla de ejemplificar como debe quedar m\u00e1s o menos una pantalla.
"},{"location":"usecases/#estructura-de-un-proyecto-web","title":"Estructura de un proyecto Web","text":"En todas las aplicaciones web modernas y los proyectos en los que trabajamos se pueden diferenciar, de forma general, tres grandes bloques funcionales, como se muestra en la imagen inferior.
El funcionamiento es muy sencillo y difiere de las aplicaciones instalables que se ejecuta todo en una misma m\u00e1quina o servidor.
As\u00ed pues el flujo normal de una aplicaci\u00f3n ser\u00eda el siguiente:
Dicho esto, por lo general necesitaremos un m\u00ednimo de dos proyectos para desarrollar una aplicaci\u00f3n:
Por un lado tendremos un proyecto Frontend que se ejecutar\u00e1 en un servidor web de ficheros est\u00e1ticos, tipo Apache. Este proyecto ser\u00e1 c\u00f3digo javascript, css y html, que se renderizar\u00e1 en el navegador Web y que realizar\u00e1 ciertas operaciones sencillas y validaciones en local y llamadas a nuestro servidor backend para ejecutar las operaciones de negocio.
Por otro lado tendremos un proyecto Backend que se ejecutar\u00e1 en un servidor de aplicaciones, tipo Tomcat o Node. Este proyecto tendr\u00e1 la l\u00f3gica de negocio de las operaciones, el acceso a los datos de la BBDD y cualquier integraci\u00f3n con servicios de terceros. La forma de exponer estas operaciones de negocio ser\u00e1 mediante endpoints de acceso, en concreto llamadas tipo REST.
Pueden haber otros tipos de proyectos dentro de la aplicaci\u00f3n, sobretodo si est\u00e1n basados en microservicios o tienen componentes batch, pero estos proyectos no vamos a verlos en el tutorial.
A partir de ahora, para que sea m\u00e1s sencillo acceder al tutorial, diferenciaremos las tecnolog\u00edas en el men\u00fa mediante los siguientes colores:
Consejo
Como norma cada uno de los proyectos que componen la aplicaci\u00f3n, deber\u00eda estar conectado a un repositorio de c\u00f3digo diferente para poder evolucionar y trabajar con cada uno de ellos de forma aislada sin afectar a los dem\u00e1s. As\u00ed adem\u00e1s podemos tener equipos aislados que trabajen con cada uno de los proyectos por separado.
Info
Durante todo el tutorial, voy a intentar separar la construcci\u00f3n del proyecto Frontend de la construcci\u00f3n del proyecto Backend. Elige una tecnolog\u00eda para cada una de las capas y utiliza siempre la misma en todos los apartados del tutorial.
"},{"location":"usecases/#diseno-de-bd","title":"** Dise\u00f1o de BD **","text":"Para el proyecto que vamos a crear vamos a modelizar y gestionar 3 entidades: CATEGORY
, AUTHOR
y GAME
.
La entidad CATEGORY
estar\u00e1 compuesta por los siguientes campos:
GAME
)La entidad AUTHOR
estar\u00e1 compuesta por los siguientes campos:
GAME
)Para la entidad GAME
, Ernesto nos ha comentado que la informaci\u00f3n que est\u00e1 guardando en sus fichas es la siguiente:
Comenzaremos con un caso b\u00e1sico que cumpla las siguientes premisas: un juego pertenece a una categor\u00eda y ha sido creado por un \u00fanico autor.
Modelando este contexto quedar\u00eda algo similar a esto:
"},{"location":"usecases/#diseno-de-pantallas","title":"** Dise\u00f1o de pantallas **","text":"Deber\u00edamos construir tres pantallas de mantenimiento CRUD (Create, Read, Update, Delete) y una pantalla de Login general para activar las acciones de administrador. M\u00e1s o menos las pantallas deber\u00edan quedar as\u00ed:
"},{"location":"usecases/#listado-de-categorias","title":"Listado de categor\u00edas","text":""},{"location":"usecases/#edicion-de-categoria","title":"Edici\u00f3n de categor\u00eda","text":""},{"location":"usecases/#listado-de-autores","title":"Listado de autores","text":""},{"location":"usecases/#edicion-de-autor","title":"Edici\u00f3n de autor","text":""},{"location":"usecases/#listado-de-juegos","title":"Listado de juegos","text":""},{"location":"usecases/#edicion-de-juego","title":"Edici\u00f3n de juego","text":""},{"location":"usecases/#diseno-funcional","title":"** Dise\u00f1o funcional **","text":"Por \u00faltimo vamos a definir un poco la funcionalidad b\u00e1sica que Ernesto necesita para iniciar su negocio.
"},{"location":"usecases/#aspectos-generales","title":"Aspectos generales","text":"usuario b\u00e1sico
es el usuario an\u00f3nimo que accede a la web sin registrar. Solo tiene permisos para mostrar listados ** usuario administrador
es el usuario que se registra en la aplicaci\u00f3n. Puede realizar las operaciones de alta, edici\u00f3n y borradoPor defecto cuando entras en la aplicaci\u00f3n tendr\u00e1s los privilegios de un usuario b\u00e1sico
hasta que el usuario haga un login correcto con el usuario / password admin
/ admin
. En ese momento pasara a ser un usuario administrador
y podr\u00e1 realizar operaciones de alta, baja y modificaci\u00f3n.
La estructura general de la aplicaci\u00f3n ser\u00e1:
Sign in
Al pulsar sobre la funcionalidad de Sign in
aparecer\u00e1 una ventana modal que preguntar\u00e1 usuario y password. Esto realizar\u00e1 una llamada al backend, donde se validar\u00e1 si el usuario es correcto.
sessionStorage
para futuras peticionesTodas las operaciones del backend que permitan crear, modificar o borrar datos, deber\u00e1n estar securizadas para que no puedan ser accedidas sin haberse autenticado previamente.
"},{"location":"usecases/#crud-de-categorias","title":"CRUD de Categor\u00edas","text":"Al acceder a esta pantalla se mostrar\u00e1 un listado de las categor\u00edas que tenemos en la BD. La tabla no tiene filtros, puesto que tiene muy pocos registros. Tampoco estar\u00e1 paginada.
En la tabla debe aparecer:
Debajo de la tabla aparecer\u00e1 un bot\u00f3n para crear nuevas categor\u00edas (solo en el caso de que el usuario tenga permisos).
Crear
Al pulsar el bot\u00f3n de crear se deber\u00e1 abrir una ventana modal con dos inputs:
Identificador
Nombre
Todos los datos obligatorios se deber\u00e1n comprobar que son v\u00e1lidos antes de guardarlo en BD. Dos botones en la parte inferior de la ventana permitir\u00e1n al usuario cerrar la ventana o guardar los datos en la BD.
Editar
Al pulsar el icono de editar se deber\u00e1 abrir una ventana modal utilizando el mismo componente que la ventana de Crear
pero con los dos campos rellenados con los datos de BD.
Borrar
Si el usuario pulsa el bot\u00f3n de borrar, se deber\u00e1 comprobar si esa categor\u00eda tiene alg\u00fan Juego
asociado. En caso de tenerlo se le informar\u00e1 al usuario de que dicha categor\u00eda no se puede eliminar por tener asociado un juego. En caso de no estar asociada, se le preguntar\u00e1 al usuario mediante un mensaje de confirmaci\u00f3n si desea eliminar la categor\u00eda. Solo en caso de que la respuesta sea afirmativa, se lanzar\u00e1 el borrado f\u00edsico de la categor\u00eda en BD.
Al acceder a esta pantalla se mostrar\u00e1 un listado de los autores que tenemos en la BD. La tabla no tiene filtros pero deber\u00e1 estar paginada en servidor.
En la tabla debe aparecer:
Debajo de la tabla aparecer\u00e1 un bot\u00f3n para crear nuevos autores (solo en el caso de que el usuario tenga permisos).
Crear
Al pulsar el bot\u00f3n de crear se deber\u00e1 abrir una ventana modal con tres inputs:
Identificador
Nombre
Nacionalidad
Todos los datos obligatorios se deber\u00e1n comprobar que son v\u00e1lidos antes de guardarlo en BD. Dos botones en la parte inferior de la ventana permitir\u00e1n al usuario cerrar la ventana o guardar los datos en la BD.
Editar
Al pulsar el icono de editar se deber\u00e1 abrir una ventana modal utilizando el mismo componente que la ventana de Crear
pero con los tres campos rellenados con los datos de BD.
Borrar
Si el usuario pulsa el bot\u00f3n de borrar, se deber\u00e1 comprobar si ese autor tiene alg\u00fan Juego
asociado. En caso de tenerlo se le informar\u00e1 al usuario de que dicho autor no se puede eliminar por tener asociado un juego. En caso de no estar asociado, se le preguntar\u00e1 al usuario mediante un mensaje de confirmaci\u00f3n si desea eliminar el autor. Solo en caso de que la respuesta sea afirmativa, se lanzar\u00e1 el borrado f\u00edsico de la categor\u00eda en BD.
Al acceder a esta pantalla se mostrar\u00e1 un listado de los juegos disponibles en el cat\u00e1logo de la BD. Esta tabla debe contener filtros en la parte superior, pero no debe estar paginada.
Se debe poder filtrar por:
contengan
el texto buscadoDos botones permitir\u00e1n realizar el filtrado de juegos (lanzando una nueva consulta a BD) o limpiar los filtros seleccionados (lanzando una consulta con los filtros vac\u00edos).
En la tabla debe aparecer a modo de fichas. No hace falta que sea exactamente igual a la maqueta, no es un requisito determinar un ancho general de ficha por lo que pueden caber 2,3 o x fichas en una misma fila, depender\u00e1 del programador. Pero todas las fichas deben tener el mismo ancho:
Los juegos no se pueden eliminar, pero si se puede editar si el usuario pulsa en alguna de las fichas (solo en el caso de que el usuario tenga permisos).
Debajo de la tabla aparecer\u00e1 un bot\u00f3n para crear nuevos juegos (solo en el caso de que el usuario tenga permisos).
Crear
Al pulsar el bot\u00f3n de crear se deber\u00e1 abrir una ventana modal con cinco inputs:
Identificador
T\u00edtulo
Edad
Categor\u00eda
Autor
Todos los datos obligatorios se deber\u00e1n comprobar que son v\u00e1lidos antes de guardarlo en BD. Dos botones en la parte inferior de la ventana permitir\u00e1n al usuario cerrar la ventana o guardar los datos en la BD.
Editar
Al pulsar en una de las fichas con un click simple, se deber\u00e1 abrir una ventana modal utilizando el mismo componente que la ventana de Crear
pero con los cinco campos rellenados con los datos de BD.
Cada vez se tiende m\u00e1s a utilizar repositorios de c\u00f3digo Git y, aunque no sea objeto de este tutorial Springboot-Angular, queremos hacer un resumen muy b\u00e1sico y sencillo de como utilizar Git.
En el mercado existen multitud de herramientas para gestionar repositorios Git, podemos utilizar cualquiera de ellas, aunque desde devonfw se recomienda utilizar Git SCM. Adem\u00e1s, existen tambi\u00e9n multitud de servidores de c\u00f3digo que implementan repositorios Git, como podr\u00edan ser GitHub, GitLab, Bitbucket, etc. Todos ellos trabajan de la misma forma, as\u00ed que este resumen servir\u00e1 para todos ellos.
Info
Este anexo muestra un resumen muy sencillo y b\u00e1sico de los comandos m\u00e1s comunes que se utilizan en Git. Para ver detalles m\u00e1s avanzados o un tutorial completo te recomiendo que leas la guia de Atlassian.
"},{"location":"appendix/git/#funcionamiento-basico","title":"Funcionamiento b\u00e1sico","text":"Existen dos conceptos en Git que debes tener muy claros: las ramas y los repositorios. Vamos a ver como funciona cada uno de ellos.
"},{"location":"appendix/git/#ramas","title":"Ramas","text":"Por un lado tenemos las ramas
de Git. El repositorio puede tener tantas ramas como se quiera, pero por lo general debe existir una rama maestra a menudo llamada develop o master, y luego muchas ramas con cada una de las funcionalidades desarrolladas.
Las ramas siempre se deben crear a partir de una rama (en el ejemplo llamaremos develop), con una foto concreta y determinada de esa rama. Esta rama deber\u00e1 tener un nombre que describa lo que va a contener esa rama (en el ejemplo feature/xxx). Y por lo general, esa rama se mergear\u00e1
con otra rama del repositorio, que puede ser la rama de origen o cualquier otra (en el ejemplo ser\u00e1 con la rama origen develop).
As\u00ed pues, podemos tener algo as\u00ed:
Las acciones de crear ramas y mergear ramas est\u00e1n explicadas m\u00e1s abajo. En este punto solo es necesario que seas conocedor de:
El otro concepto que debe queda claro, es el de repositorios. Por defecto, en Git, se trabaja con el repositorio local, en el que puedes crear ramas, modificar c\u00f3digo, mergear, etc. pero todos esos cambios que se hagan, ser\u00e1n todos en local, nadie m\u00e1s tendr\u00e1 acceso.
Tambi\u00e9n existe el repositorio remoto, tambi\u00e9n llamado origin
. Este repositorio es el que todos los integrantes del equipo utilizan como referencia. Existen acciones de Git que permite sincronizar los repositorios.
En este punto solo es necesario que seas conocedor de:
pull request
o merge request
(depende de la aplicaci\u00f3n usada para Git).En la Gu\u00eda r\u00e1pida puedes ver m\u00e1s detalle de estas acciones pero por lo general:
pull request
o merge request
contra la rama maestra que quieras modificar.A continuaci\u00f3n vamos a describir estos mismos conceptos y acciones que hemos visto, pero m\u00e1s en profundidad para que veas como trabaja internamente Git. No es necesario que leas este punto, aunque es recomendable.
"},{"location":"appendix/git/#estructuras-y-flujo-de-trabajo","title":"Estructuras y flujo de trabajo","text":"Lo primero que debes conocer de Git es su funcionamiento b\u00e1sico de flujo de trabajo. Tu repositorio local est\u00e1 compuesto por tres \"estructuras\" que contienen los archivos y los cambios de los ficheros del repositorio.
Existen operaciones que nos permiten a\u00f1adir o borrar ficheros dentro de cada una de las estructuras desde otra estructura.
As\u00ed pues, los comandos b\u00e1sicos dentro de nuestro repositorio local son los siguientes.
"},{"location":"appendix/git/#add-y-commmit","title":"add y commmit","text":"Puedes registrar los cambios realizados en tu working directory
y a\u00f1adirlos al staging area
usando el comando
git add <filename>\n
o si quieres a\u00f1adir todos los ficheros modificados git add .\n
Este es el primer paso en el flujo de trabajo b\u00e1sico. Una vez tenemos los cambios registrados en el staging area
podemos hacer un commit y persistirlos dentro del local repository
mediante el comando
git commit -m \"<Commit message>\"\n
A partir de ese momento, los ficheros modificados y a\u00f1adidos al local repository
se han persistido y se han a\u00f1adido a tu HEAD
, aunque todav\u00eda siguen estando el local, no lo has enviado a ning\u00fan repositorio remoto.
De la misma manera que se han a\u00f1adido ficheros a staging area
o a local repository
, podemos retirarlos de estas estructuras y volver a recuperar los ficheros que ten\u00edamos anteriormente en el working directory
. Por ejemplo, si nos hemos equivocado al incluir ficheros en un commit o simplemente queremos deshacer los cambios que hemos realizado bastar\u00eda con lanzar el comando
git reset --hard\n
o si queremos volver a un commit concreto git reset <COMMIT>\n
"},{"location":"appendix/git/#trabajo-con-ramas","title":"Trabajo con ramas","text":"Para complicarlo todo un poco m\u00e1s, el trabajo con git siempre se realiza mediante ramas. Estas ramas nos sirven para desarrollar funcionalidades aisladas unas de otras y poder hacer mezclas de c\u00f3digo de unas ramas a otras. Las ramas m\u00e1s comunes dentro de git suelen ser:
producci\u00f3n
.master
.merge
a la rama develop
.Siempre que trabajes con ramas debes tener en cuenta que al empezar tu desarrollo debes partir de una versi\u00f3n actualizada de la rama develop
, y al terminar tu desarrollo debes solicitar un merge
contra develop
, para que tu funcionalidad est\u00e9 incorporada en la rama de desarrollo.
Crear ramas en local es tan sencillo como ejecutar este comando:
git checkout -b <NOMBRE_RAMA>\n
Eso nos crear\u00e1 una rama con el nombre que le hayamos dicho y mover\u00e1 el Working Directory
a dicha rama.
Para cambiar de una rama a otra en local tan solo debemos ejecutar el comando:
git checkout <NOMBRE_RAMA>\n
La rama debe existir, sino se quejar\u00e1 de que no encuentra la rama. Este comando nos mover\u00e1 el Working Directory
a la rama que le hayamos indicado. Si tenemos cambios en el Staging Area
que no hayan sido movidos al Local Repository
NO nos permitir\u00e1 movernos a la rama ya que perder\u00edamos los cambios. Antes de poder movernos debemos resetear
los cambios o bien commitearlos
.
Hasta aqu\u00ed es todo m\u00e1s o menos sencillo, trabajamos con nuestro repositorio local, creamos ramas, commiteamos o reseteamos cambios de c\u00f3digo, pero todo esto lo hacemos en local. Ahora necesitamos que esos cambios se distribuyan y puedan leerlos el resto de integrantes de nuestro equipo.
Aqu\u00ed es donde entra en juego los repositorios remotos.
Aqu\u00ed debemos tener MUY en cuenta que el c\u00f3digo que vamos a publicar en remoto SOLO es posible publicarlo desde el Local Repository
. Es decir que para poder subir c\u00f3digo a remote antes debemos a\u00f1adirlo a Staging Area
y hacer un commit para persistirlo en el Local Repository
.
Antes de empezar a tocar c\u00f3digo del proyecto podemos crear un Local Repository
vac\u00edo o bien bajarnos un proyecto que ya exista en un Remote Repository
. Esta \u00faltima opci\u00f3n es la m\u00e1s normal.
Para bajarnos un proyecto desde remoto tan solo hay que ejecutar el comando:
git clone <REMOTE_URL>\n
Esto te crear\u00e1 una carpeta con el nombre del proyecto y dentro se descargar\u00e1 la estructura completa del repositorio y te mover\u00e1 al Working Directory
todo el c\u00f3digo de la rama por defecto para ese repositorio.
El env\u00edo de datos a un Remote Repository
tan solo es posible realizarlo desde Local Repository
(por lo que antes deber\u00e1s commitear cambios all\u00ed), y se debe ejecutar el comando:
git push origin\n
"},{"location":"appendix/git/#actualizar-y-fusionar","title":"actualizar y fusionar","text":"En ocasiones (bastante habitual) ser\u00e1 necesario descargarse los cambios de un Remote Repository
para poder trabajar con la \u00faltima versi\u00f3n. Para ello debemos ejecutar el comando:
git pull\n
El propio git
realizar\u00e1 la fusi\u00f3n local del c\u00f3digo remoto con el c\u00f3digo de tu Working Directory
. Pero en ocasiones, si se ha modificado el mismo fichero en remoto y en local, se puede producir un Conflicto. No pasa nada, tan solo tendr\u00e1s que abrir dicho fichero en conflicto y resolverlo manualmente dejando el c\u00f3digo mezclado correcto.
Tambi\u00e9n es posible que el c\u00f3digo que queramos actualizar est\u00e9 en otra rama, si lo que necesitamos es fusionar el c\u00f3digo de otra rama con la rama actual, nos situaremos en la rama destino y ejecutaremos el comando:
git merge <RAMA_ORIGEN>\n
Esto har\u00e1 lo mismo que un pull en local y fusionar\u00e1 el c\u00f3digo de una rama en otra. Tambi\u00e9n es posible que se produzcan conflictos que deber\u00e1s resolver de forma manual.
"},{"location":"appendix/git/#merge-request","title":"Merge Request","text":"Ya por \u00faltimo, como estamos trabajando con ramas, lo \u00fanico que hacemos es subir y bajar ramas, pero en alg\u00fan momento alguien debe fusionar el contenido de una rama en la rama develop
, release
o master
, que son las ramas principales.
Se podr\u00eda directamente usar el comando merge para eso, pero en la mayor\u00eda de los repositorios no esta permitido subir el c\u00f3digo de una rama principal, por lo que no podr\u00e1s hacer un merge y subirlo. Para eso existe otra opci\u00f3n que es la de Merge Request
.
Esta opci\u00f3n permite a un usuario solicitar que otro usuario verifique y valide que el c\u00f3digo de su rama es correcto y lo puede fusionar en Remote Repository
con una rama principal. Al ser una operaci\u00f3n delicada, tan solo es posible ejecutarla a trav\u00e9s de la web del repositorio git.
Por lo general existir\u00e1 una opci\u00f3n / bot\u00f3n que permitir\u00e1 hacer un Merge Request
con una rama origen y una rama destino (generalmente una de las principales). A esa petici\u00f3n se le asignar\u00e1 un validador y se enviar\u00e1. El usuario validador verificar\u00e1 si es correcto o no y validar\u00e1 o rechazar\u00e1 la petici\u00f3n. En caso de validarla se fusionar\u00e1 autom\u00e1ticamente en remoto y todos los usuarios podr\u00e1n descargar los nuevos cambios desde la rama.
\u00a1Cuidado!
Siempre antes de solicitar un Merge Request
debes comprobar que tienes actualizada la rama comparandola con la rama remota que queremos mergear, en nuestro ejemplo ser\u00e1 develop
.
Para actualizarla tu rama hay que seguir tres pasos muy sencillos:
develop
y descargarnos los cambios del repositorio remoto (git pull)develop
hacia nuestra rama (git merge develop)Merge Request
Los pasos b\u00e1sicos de utilizaci\u00f3n de git son sencillos.
git clone\n o \ngit init\n
develop
) git checkout -b <rama>\n
git add .\ngit commit -m \"<Commit message>\"\n
develop
. Por tanto tenemos que cambiar a la rama develop
, descargarnos los cambios del repositorio remoto, volver a cambiar a nuestra rama y ejecutar un merge desde develop
hacia nuestra rama, ejecutando estos comandos git checkout develop\ngit pull\ngit checkout <rama>\ngit merge develop\n
git push --set-upstream origin <rama>\n
merge request
contra develop
. Para que sea validado y aprobado por otro compa\u00f1ero del equipo.merge request
antes de que haya sido aprobado, nos basta con repetir los pasos anteriores git add .\ngit commit -m \"<Commit message>\"\ngit push origin\n
develop
y adem\u00e1s debe estar actualizada git pull
.Este anexo no pretende explicar el funcionamiento interno de Spring Data, simplemente conocer un poco como utilizarlo y algunos peque\u00f1os tips que pueden ser interesantes.
"},{"location":"appendix/jpa/#funcionamiento-basico","title":"Funcionamiento b\u00e1sico","text":"Lo primero que deber\u00edas tener claro, es que hagas lo que hagas, al final todo termina lanzando una query nativa sobre la BBDD. Da igual que uses cualquier tipo de acelerador (luego veremos alguno), ya que al final Spring Data termina convirtiendo lo que hayas programado en una query nativa.
Cuanta m\u00e1s informaci\u00f3n le proporciones a Spring Data, tendr\u00e1s m\u00e1s control sobre la query final, pero m\u00e1s dificil ser\u00e1 de mantener. Lo mejor es utilizar, siempre que se pueda, todos los automatismos y automagias posibles y dejar que Spring haga su faena. Habr\u00e1 ocasiones en que esto no nos sirva, en ese momento tendremos que decidir si queremos bajar el nivel de implementaci\u00f3n o queremos utilizar otra alternativa como procesos por streams.
"},{"location":"appendix/jpa/#derived-query-methods","title":"Derived Query Methods","text":"Para la realizaci\u00f3n de consultas a la base de datos, Spring Data nos ofrece un sencillo mecanismo que consiste en crear definiciones de m\u00e9todos con una sintaxis especifica, para luego traducirlas autom\u00e1ticamente a consultas nativas, por parte de Spring Data.
Esto es muy \u00fatil, ya que convierte a la aplicaci\u00f3n en agn\u00f3sticos de la tecnolog\u00eda de BBDD utilizada y podemos migrar con facilidad entre las muchas soluciones disponibles en el mercado, delegando esta tarea en Spring.
Esta es la opci\u00f3n m\u00e1s indicada en la mayor\u00eda de los casos, siempre que puedas deber\u00edas utilizar esta forma de realizar las consultas. Como parte negativa, en algunos casos en consultas m\u00e1s complejas la definici\u00f3n de los m\u00e9todos puede extenderse demasiado dificultando la lectura del c\u00f3digo.
De esto tenemos alg\u00fan ejemplo por el tutorial, en el repositorio de GameRepository.
Siguiendo el ejemplo del tutorial, si tuvieramos que recuperar los Game
por el nombre del juego, se podr\u00eda crear un m\u00e9todo en el GameRepository
de esta forma:
List<Game> findByName(String name);\n
Spring Data entender\u00eda que quieres recuperar un listado de Game
que est\u00e1n filtrados por su propiedad Name
y generar\u00eda la consulta SQL de forma autom\u00e1tica, sin tener que implementar nada.
Se pueden contruir muchos m\u00e9todos diferentes, te recomiendo que leas un peque\u00f1o tutorial de Baeldung y profundices con la documentaci\u00f3n oficial donde podr\u00e1s ver todas las opciones.
"},{"location":"appendix/jpa/#anotacion-query","title":"Anotaci\u00f3n @Query","text":"Otra forma de realizar consultas, esta vez menos autom\u00e1tica y m\u00e1s cercana a SQL, es la anotaci\u00f3n @Query.
Existen dos opciones a la hora de usar la anotaci\u00f3n @Query
. Esta anotaci\u00f3n ya la hemos usado en el tutorial, dentro del GameRepository.
En primer lugar tenemos las consultas JPQL. Estas guardan un parecido con el lenguaje SQL pero al igual que en el caso anterior, son traducidas por Spring Data a la consulta final nativa. Su uso no est\u00e1 recomendado ya que estamos a\u00f1adiendo un nivel de concreci\u00f3n y por tanto estamos aumentando la complejidad del c\u00f3digo. Aun as\u00ed, es otra forma de generar consultas.
Por otra parte, tambi\u00e9n es posible generar consultas nativas directamente dentro de esta anotaci\u00f3n interactuando de forma directa con la base de datos. Esta pr\u00e1ctica es altamente desaconsejable ya que crea acoplamientos con la tecnolog\u00eda de la BBDD utilizada y es una fuente de errores.
Puedes ver m\u00e1s informaci\u00f3n de esta anotaci\u00f3n desde este peque\u00f1o tutorial de Baeldung.
"},{"location":"appendix/jpa/#acelerando-las-consultas","title":"Acelerando las consultas","text":"En muchas ocasiones necesitamos obtener informaci\u00f3n que no est\u00e1 en una \u00fanica tabla por motivos de dise\u00f1o de la base de datos. Debemos plasmar esta casu\u00edstica con cuidado a nuestro modelo relacional para obtener resultados \u00f3ptimos en cuanto al rendimiento.
Para ilustrar el caso vamos a recuperar los objetos utilizados en el tutorial Author
, Gategory
y Game
. Si recuerdas, tenemos que un Game
tiene asociado un Author
y tiene asociada una Gategory
.
Cuando utilizamos el m\u00e9todo de filtrado find
que construimos en el GameRepository
, vemos que Spring Data traduce la @Query
que hab\u00edamos dise\u00f1ado en una query SQL para recuperar los juegos.
@Query(\"select g from Game g where (:title is null or g.title like '%'||:title||'%') and (:category is null or g.category.id = :category)\")\nList<Game> find(@Param(\"title\") String title, @Param(\"category\") Long category);\n
Esta @Query
es la que utiliza Spring Data para traducir las propiedades a objetos de BBDD y mapear los resultados a objetos Java. Si tenemos activada la property spring.jpa.show-sql=true
podremos ver las queries que est\u00e1 generando Spring Data. El resultado es el siguiente.
Hibernate: select game0_.id as id1_2_, game0_.age as age2_2_, game0_.author_id as author_i4_2_, game0_.category_id as category5_2_, game0_.title as title3_2_ from game game0_ where (? is null or game0_.title like ('%'||?||'%')) and (? is null or game0_.category_id=?)\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\nHibernate: select category0_.id as id1_1_0_, category0_.name as name2_1_0_ from category category0_ where category0_.id=?\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\nHibernate: select category0_.id as id1_1_0_, category0_.name as name2_1_0_ from category category0_ where category0_.id=?\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\nHibernate: select author0_.id as id1_0_0_, author0_.name as name2_0_0_, author0_.nationality as national3_0_0_ from author author0_ where author0_.id=?\n
Si te fijas ha generado una query SQL para filtrar los Game
, pero luego cuando ha intentado construir los objetos Java, ha tenido que lanzar una serie de queries para recuperar los diferentes Author
y Category
a trav\u00e9s de sus id
. Obviamente Spring Data es muy lista y cachea los resultados obtenidos para no tener que recuperarlos n veces, pero aun as\u00ed, lanza unas cuantas consultas. Esto penaliza el rendimiento de nuestra operaci\u00f3n, ya que tiene que lanzar n queries a BBDD que, aunque son muy \u00f3ptimas, incrementan unos milisegundos el tiempo total.
Para evitar esta circunstancia, disponemos de la anotaci\u00f3n denominada @EnitityGraph
la cual proporciona directrices a Spring Data sobre la forma en la que deseamos realizar la consulta, permitiendo que realice agrupaciones y uniones de tablas en una \u00fanica query que, aun siendo mas compleja, en muchos casos el rendimiento es mucho mejor que realizar m\u00faltiples interacciones con la BBDD.
Siguiendo el ejemplo anterior podr\u00edamos utilizar la anotaci\u00f3n de esta forma:
@Query(\"select g from Game g where (:title is null or g.title like '%'||:title||'%') and (:category is null or g.category.id = :category)\")\n@EntityGraph(attributePaths = {\"category\", \"author\"})\nList<Game> find(@Param(\"title\") String title, @Param(\"category\") Long category);\n
Donde le estamos diciendo a Spring Data que cuando realice la query, haga el cruce con las propiedades category
y author
, que a su vez son entidades y por tanto mapean dos tablas de BBDD. El resultado es el siguiente:
Hibernate: select game0_.id as id1_2_0_, category1_.id as id1_1_1_, author2_.id as id1_0_2_, game0_.age as age2_2_0_, game0_.author_id as author_i4_2_0_, game0_.category_id as category5_2_0_, game0_.title as title3_2_0_, category1_.name as name2_1_1_, author2_.name as name2_0_2_, author2_.nationality as national3_0_2_ from game game0_ left outer join category category1_ on game0_.category_id=category1_.id left outer join author author2_ on game0_.author_id=author2_.id where (? is null or game0_.title like ('%'||?||'%')) and (? is null or game0_.category_id=?)\n
Una \u00fanica query, que es m\u00e1s compleja que la anterior, ya que hace dos cruces con tablas de BBDD, pero que nos evita tener que lanzar n queries diferentes para recuperar Author
y Category
.
Generalmente, el uso de @EntityGraph
acelera mucho los resultados y es muy recomendable utilizarlo para realizar los cruces inline. Se puede utilizar tanto con @Query
como con Derived Query Methods
. Puedes leer m\u00e1s informaci\u00f3n en este peque\u00f1o tutorial de Baeldung.
A partir de Java 8 disponemos de los Java Streams. Se trata de una herramienta que nos permite multitud de opciones relativas tratamiento y trasformaci\u00f3n de los datos manejados.
En este apartado \u00fanicamente se menciona debido a que en muchas ocasiones cuando nos enfrentamos a consultas complejas, puede ser beneficioso evitar ofuscar las consultas y realizar las trasformaciones necesarias mediante los Streams.
Un ejemplo de uso pr\u00e1ctico podr\u00eda ser, evitar usar la cl\u00e1usula IN
de SQL en una determinada consulta que podr\u00eda penalizar notablemente el rendimiento de las consultas. En vez de eso se podr\u00eda utilizar el m\u00e9todo de JAVA filter
sobre el conjunto de elementos para obtener el mismo resultado.
Puedes leer m\u00e1s informaci\u00f3n en el tutorial de Baeldung.
"},{"location":"appendix/jpa/#specifications","title":"Specifications","text":"En algunos casos puede ocurrir que con las herramientas descritas anteriormente no tengamos suficiente alcance, bien porque las definiciones de los m\u00e9todos se complican y alargan demasiado o debido a que la consulta es demasiado gen\u00e9rica como para realizarlo de este modo.
Para este caso se dispone de las Specifications que nos proveen de una forma de escribir consultas reutilizables mediante una API que ofrece una forma fluida de crear y combinar consultas complejas.
Un ejemplo de caso de uso podr\u00eda ser un CRUD de una determinada entidad que debe poder filtrar por todos los atributos de esta, donde el tipo de filtrado viene especificado en la propia consulta y no siempre es requerido. En este caso no podr\u00edamos construir una consulta basada en definir un determinado m\u00e9todo ya no conocemos de ante mano que filtros ni que atributos vamos a recibir y deberemos recurrir al uso de las Specifications.
Puedes leer m\u00e1s informaci\u00f3n en el tutorial de Baeldung.
"},{"location":"appendix/rest/","title":"Breve detalle sobre REST","text":"Antes de empezar vamos a hablar de operaciones REST. Estas operaciones son el punto de entrada a nuestra aplicaci\u00f3n y se pueden diferenciar dos claros elementos:
La ruta del recurso nos indica entre otras cosas, el endpoint y su posible jerarqu\u00eda sobre la que se va a realizar la operaci\u00f3n. Debe tener una ra\u00edz de recurso y si se requiere navegar por el recursos, la jerarqu\u00eda ir\u00e1 separada por barras. La URL nunca deber\u00eda tener verbos o acciones solamente recursos, identificadores o atributos. Por ejemplo en nuestro caso de Categor\u00edas
, ser\u00edan correctas las siguientes rutas:
Sin embargo, no ser\u00edan del todo correctas las rutas:
A menudo, se integran datos identificadores o atributos de b\u00fasqueda dentro de la propia ruta. Podr\u00edamos definir la operaci\u00f3n category/3
para referirse a la Categor\u00eda con ID = 3, o category/?name=Dados
para referirse a las categor\u00edas con nombre = Dados. A veces, estos datos tambi\u00e9n pueden ir como atributos en la URL o en el cuerpo de la petici\u00f3n, aunque se recomienda que siempre que sean identificadores vayan determinados en la propia URL.
Si el dominio categor\u00eda tuviera hijos o relaciones con alg\u00fan otro dominio se podr\u00eda a\u00f1adir esas jerarqu\u00eda a la URL. Por ejemplo podr\u00edamos tener category/3/child/2
para referirnos al hijo de ID = 2 que tiene la Categor\u00eda de ID = 3, y as\u00ed sucesivamente.
La acci\u00f3n sobre el recurso se determina mediante la operaci\u00f3n o verbo HTTP que se utiliza en el endpoint. Los verbos m\u00e1s usados ser\u00edan:
POST
.De esta forma tendr\u00edamos:
GET /category/3
. Realizar\u00eda un acceso para recuperar la categor\u00eda 3.POST o PUT /category/3
. Realizar\u00eda un acceso para crear o modificar la categor\u00eda 3. Los datos a modificar deber\u00edan ir en el body.DELETE /category/3
. Realizar\u00eda un acceso para borrar la categor\u00eda 3.GET /category/?name=Dados
. Realizar\u00eda un acceso para recuperar las categor\u00edas que tengan nombre = Dados.Excepciones a la regla
A veces hay que ejecutar una operaci\u00f3n que no es 'estandar' en cuanto a verbos HTTP. Para ese caso, deberemos clarificar en la URL la acci\u00f3n que se debe realizar y si vamos a enviar datos deber\u00eda ser de tipo POST
mientras que si simplemente se requiere una contestaci\u00f3n sin enviar datos ser\u00e1 de tipo GET
. Por ejemplo POST /category/3/validate
realizar\u00eda un acceso para ejecutar una validaci\u00f3n sobre los datos enviados en el body de la categor\u00eda 3.
Se trata de una pr\u00e1ctica de programaci\u00f3n que consiste en escribir primero las pruebas (generalmente unitarias), despu\u00e9s escribir el c\u00f3digo fuente que pase la prueba satisfactoriamente y, por \u00faltimo, refactorizar el c\u00f3digo escrito.
Este ciclo se suele representar con la siguiente imagen:
Con esta pr\u00e1ctica se consigue entre otras cosas: un c\u00f3digo m\u00e1s robusto, m\u00e1s seguro, m\u00e1s mantenible y una mayor rapidez en el desarrollo.
Los pasos que se siguen son:
Primero hay que escribir el test o los tests que cubran la funcionalidad que voy a implementar. Los test no solo deben probar los casos correctos, sino que deben probar los casos err\u00f3neos e incluso los casos en los que se provoca una excepci\u00f3n. Cuantos m\u00e1s test hagas, mejor probada y m\u00e1s robusta ser\u00e1 tu aplicaci\u00f3n.
Adem\u00e1s, como efecto colateral, al escribir el test est\u00e1s pensando el dise\u00f1o de c\u00f3mo va a funcionar la aplicaci\u00f3n. En vez de liarte a programar como loco, te est\u00e1s forzando a pensar primero y ver cual es la mejor soluci\u00f3n. Por ejemplo para implementar una operaci\u00f3n de calculadora primero piensas en qu\u00e9 es lo que necesitar\u00e1s: una clase Calculadora con un m\u00e9todo que se llame Suma y que tenga dos par\u00e1metros.
El segundo paso una vez tengo definido el test, que evidentemente fallar\u00e1 (e incluso a menudo ni siquiera compilar\u00e1), es implementar el c\u00f3digo necesario para que los tests funcionen. Aqu\u00ed muchas veces pecamos de querer implementar demasiadas cosas o pensando en que en un futuro necesitaremos modificar ciertas partes y lo dejamos ya preparado para ello. Hay que ir con mucho cuidado con las optimizaciones prematuras
, a menudo no son necesarias y solo hacen que dificultar nuestro c\u00f3digo.
Piensa en construir el m\u00ednimo c\u00f3digo que haga que tus tests funcionen correctamente. Adem\u00e1s, no es necesario que sea un c\u00f3digo demasiado purista y limpio.
El \u00faltimo paso y a menudo el m\u00e1s olvidado es el Refactor
. Una vez te has asegurado que tu c\u00f3digo funciona y que los tests funcionan correctamente (ojo no solo los tuyos sino todos los que ya existan en la aplicaci\u00f3n) llega el paso de sacarle brillo a tu c\u00f3digo.
En este paso tienes que intentar mejorar tu c\u00f3digo, evitar duplicidades, evitar malos olores de programaci\u00f3n, eliminar posibles malos usos del lenguaje, etc. En definitiva que tu c\u00f3digo se lea y se entienda mejor.
Si seguimos estos pasos a la hora de programar, nuestra aplicaci\u00f3n estar\u00e1 muy bien testada. Cada vez que hagamos un cambio tendremos una certeza muy elevada, de forma r\u00e1pida y sencilla, de si la aplicaci\u00f3n sigue funcionando o hemos roto algo. Y lo mejor de todo, las implementaciones que hagamos estar\u00e1n bien pensadas y dise\u00f1adas y acotadas realmente a lo que necesitamos.
"},{"location":"appendix/springcloud/basic/","title":"Listado simple - Spring Boot","text":"A diferencia del tutorial b\u00e1sico de Spring Boot, donde constru\u00edamos una aplicaci\u00f3n monol\u00edtica, ahora vamos a construir multiples servicios por lo que necesitamos crear proyectos separados.
Para la creaci\u00f3n de proyecto nos remitimos a la gu\u00eda de instalaci\u00f3n donde se detalla el proceso de creaci\u00f3n de nuevo proyecto Entorno de desarrollo
Todos los pasos son exactamente iguales, lo \u00fanico que va a variar es el nombre de nuestro proyecto, que en este caso se va a llamar tutorial-category
. El campo que debemos modificar es artifact
en Spring Initilizr, el resto de campos se cambiaran autom\u00e1ticamente.
Esta parte de tutorial es una ampliaci\u00f3n de la parte de backend con Spring Boot, por tanto no se ve a enfocar en las partes b\u00e1sicas aprendidas previamente, si no que se va a explicar el funcionamiento de los micro servicios aplicados al mismo caso de uso.
Para cualquier duda sobre la estructura del c\u00f3digo y buenas pr\u00e1cticas, consultar el apartado de Estructura y buenas pr\u00e1cticas, ya que aplican a este caso en el mismo modo.
"},{"location":"appendix/springcloud/basic/#codigo","title":"C\u00f3digo","text":"Dado de vamos a implementar el micro servicio Spring Boot de Categor\u00edas
, vamos a respetar la misma estructura del Listado simple de la version monol\u00edtica.
En primer lugar, vamos a crear la entidad y el DTO dentro del package com.ccsw.tutorialcategory.category.model
. Ojo al package que lo hemos renombrado con respecto al listado monol\u00edtico.
package com.ccsw.tutorialcategory.category.model;\n\nimport jakarta.persistence.*;\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"category\")\npublic class Category {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"name\", nullable = false)\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
package com.ccsw.tutorialcategory.category.model;\n\n/**\n * @author ccsw\n *\n */\npublic class CategoryDto {\n\nprivate Long id;\n\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
"},{"location":"appendix/springcloud/basic/#repository-service-y-controller","title":"Repository, Service y Controller","text":"Posteriormente, emplazamos el resto de clases dentro del package com.ccsw.tutorialcategory.category
.
package com.ccsw.tutorialcategory.category;\n\nimport com.ccsw.tutorialcategory.category.model.Category;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface CategoryRepository extends CrudRepository<Category, Long> {\n\n}\n
package com.ccsw.tutorialcategory.category;\n\n\nimport com.ccsw.tutorialcategory.category.model.Category;\nimport com.ccsw.tutorialcategory.category.model.CategoryDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface CategoryService {\n\n/**\n * Recupera una {@link Category} a partir de su ID\n *\n * @param id PK de la entidad\n * @return {@link Category}\n */\nCategory get(Long id);\n\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link Category}\n */\nList<Category> findAll();\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, CategoryDto dto);\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
package com.ccsw.tutorialcategory.category;\n\nimport com.ccsw.tutorialcategory.category.model.Category;\nimport com.ccsw.tutorialcategory.category.model.CategoryDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class CategoryServiceImpl implements CategoryService {\n\n@Autowired\nCategoryRepository categoryRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic Category get(Long id) {\n\nreturn this.categoryRepository.findById(id).orElse(null);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Category> findAll() {\n\nreturn (List<Category>) this.categoryRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, CategoryDto dto) {\n\nCategory category;\n\nif (id == null) {\ncategory = new Category();\n} else {\ncategory = this.get(id);\n}\n\ncategory.setName(dto.getName());\n\nthis.categoryRepository.save(category);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.get(id) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.categoryRepository.deleteById(id);\n}\n\n}\n
package com.ccsw.tutorialcategory.category;\n\nimport com.ccsw.tutorialcategory.category.model.Category;\nimport com.ccsw.tutorialcategory.category.model.CategoryDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\n@Autowired\nCategoryService categoryService;\n\n@Autowired\nModelMapper mapper;\n\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link CategoryDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Categories\"\n)\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<CategoryDto> findAll() {\n\nList<Category> categories = this.categoryService.findAll();\n\nreturn categories.stream().map(e -> mapper.map(e, CategoryDto.class)).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Category\"\n)\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody CategoryDto dto) {\n\nthis.categoryService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Category\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\nthis.categoryService.delete(id);\n}\n\n}\n
"},{"location":"appendix/springcloud/basic/#sql-y-configuracion","title":"SQL y Configuraci\u00f3n","text":"Finalmente, debemos crear el mismo fichero de inicializaci\u00f3n de base de datos con solo los datos de categor\u00edas y modificar ligeramente la configuraci\u00f3n inicial para a\u00f1adir un puerto manualmente. Esto es necesario ya que vamos a levantar varios servicios simult\u00e1neamente y necesitaremos levantarlos en puertos diferentes para que no colisionen entre ellos.
data.sqlapplication.propertiesINSERT INTO category(name) VALUES ('Eurogames');\nINSERT INTO category(name) VALUES ('Ameritrash');\nINSERT INTO category(name) VALUES ('Familiar');\n
server.port=8091\n#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n
"},{"location":"appendix/springcloud/basic/#pruebas","title":"Pruebas","text":"Ahora si arrancamos la aplicaci\u00f3n server y abrimos el Postman podemos realizar las mismas pruebas del apartado de Listado simple pero esta vez apuntado al puerto 8091
.
Con esto ya tendr\u00edamos nuestro primer servicio separado. Podr\u00edamos conectar el frontend a este servicio, pero a medida que nuestra aplicaci\u00f3n creciera en n\u00famero de servicios ser\u00eda un poco engorroso todo, as\u00ed que todav\u00eda no lo vamos a conectar hasta que no tengamos toda la infraestructura.
Vamos a convertir en micro servicio el siguiente listado.
"},{"location":"appendix/springcloud/filtered/","title":"Listado filtrado - Spring Boot","text":"Al igual que en los caos anteriores vamos a crear un nuevo proyecto que contendr\u00e1 un nuevo micro servicio.
Para la creaci\u00f3n de proyecto nos remitimos a la gu\u00eda de instalaci\u00f3n donde se detalla el proceso de creaci\u00f3n de nuevo proyecto Entorno de desarrollo
Todos los pasos son exactamente iguales, lo \u00fanico que va a variar, es el nombre de nuestro proyecto, que en este caso se va a llamar tutorial-game
. El campo que debemos modificar es artifact
en Spring Initilizr, el resto de campos se cambiaran autom\u00e1ticamente.
Dado de vamos a implementar el micro servicio Spring Boot de Juegos
, vamos a respetar la misma estructura del Listado filtrado de la version monol\u00edtica.
En primer lugar, vamos a a\u00f1adir la clase que necesitamos para realizar el filtrado y vimos en la version monol\u00edtica del tutorial en el package com.ccsw.tutorialgame.common.criteria
.
package com.ccsw.tutorialgame.common.criteria;\n\npublic class SearchCriteria {\n\nprivate String key;\nprivate String operation;\nprivate Object value;\n\npublic SearchCriteria(String key, String operation, Object value) {\n\nthis.key = key;\nthis.operation = operation;\nthis.value = value;\n}\n\npublic String getKey() {\nreturn key;\n}\n\npublic void setKey(String key) {\nthis.key = key;\n}\n\npublic String getOperation() {\nreturn operation;\n}\n\npublic void setOperation(String operation) {\nthis.operation = operation;\n}\n\npublic Object getValue() {\nreturn value;\n}\n\npublic void setValue(Object value) {\nthis.value = value;\n}\n\n}\n
"},{"location":"appendix/springcloud/filtered/#entity-y-dto","title":"Entity y Dto","text":"Seguimos con la entidad y el DTO dentro del package com.ccsw.tutorialgame.game.model
. En este punto, f\u00edjate que nuestro modelo de Entity
no tiene relaci\u00f3n con la tabla Author
ni Category
ya que estos dos objetos no pertenecen a nuestro dominio y se gestionan desde otro micro servicio. Lo que tendremos ahora ser\u00e1 el identificador del registro que hace referencia a esos objetos. Ya no usaremos @JoinColumn
porque en nuestro modelo no existen esas tablas relacionadas.
Sin embargo el Dto si que utiliza relaciones, ya que son relaciones de negocio (en el Service
) y no son relaciones de dominio (en BBDD o Repository
)
package com.ccsw.tutorialgame.game.model;\n\nimport jakarta.persistence.*;\n\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"game\")\npublic class Game {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"title\", nullable = false)\nprivate String title;\n\n@Column(name = \"age\", nullable = false)\nprivate String age;\n\n@Column(name = \"category_id\", nullable = false)\nprivate Long idCategory;\n\n@Column(name = \"author_id\", nullable = false)\nprivate Long idAuthor;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return idCategory\n */\npublic Long getIdCategory() {\n\nreturn this.idCategory;\n}\n\n/**\n * @param idCategory new value of {@link #getIdCategory}.\n */\npublic void setIdCategory(Long idCategory) {\n\nthis.idCategory = idCategory;\n}\n\n/**\n * @return idAuthor\n */\npublic Long getIdAuthor() {\n\nreturn this.idAuthor;\n}\n\n/**\n * @param idAuthor new value of {@link #getIdAuthor}.\n */\npublic void setIdAuthor(Long idAuthor) {\n\nthis.idAuthor = idAuthor;\n}\n\n}\n
package com.ccsw.tutorialgame.game.model;\n\n\nimport com.ccsw.tutorialgame.author.model.AuthorDto;\nimport com.ccsw.tutorialgame.category.model.CategoryDto;\n\n/**\n * @author ccsw\n *\n */\npublic class GameDto {\n\nprivate Long id;\n\nprivate String title;\n\nprivate String age;\n\nprivate Long idCategory;\n\nprivate Long idAuthor;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return idCategory\n */\npublic Long getIdCategory() {\n\nreturn this.idCategory;\n}\n\n/**\n * @param idCategory new value of {@link #getIdCategory}.\n */\npublic void setIdCategory(Long idCategory) {\n\nthis.idCategory = idCategory;\n}\n\n/**\n * @return idAuthor\n */\npublic Long getIdAuthor() {\n\nreturn this.idAuthor;\n}\n\n/**\n * @param idAuthor new value of {@link #getIdAuthor}.\n */\npublic void setIdAuthor(Long idAuthor) {\n\nthis.idAuthor = idAuthor;\n}\n\n}\n
"},{"location":"appendix/springcloud/filtered/#repository-service-controller","title":"Repository, Service, Controller","text":"Posteriormente, emplazamos el resto de clases dentro del package com.ccsw.tutorialgame.game
.
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.game.model.Game;\nimport org.springframework.data.jpa.repository.JpaSpecificationExecutor;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameRepository extends CrudRepository<Game, Long>, JpaSpecificationExecutor<Game> {\n\n}\n
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.game.model.Game;\nimport com.ccsw.tutorialgame.game.model.GameDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameService {\n\n/**\n * Recupera los juegos filtrando opcionalmente por t\u00edtulo y/o categor\u00eda\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link Game}\n */\nList<Game> find(String title, Long idCategory);\n\n/**\n * Guarda o modifica un juego, dependiendo de si el identificador est\u00e1 o no informado\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, GameDto dto);\n\n}\n
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.common.criteria.SearchCriteria;\nimport com.ccsw.tutorialgame.game.model.Game;\nimport jakarta.persistence.criteria.*;\nimport org.springframework.data.jpa.domain.Specification;\n\n\npublic class GameSpecification implements Specification<Game> {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate final SearchCriteria criteria;\n\npublic GameSpecification(SearchCriteria criteria) {\n\nthis.criteria = criteria;\n}\n\n@Override\npublic Predicate toPredicate(Root<Game> root, CriteriaQuery<?> query, CriteriaBuilder builder) {\nif (criteria.getOperation().equalsIgnoreCase(\":\") && criteria.getValue() != null) {\nPath<String> path = getPath(root);\nif (path.getJavaType() == String.class) {\nreturn builder.like(path, \"%\" + criteria.getValue() + \"%\");\n} else {\nreturn builder.equal(path, criteria.getValue());\n}\n}\nreturn null;\n}\n\nprivate Path<String> getPath(Root<Game> root) {\nString key = criteria.getKey();\nString[] split = key.split(\"[.]\", 0);\n\nPath<String> expression = root.get(split[0]);\nfor (int i = 1; i < split.length; i++) {\nexpression = expression.get(split[i]);\n}\n\nreturn expression;\n}\n\n}\n
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.common.criteria.SearchCriteria;\nimport com.ccsw.tutorialgame.game.model.Game;\nimport com.ccsw.tutorialgame.game.model.GameDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.jpa.domain.Specification;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class GameServiceImpl implements GameService {\n\n@Autowired\nGameRepository gameRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Game> find(String title, Long idCategory) {\n\nGameSpecification titleSpec = new GameSpecification(new SearchCriteria(\"title\", \":\", title));\nGameSpecification categorySpec = new GameSpecification(new SearchCriteria(\"idCategory\", \":\", idCategory));\n\nSpecification<Game> spec = Specification.where(titleSpec).and(categorySpec);\n\nreturn this.gameRepository.findAll(spec);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, GameDto dto) {\n\nGame game;\n\nif (id == null) {\ngame = new Game();\n} else {\ngame = this.gameRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(dto, game, \"id\");\n\ngame.setIdAuthor(dto.getIdAuthor());\ngame.setIdCategory(dto.getIdCategory());\n\nthis.gameRepository.save(game);\n}\n\n}\n
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.game.model.Game;\nimport com.ccsw.tutorialgame.game.model.GameDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Game\", description = \"API of Game\")\n@RequestMapping(value = \"/game\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class GameController {\n\n@Autowired\nGameService gameService;\n\n@Autowired\nModelMapper mapper;\n\n/**\n * M\u00e9todo para recuperar una lista de {@link Game}\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link GameDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a filtered list of Games\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<GameDto> find(@RequestParam(value = \"title\", required = false) String title,\n@RequestParam(value = \"idCategory\", required = false) Long idCategory) {\n\nList<Game> game = this.gameService.find(title, idCategory);\n\nreturn game.stream().map(e -> mapper.map(e, GameDto.class)).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Game}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Game\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody GameDto dto) {\n\ngameService.save(id, dto);\n}\n\n}\n
"},{"location":"appendix/springcloud/filtered/#sql-y-configuracion","title":"SQL y Configuraci\u00f3n","text":"Finalmente, debemos crear el script de inicializaci\u00f3n de base de datos con solo los datos de juegos y modificar ligeramente la configuraci\u00f3n inicial para a\u00f1adir un puerto manualmente para poder tener multiples micro servicios funcionando simult\u00e1neamente.
data.sqlapplication.propertiesINSERT INTO game(title, age, category_id, author_id) VALUES ('On Mars', '14', 1, 2);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Aventureros al tren', '8', 3, 1);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('1920: Wall Street', '12', 1, 4);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Barrage', '14', 1, 3);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Los viajes de Marco Polo', '12', 1, 3);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Azul', '8', 3, 5);\n
server.port=8093\n#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n
"},{"location":"appendix/springcloud/filtered/#pruebas","title":"Pruebas","text":"Ahora si arrancamos la aplicaci\u00f3n server y abrimos el Postman podemos realizar las mismas pruebas del apartado de Listado filtrado pero esta vez apuntado al puerto 8093
.
F\u00edjate que cuando probemos el listado de juegos, devolver\u00e1 identificadores en idAuthor
y idCategory
, y no objetos como funcionaba hasta ahora en la aplicaci\u00f3n monol\u00edtica. As\u00ed que las pruebas que realices para insertar tambi\u00e9n deben utilizar esas propiedades y NO objetos.
En este punto ya tenemos un micro servicio de categor\u00edas en el puerto 8091
, un micro servicio de autores en el puerto 8092
y un \u00faltimo micro servicio de juegos en el puerto 8093
.
Si ahora fueramos a conectarlo con el frontend tendr\u00edamos dos problemas:
author
y category
sino que devuelve su ID. Esto obliga al frontend a tener que hacer dos llamadas extra para completar la informaci\u00f3n. Estar\u00edamos llevando l\u00f3gica de negocio al frontend y esto no nos convence.Para poder solverntar ambos problemas, necesitamos conectar todos nuestros micro servicios con una infraestructura que nos ayudar\u00e1 a gestionar todo el ecosistema de micro servicios. Vamos all\u00e1 con el \u00faltimo punto.
"},{"location":"appendix/springcloud/infra/","title":"Infraestructura - Spring Cloud","text":"Creados los tres micro servicios que compondr\u00e1n nuestro aplicativo, ya podemos empezar con la creaci\u00f3n de las piezas de infraestructura que ser\u00e1n las encargadas de realizar la orquestaci\u00f3n.
"},{"location":"appendix/springcloud/infra/#service-discovery-eureka","title":"Service Discovery - Eureka","text":"Para esta pieza hay muchas aplicaciones de mercado, incluso los propios proveedores de cloud tiene la suya propia, pero en este caso, vamos a utilizar la que ofrece Spring Cloud, as\u00ed que vamos a crear un proyecto de una forma similar a la que estamos acostumbrados.
"},{"location":"appendix/springcloud/infra/#crear-el-servicio","title":"Crear el servicio","text":"Volviendo una vez m\u00e1s a Spring Initializr seleccionaremos los siguientes datos:
Es importante que a\u00f1adamos la dependencia de Eureka Server
para que sea capaz de ejecutar el proyecto como si fuera un servidor Eureka.
Importamos el proyecto dentro del IDE y ya solo nos queda activar el servidor y configurarlo.
En primer lugar, a\u00f1adimos la anotaci\u00f3n que habilita el servidor de Eureka.
TutorialEurekaApplication.javapackage com.ccsw.tutorialeureka;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;\n\n@SpringBootApplication\n@EnableEurekaServer\npublic class TutorialEurekaApplication {\n\npublic static void main(String[] args) {\nSpringApplication.run(TutorialEurekaApplication.class, args);\n}\n\n}\n
Ahora debemos a\u00f1adir las configuraciones necesarias. En primer lugar para facilitar la visualizaci\u00f3n de las propiedades vamos a renombrar nuestro fichero application.properties
a application.yml
. Hecho esto, a\u00f1adimos la configuraci\u00f3n de puerto que ya conocemos y a\u00f1adimos directivas sobre que Eureka no se registre a s\u00ed mismo dentro del cat\u00e1logo de servicios.
server:\n port: 8761\neureka:\n client:\n registerWithEureka: false\n fetchRegistry: false\n
"},{"location":"appendix/springcloud/infra/#probar-el-servicio","title":"Probar el servicio","text":"Hechas estas sencillas configuraciones y arrancando el proyecto, nos dirigimos a la http://localhost/8761
donde podemos ver la interfaz de Eureka y si miramos con detenimiento, vemos que el cat\u00e1logo de servicios aparece vac\u00edo, ya que a\u00fan no se ha registrado ninguno de ellos.
Ahora que ya tenemos disponible Eureka, ya podemos proceder a registrar nuestros micro servicios dentro del cat\u00e1logo. Para ello vamos a realizar las mismas modificaciones sobre los tres micro servicios. Recuerda que hay que realizarlo sobre los tres para que se registren todos.
"},{"location":"appendix/springcloud/infra/#configurar-micro-servicios","title":"Configurar micro servicios","text":"Para este fin debemos a\u00f1adir una nueva dependencia dentro del pom.xml
y modificar la configuraci\u00f3n del proyecto.
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\nxsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n<modelVersion>4.0.0</modelVersion>\n<parent>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-parent</artifactId>\n<version>3.0.4</version>\n<relativePath/> <!-- lookup parent from repository -->\n</parent>\n<groupId>com.ccsw</groupId>\n<artifactId>tutorial-XXX</artifactId> <!-- Cada proyecto tiene su configaci\u00f3n propia, NO modificar -->\n<version>0.0.1-SNAPSHOT</version>\n<name>tutorial-XXX</name> <!-- Cada proyecto tiene su configaci\u00f3n propia, NO modificar -->\n<description>Demo project for Spring Boot</description>\n<properties>\n<java.version>19</java.version>\n<spring-cloud.version>2022.0.1</spring-cloud.version>\n</properties>\n<dependencies>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-data-jpa</artifactId>\n</dependency>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-web</artifactId>\n</dependency>\n\n<dependency>\n<groupId>org.springdoc</groupId>\n<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>\n<version>2.0.3</version>\n</dependency>\n\n<dependency>\n<groupId>org.hibernate</groupId>\n<artifactId>hibernate-validator</artifactId>\n<version>8.0.0.Final</version>\n</dependency>\n\n<dependency>\n<groupId>net.sf.dozer</groupId>\n<artifactId>dozer</artifactId>\n<version>5.5.1</version>\n</dependency>\n\n<dependency>\n<groupId>org.springframework.cloud</groupId>\n<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>\n</dependency>\n<dependency>\n<groupId>com.h2database</groupId>\n<artifactId>h2</artifactId>\n<scope>runtime</scope>\n</dependency>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-test</artifactId>\n<scope>test</scope>\n</dependency>\n</dependencies>\n<dependencyManagement>\n<dependencies>\n<dependency>\n<groupId>org.springframework.cloud</groupId>\n<artifactId>spring-cloud-dependencies</artifactId>\n<version>${spring-cloud.version}</version>\n<type>pom</type>\n<scope>import</scope>\n</dependency>\n</dependencies>\n</dependencyManagement>\n<build>\n<plugins>\n<plugin>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-maven-plugin</artifactId>\n</plugin>\n</plugins>\n</build>\n\n</project>\n
spring.application.name=spring-cloud-eureka-client-XXX\nserver.port=809X\n\n#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n\n#Eureka\neureka.client.serviceUrl.defaultZone=${EUREKA_URI:http://localhost:8761/eureka}\neureka.instance.preferIpAddress=true\n
Como podemos observar, lo que hemos hecho, es a\u00f1adir la dependencia de Eureka Client y le hemos comunicado a cada micro servicio donde tenemos arrancado Eureka. De este modo al arrancar cada micro servicio, este se registrar\u00e1 autom\u00e1ticamente dentro de Eureka.
Para poder diferenciar cada micro servicio, estos tienen su configuraci\u00f3n de nombre y puerto (mantenemos el puerto que hab\u00edamos configurado en pasos previos):
spring.application.name=spring-cloud-eureka-client-category
spring.application.name=spring-cloud-eureka-client-author
spring.application.name=spring-cloud-eureka-client-game
Nombres en vez de rutas
Estos nombres ser\u00e1n por los que vamos a identificar cada micro servicio dentro de Eureka que ser\u00e1 quien conozca las rutas de los mismos, asi cuando queramos realizar redirecciones a estos no necesitaremos conocerlas rutas ni los puertos de los mismos, con proporcionar los nombres tendremos la informaci\u00f3n completa de como llegar a ellos.
"},{"location":"appendix/springcloud/infra/#probar-micro-servicios","title":"Probar micro servicios","text":"Hechas estas configuraciones y arrancados los micro servicios, volvemos a dirigirnos a Eureka en http://localhost/8761
donde podemos ver que estos aparecen en el listado de servicios registrados.
Para esta pieza, de nuevo, hay muchas implementaciones y aplicaciones de mercado, pero nosotros vamos a utilizar la de Spring Cloud, as\u00ed que vamos a crear un nuevo proyecto de una forma similar a la de Eureka.
"},{"location":"appendix/springcloud/infra/#crear-el-servicio_1","title":"Crear el servicio","text":"Volviendo una vez m\u00e1s a Spring Initializr seleccionaremos los siguientes datos:
Ojo con las dependencias de Gateway
y de Eureka Client
que debemos a\u00f1adir.
De nuevo lo importamos en nuestro IDE y pasamos a a\u00f1adir las configuraciones pertinentes.
Al igual que en el caso de Eureka vamos a renombrar nuestro fichero application.properties
a application.yml
.
server:\n port: 8080\neureka:\n client:\n serviceUrl:\n defaultZone: http://localhost:8761/eureka\nspring:\n application:\n name: spring-cloud-eureka-client-gateway\n cloud:\n gateway:\n default-filters:\n - DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin\n globalcors:\n corsConfigurations:\n '[/**]':\n allowedOrigins: \"*\"\n allowedMethods: \"*\"\n allowedHeaders: \"*\"\n routes:\n - id: category\n uri: lb://SPRING-CLOUD-EUREKA-CLIENT-CATEGORY\n predicates:\n - Path=/category/**\n - id: author\n uri: lb://SPRING-CLOUD-EUREKA-CLIENT-AUTHOR\n predicates:\n - Path=/author/**\n - id: game\n uri: lb://SPRING-CLOUD-EUREKA-CLIENT-GAME\n predicates:\n - Path=/game/**\n
Lo que hemos hecho aqu\u00ed es configurar el puerto como 8080
ya que el Gateway
va a ser nuestro punto de acceso y el encargado de redirigir cada petici\u00f3n al micro servicio correcto.
Posteriormente hemos configurado el cliente de Eureka para que el Gateway establezca comunicaci\u00f3n con Eureka que hemos configurado previamente para, en primer lugar, registrarse como un cliente y seguidamente obtener informaci\u00f3n del cat\u00e1logo de servicios existentes.
El paso siguiente es darle un nombre a la aplicaci\u00f3n para que se registre en Eureka y a\u00f1adir configuraci\u00f3n de CORS para que cuando realicemos las llamadas desde navegador pueda realizar la redirecci\u00f3n correctamente.
Finalmente a\u00f1adimos las directrices de redirecci\u00f3n al Gateway indic\u00e1ndole los nombres de los micro servicios con los que estos se han registrado en Eureka junto a los predicados que incluyen las rutas parciales que queremos que sean redirigidas a cada micro servicio.
Con esto nos queda la siguiente configuraci\u00f3n:
category
redirigir\u00e1n al micro servicio de Categorias
author
redirigir\u00e1n al micro servicio de Autores
game
redirigir\u00e1n al micro servicio de Juegos
Hechas esto y arrancado el proyecto, volvemos a dirigirnos a Eureka en http://localhost/8761
donde podemos ver que el Gateway se ha registrado correctamente junto al resto de clientes.
El \u00faltimo paso es la implementaci\u00f3n de la comunicaci\u00f3n entre los micro servicios, en este caso necesitamos que nuestro micro servicio de Game
obtenga datos de Category
y Author
para poder servir informaci\u00f3n completa de los Game
ya que en su modelo solo posee los identificadores. Si record\u00e1is, est\u00e1bamos respondiendo solamente con los id
.
Para la comunicaci\u00f3n entre los distintos servicios, Spring Cloud nos prove de Feign Clients
que ofrecen una interfaz muy sencilla de comunicaci\u00f3n y que utiliza a la perfecci\u00f3n la infraestructura que ya hemos construido.
En primer lugar debemos a\u00f1adir la dependencia necesaria dentro de nuestro pom.xml del micro servicio de Game
.
...\n <dependency>\n<groupId>org.springframework.cloud</groupId>\n<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>\n</dependency>\n\n<dependency>\n<groupId>org.springframework.cloud</groupId>\n<artifactId>spring-cloud-starter-openfeign</artifactId>\n</dependency>\n<dependency>\n<groupId>com.h2database</groupId>\n<artifactId>h2</artifactId>\n<scope>runtime</scope>\n</dependency>\n...\n
El siguiente paso es habilitar el uso de los Feign Clients
mediante la anotaci\u00f3n de SpringCloud.
package com.ccsw.tutorialgame;\n\nimport org.springframework.boot.SpringApplication;\nimport org.springframework.boot.autoconfigure.SpringBootApplication;\nimport org.springframework.cloud.openfeign.EnableFeignClients;\n\n@SpringBootApplication\n@EnableFeignClients\npublic class TutorialGameApplication {\n\npublic static void main(String[] args) {\nSpringApplication.run(TutorialGameApplication.class, args);\n}\n\n}\n
"},{"location":"appendix/springcloud/infra/#configurar-los-clientes","title":"Configurar los clientes","text":"Realizadas las configuraciones ya podemos realizar los cambios necesarios en nuestro c\u00f3digo para implementar la comunicaci\u00f3n. En primer lugar vamos a crear los clientes de Categor\u00edas
y Autores
.
package com.ccsw.tutorialgame.category;\n\nimport com.ccsw.tutorialgame.category.model.CategoryDto;\nimport org.springframework.cloud.openfeign.FeignClient;\nimport org.springframework.web.bind.annotation.GetMapping;\n\nimport java.util.List;\n\n@FeignClient(value = \"SPRING-CLOUD-EUREKA-CLIENT-CATEGORY\", url = \"http://localhost:8080\")\npublic interface CategoryClient {\n\n@GetMapping(value = \"/category\")\nList<CategoryDto> findAll();\n}\n
package com.ccsw.tutorialgame.author;\n\nimport com.ccsw.tutorialgame.author.model.AuthorDto;\nimport org.springframework.cloud.openfeign.FeignClient;\nimport org.springframework.web.bind.annotation.GetMapping;\n\nimport java.util.List;\n\n@FeignClient(value = \"SPRING-CLOUD-EUREKA-CLIENT-AUTHOR\", url = \"http://localhost:8080\")\npublic interface AuthorClient {\n\n@GetMapping(value = \"/author\")\nList<AuthorDto> findAll();\n}\n
Lo que hacemos aqu\u00ed es crear una simple interfaz donde a\u00f1adimos la configuraci\u00f3n del Feign Client
con la url del Gateway a trav\u00e9s del cual vamos a realizar todas las comunicaciones y creamos un m\u00e9todo abstracto con la anotaci\u00f3n pertinente para hacer referencia al endpoint de obtenci\u00f3n del listado.
Con esto ya podemos inyectar estas interfaces dentro de nuestro controlador para obtener todos los datos necesarios que completaran la informaci\u00f3n de la Category
y Author
de cada Game
.
Adem\u00e1s, vamos a cambiar el Dto de respuesta, para que en vez de devolver ids, devuelva los objetos correspondientes, que son los que est\u00e1 esperando nuestro frontend. Para ello, primero crearemos los Dtos que necesitamos. Los crearemos en:
com.ccsw.tutorialgame.category.model
com.ccsw.tutorialgame.author.model
package com.ccsw.tutorialgame.category.model;\n\n/**\n * @author ccsw\n *\n */\npublic class CategoryDto {\n\nprivate Long id;\n\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
package com.ccsw.tutorialgame.author.model;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorDto {\n\nprivate Long id;\n\nprivate String name;\n\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
Adem\u00e1s, modificaremos nuestro GameDto
para hacer uso de esos objetos.
package com.ccsw.tutorialgame.game.model;\n\n\nimport com.ccsw.tutorialgame.author.model.AuthorDto;\nimport com.ccsw.tutorialgame.category.model.CategoryDto;\n\n/**\n * @author ccsw\n *\n */\npublic class GameDto {\n\nprivate Long id;\n\nprivate String title;\n\nprivate String age;\n\nprivate CategoryDto category;\n\nprivate AuthorDto author;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return category\n */\npublic CategoryDto getCategory() {\n\nreturn this.category;\n}\n\n/**\n * @param category new value of {@link #getCategory}.\n */\npublic void setCategory(CategoryDto category) {\n\nthis.category = category;\n}\n\n/**\n * @return author\n */\npublic AuthorDto getAuthor() {\n\nreturn this.author;\n}\n\n/**\n * @param author new value of {@link #getAuthor}.\n */\npublic void setAuthor(AuthorDto author) {\n\nthis.author = author;\n}\n\n}\n
Y por \u00faltimo implementaremos el c\u00f3digo necesario para transformar los ids
en objetos dto. Aqu\u00ed lo que haremos ser\u00e1 recuperar todos los autores y categor\u00edas, haciendo uso de los Feign Client
, y cuando ejecutemos el mapeo de los juegos, ir sustituyendo sus valores por los dtos correspondientes.
package com.ccsw.tutorialgame.game;\n\nimport com.ccsw.tutorialgame.author.AuthorClient;\nimport com.ccsw.tutorialgame.author.model.AuthorDto;\nimport com.ccsw.tutorialgame.category.CategoryClient;\nimport com.ccsw.tutorialgame.category.model.CategoryDto;\nimport com.ccsw.tutorialgame.game.model.Game;\nimport com.ccsw.tutorialgame.game.model.GameDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Game\", description = \"API of Game\")\n@RequestMapping(value = \"/game\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class GameController {\n\n@Autowired\nGameService gameService;\n\n@Autowired\nCategoryClient categoryClient;\n@Autowired\nAuthorClient authorClient;\n/**\n * M\u00e9todo para recuperar una lista de {@link Game}\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link GameDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a filtered list of Games\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<GameDto> find(@RequestParam(value = \"title\", required = false) String title,\n@RequestParam(value = \"idCategory\", required = false) Long idCategory) {\n\nList<CategoryDto> categories = categoryClient.findAll();\nList<AuthorDto> authors = authorClient.findAll();\nreturn gameService.find(title, idCategory).stream().map(game -> {\nGameDto gameDto = new GameDto();\ngameDto.setId(game.getId());\ngameDto.setTitle(game.getTitle());\ngameDto.setAge(game.getAge());\ngameDto.setCategory(categories.stream().filter(category -> category.getId().equals(game.getIdCategory())).findFirst().orElse(null));\ngameDto.setAuthor(authors.stream().filter(author -> author.getId().equals(game.getIdAuthor())).findFirst().orElse(null));\nreturn gameDto;\n}).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Game}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Game\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody GameDto dto) {\n\ngameService.save(id, dto);\n}\n\n}\n
Con todo esto, ya tenemos construido nuestro aplicativo de micro servicios con la arquitectura Spring Cloud. Podemos proceder a realizar las mismas pruebas tanto manuales como a trav\u00e9s de los frontales.
Escalado
Una de las principales ventajas de las arquitecturas de micro servicios, es la posibilidad de escalar partes de los aplicativos sin tener que escalar el sistema completo. Para confirmar que esto es asi, podemos levantar multiples instancias de cada servicio en puertos diferentes y veremos que esto se refleja en Eureka y el Gateway balancear\u00e1 autom\u00e1ticamente entre las distintas instancias.
"},{"location":"appendix/springcloud/intro/","title":"Introducci\u00f3n Micro Servicios - Spring Cloud","text":""},{"location":"appendix/springcloud/intro/#que-son-los-micro-servicios","title":"Que son los micro servicios?","text":"Pues como su nombre indica, son servicios peque\u00f1itos
Aunque si nos vamos a una definici\u00f3n m\u00e1s t\u00e9cnica (seg\u00fan ChatGPT):
Los micro servicios son una arquitectura de software en la que una aplicaci\u00f3n est\u00e1 compuesta por peque\u00f1os servicios independientes que se comunican entre s\u00ed a trav\u00e9s de interfaces bien definidas. Cada servicio se enfoca en realizar una tarea espec\u00edfica dentro de la aplicaci\u00f3n y se ejecuta de manera aut\u00f3noma.
Cada micro servicio es responsable de un dominio del negocio y puede ser desarrollado, probado, implementado y escalado de manera independiente. Esto permite una mayor flexibilidad y agilidad en el desarrollo y la implementaci\u00f3n de aplicaciones, ya que los cambios en un servicio no afectan a otros servicios.
Adem\u00e1s, los micro servicios son escalables y resistentes a fallos, ya que si un servicio falla, los dem\u00e1s servicios pueden seguir funcionando. Tambi\u00e9n permiten la utilizaci\u00f3n de diferentes tecnolog\u00edas para cada servicio, lo que ayuda a optimizar el rendimiento y la eficiencia en la aplicaci\u00f3n en general.
"},{"location":"appendix/springcloud/intro/#spring-cloud","title":"Spring Cloud","text":"Existente multiples soluciones para implementar micro servicios, en nuestro caso vamos a utilizar la soluci\u00f3n que nos ofrece Spring Framework y que est\u00e1 incluido dentro del m\u00f3dulo Spring Cloud.
Esta soluci\u00f3n nace hace ya varios a\u00f1os como parte de la infraestructura de Netflix para dar soluci\u00f3n a sus propias necesidades. Con el tiempo este c\u00f3digo opensource ha sido adquirido por Spring Framework y se ha incluido dentro de su ecosistema, evolucionandolo con nuevas funcionalidades. Todo ello ha sido publicado bajo el m\u00f3dulo de Spring Cloud.
"},{"location":"appendix/springcloud/intro/#contexto-de-la-aplicacion","title":"Contexto de la aplicaci\u00f3n","text":"Llegados a este punto, \u00bfqu\u00e9 es lo que vamos a hacer en los siguientes puntos?. Pues vamos a coger nuestra aplicaci\u00f3n monol\u00edtica que ya tenemos implementada durante todo el tutorial, y vamos a proceder a trocearla e implementarla con una metodolog\u00eda de micro servicios.
Pero, adem\u00e1s de trocear la aplicaci\u00f3n en peque\u00f1os servicios, nos va a hacer falta una serie de servicios / utilidades para conectar todo el ecosistema. Nos har\u00e1 falta una infraestructura.
"},{"location":"appendix/springcloud/intro/#infraestructura","title":"Infraestructura","text":"A diferencia de una aplicaci\u00f3n monol\u00edtica, en un enfoque de micro servicios, ya no basta \u00fanicamente con la aplicaci\u00f3n desplegada en su servidor, sino que ser\u00e1n necesarios varios actores que se responsabilizar\u00e1n de darle consistencia al sistema, permitir la comunicaci\u00f3n entre ellos, y ayudar\u00e1n a solventar ciertos problemas que nos surgir\u00e1n al trocear nuestras aplicaciones.
Las principales piezas que vamos a utilizar para la implementaci\u00f3n de nuestra infraestructura, ser\u00e1n:
Service Discovery
que no es m\u00e1s que un cat\u00e1logo de todos los servicios que componen el ecosistema al cual cada servicio debe informar de forma proactiva, de su localizaci\u00f3n y disponibilidad.Service Discovery
e informar peri\u00f3dicamente a este cat\u00e1logo de su estado y sus m\u00e9tricas para que en caso de perdida de servicio, el resto de elementos lo sepan y puedan tomar decisiones al respecto. Tambi\u00e9n nos servir\u00e1 para que cada elemento pueda guardar en local una cach\u00e9 del cat\u00e1logo publicado, que se ir\u00e1 refrescando cada vez que lance un health check
.Service Discovery
. Es altamente configurable (rutas, redirecciones, carga, etc.) y es una pieza fundamental para unificar todas las llamadas en un \u00fanico punto del ecosistema.Con las piezas identificadas anteriormente y con el Contexto de la aplicaci\u00f3n en mente, lo que vamos a hacer en los siguientes puntos es trocear el sistema y generar la siguiente arquitectura:
Ya deber\u00edamos tener claros los conceptos y los actores que compondr\u00e1n nuestro sistema, as\u00ed que, all\u00e1 vamos!!!
"},{"location":"appendix/springcloud/paginated/","title":"Listado paginado - Spring Boot","text":"Al igual que en el caso anterior vamos a crear un nuevo proyecto que contendr\u00e1 un nuevo micro servicio.
Para la creaci\u00f3n de proyecto nos remitimos a la gu\u00eda de instalaci\u00f3n donde se detalla el proceso de creaci\u00f3n de nuevo proyecto Entorno de desarrollo
Todos los pasos son exactamente iguales, lo \u00fanico que va a variar, es el nombre de nuestro proyecto, que en este caso se va a llamar tutorial-author
. El campo que debemos modificar es artifact
en Spring Initilizr, el resto de campos se cambiaran autom\u00e1ticamente.
Dado de vamos a implementar el micro servicio Spring Boot de Autores
, vamos a respetar la misma estructura del Listado paginado de la version monol\u00edtica.
En primer lugar, vamos a a\u00f1adir la clase que necesitamos para realizar la paginaci\u00f3n y vimos en la version monol\u00edtica del tutorial en el package com.ccsw.tutorialauthor.common.pagination
. Ojo al package que lo hemos renombrado con respecto al listado monol\u00edtico.
package com.ccsw.tutorialauthor.common.pagination;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport org.springframework.data.domain.*;\n\nimport java.io.Serializable;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class PageableRequest implements Serializable {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate int pageNumber;\n\nprivate int pageSize;\n\nprivate List<SortRequest> sort;\n\npublic PageableRequest() {\n\nsort = new ArrayList<>();\n}\n\npublic PageableRequest(int pageNumber, int pageSize) {\n\nthis();\nthis.pageNumber = pageNumber;\nthis.pageSize = pageSize;\n}\n\npublic PageableRequest(int pageNumber, int pageSize, List<SortRequest> sort) {\n\nthis();\nthis.pageNumber = pageNumber;\nthis.pageSize = pageSize;\nthis.sort = sort;\n}\n\npublic int getPageNumber() {\nreturn pageNumber;\n}\n\npublic void setPageNumber(int pageNumber) {\nthis.pageNumber = pageNumber;\n}\n\npublic int getPageSize() {\nreturn pageSize;\n}\n\npublic void setPageSize(int pageSize) {\nthis.pageSize = pageSize;\n}\n\npublic List<SortRequest> getSort() {\nreturn sort;\n}\n\npublic void setSort(List<SortRequest> sort) {\nthis.sort = sort;\n}\n\n@JsonIgnore\npublic Pageable getPageable() {\n\nreturn PageRequest.of(this.pageNumber, this.pageSize, Sort.by(sort.stream().map(e -> new Sort.Order(e.getDirection(), e.getProperty())).collect(Collectors.toList())));\n}\n\npublic static class SortRequest implements Serializable {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate String property;\n\nprivate Sort.Direction direction;\n\nprotected String getProperty() {\nreturn property;\n}\n\nprotected void setProperty(String property) {\nthis.property = property;\n}\n\nprotected Sort.Direction getDirection() {\nreturn direction;\n}\n\nprotected void setDirection(Sort.Direction direction) {\nthis.direction = direction;\n}\n}\n\n}\n
"},{"location":"appendix/springcloud/paginated/#entity-y-dto","title":"Entity y Dto","text":"Seguimos con la entidad y los DTOs dentro del package com.ccsw.tutorialauthor.author.model
.
package com.ccsw.tutorialauthor.author.model;\n\nimport jakarta.persistence.*;\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"author\")\npublic class Author {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"name\", nullable = false)\nprivate String name;\n\n@Column(name = \"nationality\")\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
package com.ccsw.tutorialauthor.author.model;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorDto {\n\nprivate Long id;\n\nprivate String name;\n\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
package com.ccsw.tutorialauthor.author.model;\n\nimport com.ccsw.tutorialauthor.common.pagination.PageableRequest;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorSearchDto {\n\nprivate PageableRequest pageable;\n\npublic PageableRequest getPageable() {\nreturn pageable;\n}\n\npublic void setPageable(PageableRequest pageable) {\nthis.pageable = pageable;\n}\n}\n
"},{"location":"appendix/springcloud/paginated/#repository-service-y-controller","title":"Repository, Service y Controller","text":"Posteriormente, emplazamos el resto de clases dentro del package com.ccsw.tutorialauthor.author
.
package com.ccsw.tutorialauthor.author;\n\nimport com.ccsw.tutorialauthor.author.model.Author;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorRepository extends CrudRepository<Author, Long> {\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param pageable pageable\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findAll(Pageable pageable);\n\n}\n
package com.ccsw.tutorialauthor.author;\n\nimport com.ccsw.tutorialauthor.author.model.Author;\nimport com.ccsw.tutorialauthor.author.model.AuthorDto;\nimport com.ccsw.tutorialauthor.author.model.AuthorSearchDto;\nimport org.springframework.data.domain.Page;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorService {\n\n/**\n * Recupera un {@link Author} a trav\u00e9s de su ID\n *\n * @param id PK de la entidad\n * @return {@link Author}\n */\nAuthor get(Long id);\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findPage(AuthorSearchDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, AuthorDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n/**\n * Recupera un listado de autores {@link Author}\n *\n * @return {@link List} de {@link Author}\n */\nList<Author> findAll();\n\n}\n
package com.ccsw.tutorialauthor.author;\n\nimport com.ccsw.tutorialauthor.author.model.Author;\nimport com.ccsw.tutorialauthor.author.model.AuthorDto;\nimport com.ccsw.tutorialauthor.author.model.AuthorSearchDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class AuthorServiceImpl implements AuthorService {\n\n@Autowired\nAuthorRepository authorRepository;\n\n/**\n * {@inheritDoc}\n * @return\n */\n@Override\npublic Author get(Long id) {\n\nreturn this.authorRepository.findById(id).orElse(null);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic Page<Author> findPage(AuthorSearchDto dto) {\n\nreturn this.authorRepository.findAll(dto.getPageable().getPageable());\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, AuthorDto data) {\n\nAuthor author;\n\nif (id == null) {\nauthor = new Author();\n} else {\nauthor = this.get(id);\n}\n\nBeanUtils.copyProperties(data, author, \"id\");\n\nthis.authorRepository.save(author);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.get(id) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.authorRepository.deleteById(id);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Author> findAll() {\n\nreturn (List<Author>) this.authorRepository.findAll();\n}\n\n}\n
package com.ccsw.tutorialauthor.author;\n\nimport com.ccsw.tutorialauthor.author.model.Author;\nimport com.ccsw.tutorialauthor.author.model.AuthorDto;\nimport com.ccsw.tutorialauthor.author.model.AuthorSearchDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Author\", description = \"API of Author\")\n@RequestMapping(value = \"/author\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class AuthorController {\n\n@Autowired\nAuthorService authorService;\n\n@Autowired\nModelMapper mapper;\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link AuthorDto}\n */\n@Operation(summary = \"Find Page\", description = \"Method that return a page of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.POST)\npublic Page<AuthorDto> findPage(@RequestBody AuthorSearchDto dto) {\n\nPage<Author> page = this.authorService.findPage(dto);\n\nreturn new PageImpl<>(page.getContent().stream().map(e -> mapper.map(e, AuthorDto.class)).collect(Collectors.toList()), page.getPageable(), page.getTotalElements());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Author\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody AuthorDto dto) {\n\nthis.authorService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Author\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\nthis.authorService.delete(id);\n}\n\n/**\n * Recupera un listado de autores {@link Author}\n *\n * @return {@link List} de {@link AuthorDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<AuthorDto> findAll() {\n\nList<Author> authors = this.authorService.findAll();\n\nreturn authors.stream().map(e -> mapper.map(e, AuthorDto.class)).collect(Collectors.toList());\n}\n\n}\n
"},{"location":"appendix/springcloud/paginated/#sql-y-configuracion","title":"SQL y Configuraci\u00f3n","text":"Finalmente, debemos crear el script de inicializaci\u00f3n de base de datos con solo los datos de author y modificar ligeramente la configuraci\u00f3n inicial para a\u00f1adir un puerto manualmente para poder tener multiples micro servicios funcionando simult\u00e1neamente.
data.sqlapplication.propertiesINSERT INTO author(name, nationality) VALUES ('Alan R. Moon', 'US');\nINSERT INTO author(name, nationality) VALUES ('Vital Lacerda', 'PT');\nINSERT INTO author(name, nationality) VALUES ('Simone Luciani', 'IT');\nINSERT INTO author(name, nationality) VALUES ('Perepau Llistosella', 'ES');\nINSERT INTO author(name, nationality) VALUES ('Michael Kiesling', 'DE');\nINSERT INTO author(name, nationality) VALUES ('Phil Walker-Harding', 'US');\n
server.port=8092\n#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n
"},{"location":"appendix/springcloud/paginated/#pruebas","title":"Pruebas","text":"Ahora si arrancamos la aplicaci\u00f3n server y abrimos el Postman podemos realizar las mismas pruebas del apartado de Listado paginado pero esta vez apuntado al puerto 8092
.
En este punto ya tenemos un micro servicio de categor\u00edas en el puerto 8091
y un micro servicio de autores en el puerto 8092
. Al igual que antes, con estos datos ya podr\u00edamos conectar el frontend a estos servicios, pero vamos a esperar un poquito m\u00e1s a tener toda la infraestructura, para que sea m\u00e1s sencillo.
Vamos a convertir en micro servicio el \u00faltimo listado.
"},{"location":"appendix/springcloud/summary/","title":"Resumen Micro Servicios - Spring Cloud","text":""},{"location":"appendix/springcloud/summary/#que-hemos-hecho","title":"\u00bfQu\u00e9 hemos hecho?","text":"Llegados a este punto, ya has podido comprobar que implementar una aplicaci\u00f3n orientada a micro servicios es bastante similar a una aplicaci\u00f3n monol\u00edtica, con la salvedad de que tienes que tener en cuenta la distribuci\u00f3n de estos, y por tanto su gesti\u00f3n y coordinaci\u00f3n.
En definitiva, lo que hemos implementado ha sido:
Service Discovery: Que ayudar\u00e1 a tener un cat\u00e1logo de todos las piezas de mi infraestructura, su IP, su puerto y ciertas m\u00e9tricas que ayuden luego en la elecci\u00f3n de servicio.
Gateway: Que centraliza las peticiones en un \u00fanico punto y permite hacer de balanceo de carga, seguridad, etc. Ser\u00e1 el punto de entrada a nuestro ecosistema.
Micro servicio Category: Contiene las operaciones sobre el \u00e1mbito funcional de categor\u00edas, guarda y recupera informaci\u00f3n de ese \u00e1mbito funcional.
Micro servicio Author: Contiene las operaciones sobre el \u00e1mbito funcional de autores, guarda y recupera informaci\u00f3n de ese \u00e1mbito funcional.
Micro servicio Game: Contiene las operaciones sobre el \u00e1mbito funcional de autores, guarda y recupera informaci\u00f3n de ese \u00e1mbito funcional. Adem\u00e1s, realiza llamadas entre los otros dos micro servicios para nutrir de m\u00e1s informaci\u00f3n sus endpoints.
El diagrama de nuestra aplicaci\u00f3n ahora es as\u00ed:
"},{"location":"appendix/springcloud/summary/#siguientes-pasos","title":"Siguientes pasos","text":"Bueno, el siguiente paso m\u00e1s evidente, ser\u00e1 ver que si conectas el frontend sigue funcionando exactamente igual que lo estaba haciendo antes.
Ahora te propongo hacer el mismo ejercicio con los otros dos m\u00f3dulos Cliente
y Pr\u00e9stamo
que has tenido que implementar en el punto Ahora hazlo tu!.
Ten en cuenta que Cliente
no depende de nadie, pero Pr\u00e9stamo
si que depende de Cliente
y de Game
. A ver como solucionas los cruces y sobre todo los filtros
Pues ya estar\u00eda todo, ahora solo te puedo dar la enhorabuena y pasar algo de informaci\u00f3n extra / cursos / formaciones por si quieres seguir aprendiendo.
Por un lado tienes el itinerario avanzado de Springboot donde se puede m\u00e1s detalle de micro servicios.
Por otro lado tambi\u00e9n tienes los itinerarios de Cloud ya que no todo va a ser micro servicios con Spring Cloud, tambi\u00e9n existen micro servicios con otras tecnolog\u00edas, aunque el concepto es muy similar.
"},{"location":"cleancode/angular/","title":"Estructura y Buenas pr\u00e1cticas - Angular","text":"Nota
Antes de empezar y para puntualizar, Angular se considera un framework SPA Single-page application.
En esta parte vamos a explicar los fundamentos de un proyecto en Angular y las recomendaciones existentes.
"},{"location":"cleancode/angular/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":""},{"location":"cleancode/angular/#ciclo-de-vida-de-angular","title":"Ciclo de vida de Angular","text":"El comportamiento de ciclo de vida de un componente Angular pasa por diferentes etapas que podemos ver en el esquema que mostramos a continuaci\u00f3n:
Es importante tenerlo claro para saber que m\u00e9todos podemos utilizar para realizar operaciones con el componente.
"},{"location":"cleancode/angular/#carpetas-creadas-por-angular","title":"Carpetas creadas por Angular","text":"Al crear una aplicaci\u00f3n Angular, tendremos los siguientes directorios:
Otros ficheros importantes de un proyecto de Angular
Otros archivos que debemos tener en cuenta dentro del proyecto son:
Existe m\u00faltiples consensos al respecto de como estructurar un proyecto en Angular, pero al final, depende de los requisitos del proyecto. Una sugerencia de como hacerlo es la siguiente:
- src\\app\n - core /* Componentes y utilidades comunes */ \n - header /* Estructura del header */ \n - footer /* Estructura del footer */ \n - domain1 /* M\u00f3dulo con los componentes del dominio1 */\n - services /* Servicios con operaciones del dominio1 */ \n - models /* Modelos de datos del dominio1 */ \n - component1 /* Componente1 del dominio1 */ \n - componentX /* ComponenteX del dominio1 */ \n - domainX /* As\u00ed para el resto de dominios de la aplicaci\u00f3n */\n
Recordar, que esto es una sugerencia para una estructura de carpetas y componentes. No existe un estandar.
ATENCI\u00d3N: Componentes gen\u00e9ricos
Debemos tener en cuenta que a la hora de programar un componente core
, lo ideal es pensar que sea un componente plug & play, es decir que si lo copias y lo llevas a otro proyecto funcione sin la necesidad de adaptarlo.
A continuaci\u00f3n veremos un listado de buenas pr\u00e1cticas de Angular y de c\u00f3digo limpio que deber\u00edamos intentar seguir en nuestro desarrollo.
"},{"location":"cleancode/angular/#estructura-de-archivos","title":"Estructura de archivos","text":"Antes de empezar con un proyecto lo ideal, es pararse y pensar en los requerimientos de una buena estructura, en un futuro lo agradecer\u00e1s.
"},{"location":"cleancode/angular/#nombres-claros","title":"Nombres claros","text":"Utilizar la S de los principios S.O.L.I.D para los nombres de variables, m\u00e9todos y dem\u00e1s c\u00f3digo.
El efecto que produce este principio son clases con nombres muy descriptivos y por tanto largos.
Tambi\u00e9n se recomienta utilizar kebab-case
para los nombres de ficheros. Ej. hero-button.component.ts
Intenta organizar tu c\u00f3digo fuente:
Un linter es una herramienta que nos ayuda a seguir las buenas pr\u00e1cticas o gu\u00edas de estilo de nuestro c\u00f3digo fuente. En este caso, para JavaScript, proveeremos de unos muy famosos. Una de las m\u00e1s famosas es la combinaci\u00f3n de Angular app to ESLint with Prettier, AirBnB Styleguide Recordar que a\u00f1adir este tipo de configuraci\u00f3n es opcional, pero necesaria para tener un buen c\u00f3digo de calidad.
"},{"location":"cleancode/angular/#git-hooks","title":"Git Hooks","text":"Los Git Hooks son scripts de shell que se ejecutan autom\u00e1ticamente antes o despu\u00e9s de que Git ejecute un comando importante como Commit o Push. Para hacer uso de el es tan sencillo como:
npm install husky --save-dev
Y a\u00f1adir en el fichero lo siguiente:
// package.json\n{\n\"husky\": {\n\"hooks\": {\n\"pre-commit\": \"npm test\",\n\"pre-push\": \"npm test\",\n\"...\": \"...\"\n}\n}\n}\n
Usar husky para el preformateo de c\u00f3digo antes de subirlo
Es una buena pr\u00e1ctica que todo el equipo use el mismo est\u00e1ndar de formateo de codigo, con husky se puede solucionar.
"},{"location":"cleancode/angular/#utilizar-banana-in-the-box","title":"Utilizar Banana in the Box","text":"Como el nombre sugiere banana in the box se debe a la forma que tiene lo siguiente: [{}] Esto es una forma muy sencilla de trabajar los cambios en la forma de Two ways binding. Es decir, el padre informa de un valor u objeto y el hijo lo manipula y actualiza el estado/valor al padre inmediatamente. La forma de implementarlo es sencillo
Padre: HTML:
<my-input [(text)]=\"text\"></my-input>
Hijo
@Input() value: string;\n@Output() valueChange = new EventEmitter<string>();\nupdateValue(value){\nthis.value = value;\nthis.valueChange.emit(value);\n}\n
Prefijo Change
Destacar que el prefijo 'Change' es necesario incluirlo en el Hijo para que funcione
"},{"location":"cleancode/angular/#correcto-uso-de-los-servicios","title":"Correcto uso de los servicios","text":"Una buena practica es aconsejable no declarar los servicios en el provides, sino usar un decorador que forma parte de las ultimas versiones de Angular
@Injectable({\nprovidedIn: 'root',\n})\nexport class HeroService {\nconstructor() { }\n}\n
"},{"location":"cleancode/angular/#lazy-load","title":"Lazy Load","text":"Lazy Load es un patr\u00f3n de dise\u00f1o que consiste en retrasar la carga o inicializaci\u00f3n
desde el app-routing.module.ts
A\u00f1adiremos un codigo parecido a este
{\npath: 'customers',\nloadChildren: () => import('./customers/customers.module').then(m => m.CustomersModule)\n},\n
Con esto veremos que el m\u00f3dulo se cargar\u00e1 seg\u00fan se necesite.
"},{"location":"cleancode/nodejs/","title":"Estructura y Buenas pr\u00e1cticas - Nodejs","text":""},{"location":"cleancode/nodejs/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":"En los proyectos Nodejs no existe nada estandarizado y oficial que hable sobre estructura de proyectos y nomenclatura de Nodejs. Tan solo existen algunas sugerencias y buenas pr\u00e1cticas a la hora de desarrollar que te recomiendo que utilices en la medida de lo posible.
Tip
Piensa que el c\u00f3digo fuente que escribes hoy, es como un libro que se leer\u00e1 durante a\u00f1os. Alguien tendr\u00e1 que coger tu c\u00f3digo y leerlo en unos meses o a\u00f1os para hacer alguna modificaci\u00f3n y, como buenos desarrolladores que somos, tenemos la obligaci\u00f3n de facilitarle en todo lo posible la comprensi\u00f3n de ese c\u00f3digo fuente. Quiz\u00e1 esa persona futura podr\u00edas ser tu en unos meses y quedar\u00eda muy mal que no entendieras ni tu propio c\u00f3digo
"},{"location":"cleancode/nodejs/#estructura-en-capas","title":"Estructura en capas","text":"Todos los proyectos para crear una Rest API con node y express est\u00e1n divididos en capas. Como m\u00ednimo estar\u00e1 la capa de rutas, controlador y modelo. En nuestro caso vamos a a\u00f1adir una capa mas de servicios para quitarle trabajo al controlador y desacoplarlo de la capa de datos. As\u00ed si en el futuro queremos cambiar nuestra base de datos no romperemos tanto \ud83d\ude0a
Rutas
En nuestro proyecto una ruta ser\u00e1 una secci\u00f3n de c\u00f3digo express que asociar\u00e1 un verbo http, una ruta o patr\u00f3n de url y una funci\u00f3n perteneciente al controlador para manejar esa petici\u00f3n.
Controladores
En nuestros controladores tendremos los m\u00e9todos que obtendr\u00e1n las solicitudes de las rutas, se comunicar\u00e1n con la capa de servicio y convertir\u00e1n estas solicitudes en respuestas http.
Servicio
Nuestra capa de servicio incluir\u00e1 toda la l\u00f3gica de negocio de nuestra aplicaci\u00f3n. Para realizar sus operaciones puede realizar llamadas tanto a otras clases dentro de esta capa, como a clases de la capa inferior.
Modelo
Como su nombre indica esta capa representa los modelos de datos de nuestra aplicaci\u00f3n. En nuestro caso, al usar un ODM, solo tendremos modelos de datos definidos seg\u00fan sus requisitos.
"},{"location":"cleancode/nodejs/#buenas-practicas","title":"Buenas pr\u00e1cticas","text":""},{"location":"cleancode/nodejs/#accesos-entre-capas","title":"Accesos entre capas","text":"En base a la divisi\u00f3n por capas que hemos comentado arriba, y el resto de entidades implicadas, hay una serie de reglas important\u00edsimas que debes seguir muy de cerca:
Un Controlador
Un Servicio
Un linter es una herramienta que nos ayuda a seguir las buenas pr\u00e1cticas o gu\u00edas de estilo de nuestro c\u00f3digo fuente. En este caso, para JavaScript, proveeremos de unos muy famosos. Una de las m\u00e1s famosas es la combinaci\u00f3n de Angular app to ESLint with Prettier, AirBnB Styleguide Recordar que a\u00f1adir este tipo de configuraci\u00f3n es opcional, pero necesaria para tener un buen c\u00f3digo de calidad.
"},{"location":"cleancode/react/","title":"Estructura y Buenas pr\u00e1cticas - React","text":"Nota
Antes de empezar y para puntualizar, React se considera un framework SPA Single-page application.
Aqu\u00ed tenemos que puntualizar que React por s\u00ed mismo es una librer\u00eda y no un framework, puesto que se ocupa de las interfaces de usuario. Sin embargo, diversos a\u00f1adidos pueden convertir a React en un producto equiparable en caracter\u00edsticas a un framework.
En esta parte vamos a explicar los fundamentos de un proyecto en React y las recomendaciones existentes.
"},{"location":"cleancode/react/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":""},{"location":"cleancode/react/#como-funciona-react","title":"Como funciona React","text":"React es una herramienta para crear interfaces de usuario de una manera \u00e1gil y vers\u00e1til, en lugar de manipular el DOM del navegador directamente, React crea un DOM virtual en la memoria, d\u00f3nde realiza toda la manipulaci\u00f3n necesaria antes de realizar los cambios en el DOM del navegador. Estas interfaces de usuario denominadas componentes pueden definirse como clases o funciones independiente y reutilizables con unos par\u00e1metros de entrada que devuelven elementos de react. En ese tutorial solo utilizaremos componentes de tipo funci\u00f3n.
Por si no te suena, un componente web es una forma de crear un bloque de c\u00f3digo encapsulado y de responsabilidad \u00fanica que puede reutilizarse en cualquier pagina mediante nuevas etiquetas html.
Nota
Desde la versi\u00f3n 16.8 se introdujo en React el concepto de hooks. Esto permiti\u00f3 usar el estado y otras caracter\u00edsticas de React sin necesidad de escribir una clase.
"},{"location":"cleancode/react/#ciclo-de-vida-de-un-componente-en-react","title":"Ciclo de vida de un componente en React","text":"El comportamiento de ciclo de vida de un componente React pasa por diferentes etapas que podemos ver en el esquema que mostramos a continuaci\u00f3n:
Es importante tenerlo claro para saber que m\u00e9todos podemos utilizar para realizar operaciones con el componente.
"},{"location":"cleancode/react/#carpetas-creadas-por-react","title":"Carpetas creadas por React","text":"Al crear una aplicaci\u00f3n React, tendremos los siguientes directorios:
Otros ficheros importantes de un proyecto de React
Otros archivos que debemos tener en cuenta dentro del proyecto son:
Existe m\u00faltiples consensos al respecto de c\u00f3mo estructurar un proyecto en React, pero al final, depende de los requisitos del proyecto. Una sugerencia de c\u00f3mo hacerlo es la siguiente:
- src\\\n - components /* Componentes comunes */ \n - context /* Carpeta para almacenar el contexto de la aplicaci\u00f3n */ \n - pages /* Carpeta para componentes asociados a rutas del navegador */\n - components /* Componentes propios de cada p\u00e1gina */ \n - redux /* Para todo aquello relacionado con el estado de nuestra aplicaci\u00f3n */\n - types /* Carpeta para los tipos de datos de typescript */\n
Recordad, que \u00e9sto es una sugerencia para una estructura de carpetas y componentes. No existe un est\u00e1ndar.
"},{"location":"cleancode/react/#buenas-practicas","title":"Buenas pr\u00e1cticas","text":"A continuaci\u00f3n, veremos un listado de buenas pr\u00e1cticas de React y de c\u00f3digo limpio que deber\u00edamos intentar seguir en nuestro desarrollo.
"},{"location":"cleancode/react/#estructura-de-archivos","title":"Estructura de archivos","text":"Antes de empezar con un proyecto lo ideal, es pararse y pensar en los requerimientos de una buena estructura, en un futuro lo agradecer\u00e1s.
"},{"location":"cleancode/react/#nombres-claros","title":"Nombres claros","text":"Utilizar la S de los principios S.O.L.I.D para los nombres de variables, m\u00e9todos y dem\u00e1s c\u00f3digo.
El efecto que produce este principio son clases con nombres muy descriptivos y por tanto largos.
"},{"location":"cleancode/react/#organiza-tu-codigo","title":"Organiza tu c\u00f3digo","text":"Intenta organizar tu c\u00f3digo fuente:
Un linter es una herramienta que nos ayuda a seguir las buenas pr\u00e1cticas o gu\u00edas de estilo de nuestro c\u00f3digo fuente. En este caso, para JavaScript, proveeremos de unos muy famosos. Recordar que a\u00f1adir este tipo de configuraci\u00f3n es opcional, pero necesaria para tener un buen c\u00f3digo de calidad.
"},{"location":"cleancode/react/#usa-el-estado-correctamente","title":"Usa el estado correctamente","text":"La primera regla del hook useState es usarlo solo localmente. El estado global de nuestra aplicaci\u00f3n debe de entrar a nuestro componente a trav\u00e9s de las props as\u00ed como las mutaciones de este solo deben realizarse mediante alguna herramienta de gesti\u00f3n de estados como redux. Por otro lado, es preferible no abusar de los hooks y solo usarlos cuando sea realmente necesario ya que pueden reducir el rendimiento de nuestra aplicaci\u00f3n.
"},{"location":"cleancode/react/#reutiliza-codigo-y-componentes","title":"Reutiliza c\u00f3digo y componentes","text":"Siempre que sea posible deberemos de reutilizar c\u00f3digo mediante funciones compartidas o bien si este c\u00f3digo implica almacenamiento de estado u otras caracter\u00edsticas similares mediante custom Hooks.
"},{"location":"cleancode/react/#usa-ts-en-lugar-de-js","title":"Usa TS en lugar de JS","text":"Ya hemos creado nuestro proyecto incluyendo typescript pero esto no viene por defecto en un proyecto React como si pasa con Angular. Nuestra recomendaci\u00f3n es que siempre que puedas a\u00f1adas typescript a tus proyectos React, no solo se gana calidad en el c\u00f3digo, sino que eliminamos la probabilidad de usar un componente incorrectamente y ganamos tiempo de desarrollo.
"},{"location":"cleancode/springboot/","title":"Estructura y Buenas pr\u00e1cticas - Spring Boot","text":""},{"location":"cleancode/springboot/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":"En Springboot no existe nada estandarizado y oficial que hable sobre estructura de proyectos y nomenclatura. Tan solo existen algunas sugerencias y buenas pr\u00e1cticas a la hora de desarrollar que te recomiendo que utilices en la medida de lo posible.
Tip
Piensa que el c\u00f3digo fuente que escribes hoy, es como un libro que se leer\u00e1 durante a\u00f1os. Alguien tendr\u00e1 que coger tu c\u00f3digo y leerlo en unos meses o a\u00f1os para hacer alguna modificaci\u00f3n y, como buenos desarrolladores que somos, tenemos la obligaci\u00f3n de facilitarle en todo lo posible la comprensi\u00f3n de ese c\u00f3digo fuente. Quiz\u00e1 esa persona futura podr\u00edas ser tu en unos meses y quedar\u00eda muy mal que no entendieras ni tu propio c\u00f3digo
"},{"location":"cleancode/springboot/#estructura-en-capas","title":"Estructura en capas","text":"Todos los proyectos web que construimos basados en Springboot se caracterizan por estar divididos en tres capas (a menos que utilicemos DDD para desarrollar que entonces existen infinitas capas ).
Servicios
. Es la capa intermedia que da soporte a las operaciones que est\u00e1n expuestas y ejecutan toda la l\u00f3gica de negocio de la aplicaci\u00f3n. Para realizar sus operaciones puede realizar llamadas tanto a otras clases dentro de esta capa, como a clases de la capa inferior.finales
, no pueden llamar a ninguna otra clase para ejecutar sus operaciones, ni siquiera de su misma capa.En proyectos medianos o grandes, estructurar los directorios del proyecto en base a la estructura anteriormente descrita ser\u00eda muy complejo, ya que en cada uno de los niveles tendr\u00edamos muchas clases. As\u00ed que lo normal es diferenciar por \u00e1mbito funcional y dentro de cada package
realizar la separaci\u00f3n en Controlador
, L\u00f3gica
y Acceso a datos
.
Tened en cuenta en un mismo \u00e1mbito funcional puede tener varios controladores o varios servicios de l\u00f3gica uno por cada entidad que estemos tratando. Siempre que se pueda, agruparemos entidades que intervengan dentro de una misma funcionalidad.
En nuestro caso del tutorial, tendremos tres \u00e1mbitos funcionales Categor\u00eda
, Autor
, y Juego
que diferenciaremos cada uno con su propia estructura.
@TODO: En construcci\u00f3n
En base a la divisi\u00f3n por capas que hemos comentado arriba, y el resto de entidades implicadas, hay una serie de reglas important\u00edsimas que debes seguir muy de cerca:
Controlador
L\u00f3gica
.Acceso a Datos
, siempre debe pasar por la capa L\u00f3gica
.Entity
.Entity
y Dto
.save
para guardar, usemos esa palabra en todas las operaciones que sean de ese tipo. Evitad utilizar diferentes palabras save
, guardar
, persistir
, actualizar
para la misma acci\u00f3n.Servicio
Controlador
.Acceso a Datos
.Acceso a Datos
que NO sean de su \u00e1mbito / competencia.Servicios
para recuperar cierta informaci\u00f3n que no sea de su \u00e1mbito / competencia.Entity
.Acceso a Datos
Controlador
, ni Servicios
, ni Acceso a Datos
.Servicios
.Nota
Antes de empezar y para puntualizar, Vue.js es un framework progresivo para construir interfaces de usuario. A diferencia de otros frameworks monol\u00edticos, Vue.js est\u00e1 dise\u00f1ado desde cero para ser utilizado incrementalmente. La librer\u00eda central est\u00e1 enfocada solo en la capa de visualizaci\u00f3n, y es f\u00e1cil de utilizar e integrar con otras librer\u00edas o proyectos existentes. Por otro lado, Vue.js tambi\u00e9n es perfectamente capaz de impulsar sofisticadas Single-Page Applications cuando se utiliza en combinaci\u00f3n con herramientas modernas y librer\u00edas de apoyo.
En esta parte vamos a explicar los fundamentos de un proyecto en Vue.js y las recomendaciones existentes.
"},{"location":"cleancode/vuejs/#estructura-y-funcionamiento","title":"Estructura y funcionamiento","text":""},{"location":"cleancode/vuejs/#ciclos-de-vida-de-un-componente","title":"Ciclos de vida de un componente","text":"Vue.js cuenta con un conjunto de ciclos de vida que permiten a los desarrolladores controlar y personalizar el comportamiento de sus componentes en diferentes momentos. Estos ciclos de vida se pueden agrupar en tres fases principales: creaci\u00f3n, actualizaci\u00f3n y eliminaci\u00f3n.
A continuaci\u00f3n, te explicar\u00e9 cada uno de los ciclos de vida disponibles en Vue.js junto con la Options API:
beforeCreate: Este ciclo de vida se ejecuta inmediatamente despu\u00e9s de que se haya creado una instancia de componente, pero antes de que se haya creado su DOM. En este punto, a\u00fan no es posible acceder a las propiedades del componente y a\u00fan no se han establecido las observaciones reactivas.
created: Este ciclo de vida se ejecuta despu\u00e9s de que se haya creado una instancia de componente y se hayan establecido las observaciones reactivas. En este punto, el componente ya puede acceder a sus propiedades y m\u00e9todos.
beforeMount: Este ciclo de vida se ejecuta justo antes de que el componente se monte en el DOM. En este punto, el componente ya est\u00e1 preparado para ser renderizado, pero a\u00fan no se ha agregado al \u00e1rbol de elementos del DOM.
mounted: Este ciclo de vida se ejecuta despu\u00e9s de que el componente se ha montado en el DOM. En este punto, el componente ya est\u00e1 en el \u00e1rbol de elementos del DOM y se puede acceder a sus elementos hijos y a los elementos del DOM que lo rodean.
beforeUpdate: Este ciclo de vida se ejecuta justo antes de que el componente se actualice en respuesta a un cambio en sus propiedades o estado. En este punto, el componente a\u00fan no se ha actualizado en el DOM.
updated: Este ciclo de vida se ejecuta despu\u00e9s de que el componente se haya actualizado en el DOM en respuesta a un cambio en sus propiedades o estado. En este punto, el componente ya se ha actualizado en el DOM y se puede acceder a sus elementos hijos y a los elementos del DOM que lo rodean.
beforeUnmount: Este ciclo de vida se ejecuta justo antes de que el componente se elimine del DOM. En este punto, el componente a\u00fan est\u00e1 en el \u00e1rbol de elementos del DOM.
unmounted: Este ciclo de vida se ejecuta despu\u00e9s de que el componente se haya eliminado del DOM. En este punto, el componente ya no est\u00e1 en el \u00e1rbol de elementos del DOM y no se puede acceder a sus elementos hijos.
errorCaptured: Este ciclo de vida se ejecuta cuando se produce un error en cualquier descendiente del componente y se captura en el componente actual. Esto permite que el componente maneje el error de forma personalizada en lugar de propagarse hacia arriba en la cadena de componentes.
activated: Este ciclo de vida se ejecuta cuando un componente que se encuentra en un \u00e1rbol de componentes inactivo (por ejemplo, un componente en una pesta\u00f1a inactiva) se activa.
deactivated: Este ciclo de vida se ejecuta cuando un componente que se encuentra en un \u00e1rbol de componentes activo (por ejemplo, un componente en una pesta\u00f1a activa) se desactiva y se vuelve inactivo.
renderTracked: Este ciclo de vida se ejecuta cuando se observa una dependencia en el proceso de renderizado del componente. Esto se utiliza principalmente para fines de depuraci\u00f3n.
renderTriggered: Este ciclo de vida se ejecuta cuando se desencadena un nuevo renderizado del componente. Esto se utiliza principalmente para fines de depuraci\u00f3n.
serverPrefetch: Este ciclo de vida se utiliza en el contexto de renderizado del lado del servidor (SSR). Se ejecuta cuando el componente se preprocesa en el servidor antes de enviarse al cliente. En este punto, el componente a\u00fan no se ha montado en el DOM y no se pueden realizar operaciones que dependan del DOM. Esto se utiliza principalmente para cargar datos de forma as\u00edncrona antes de que se renderice el componente en el servidor.
Os dejo un peque\u00f1o esquema de los ciclos de vida mas importantes y en que momento se ejecutan:
Es importante tenerlo claro para saber que m\u00e9todos podemos utilizar para realizar operaciones con el componente.
"},{"location":"cleancode/vuejs/#carpetas-creadas-por-vuejs","title":"Carpetas creadas por Vue.js","text":"Otros ficheros importantes de un proyecto de Vue.js
Otros archivos que debemos tener en cuenta dentro del proyecto son:
A continuaci\u00f3n veremos un listado de buenas pr\u00e1cticas de Vue.js y de c\u00f3digo limpio que deber\u00edamos intentar seguir en nuestro desarrollo.
"},{"location":"cleancode/vuejs/#estructura-de-archivos","title":"Estructura de archivos","text":"Antes de empezar con un proyecto lo ideal, es pararse y pensar en los requerimientos de una buena estructura, en un futuro lo agradecer\u00e1s.
"},{"location":"cleancode/vuejs/#nombres-claros","title":"Nombres claros","text":"Determinar una manera de nombrar a los componentes (UpperCamelCase, lowerCamelCase, kebab-case, snake_case, ...) y continuarla para todos los archivos, nombres descriptivos de los componentes y en una ruta acorde (si es un componente que forma parte de una pantalla, se ubicar\u00e1 dentro de la carpeta de esa pantalla pero si se usa en m\u00e1s de una pantalla, se ubicar\u00e1 en una carpeta externa a cualquier pantalla llamada common), componentes de m\u00e1ximo 350 l\u00edneas y componentes con finalidad \u00fanica (recibe los datos necesarios para realizar las tareas b\u00e1sicas de ese componente).
"},{"location":"cleancode/vuejs/#organiza-tu-codigo","title":"Organiza tu c\u00f3digo","text":"El c\u00f3digo debe estar ordenado dentro de los componente siguiendo un orden de importancia similar a este:
Modificaciones entre componentes
A la hora de crear un componente b\u00e1sico (como un input propio) que necesite modificar su propio valor (algo que un componente hijo no debe hacer, ya que la variable estar\u00e1 en el padre), saber diferenciar entre v-model y modelValue (esta \u00faltima s\u00ed que permite modificar el valor en el padre mediante el evento update:modelValue sin tener que hacer nada m\u00e1s en el padre que pasarle el valor).
Utiliza formateo y correcci\u00f3n de c\u00f3digo
Si has seguido nuestro tutorial se habr\u00e1 instalado ESLint y Prettier. Si no, deber\u00edas instalarlo para generar c\u00f3digo de buena calidad. Adem\u00e1s de instalar alguna extensi\u00f3n en Visual Studio Code que te ayude a gestionar esas herramientas.
Nomenclatura de funciones y variables
El nombre de las funciones, al igual que los path de una API, deber\u00edan ser autoexplicativos y no tener que seguir la traza del c\u00f3digo para saber qu\u00e9 hace. Con un buen nombre para cada funci\u00f3n o variables de estado, evitas tener que a\u00f1adir comentarios para explicar qu\u00e9 hace o qu\u00e9 almacena cada una de ellas.
"},{"location":"develop/basic/angular/","title":"Listado simple - Angular","text":"Ahora que ya tenemos listo el proyecto frontend de Angular (en el puerto 4200), ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/angular/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 que Angular tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. Tanto en la propia web de documentaci\u00f3n de Angular como en la web de componentes Angular Material puedes buscar casi cualquier ejemplo que necesites.
Si abrimos el proyecto con el IDE que tengamos (Visual Studio Code en el caso del tutorial) podemos ver que en la carpeta src/app
existen unos ficheros ya creados por defecto. Estos ficheros son:
app.component.ts
\u2192 contiene el c\u00f3digo inicial del proyecto escrito en TypeScript.app.component.html
\u2192 contiene la plantilla inicial del proyecto escrita en HTML.app.component.scss
\u2192 contiene los estilos CSS privados de la plantilla inicial.Vamos a modificar este c\u00f3digo inicial para ver como funciona. Abrimos el fichero app.component.ts
y modificamos la l\u00ednea donde se asigna un valor a la variable title
.
...\ntitle = 'Tutorial de Angular';\n...\n
Ahora abrimos el fichero app.component.html
, borramos todo el c\u00f3digo de la plantilla y a\u00f1adimos el siguiente c\u00f3digo:
<h1>{{title}}</h1>\n
Las llaves dobles permiten hacen un binding entre el c\u00f3digo del componente y la plantilla. Es decir, en este caso ir\u00e1 al c\u00f3digo TypeScript y buscar\u00e1 el valor de la variable title
.
Consejo
El binding tambi\u00e9n nos sirve para ejecutar los m\u00e9todos de TypeScript desde el c\u00f3digo HTML. Adem\u00e1s si el valor que contiene la variable se modificara durante la ejecuci\u00f3n de alg\u00fan m\u00e9todo, autom\u00e1ticamente el c\u00f3digo HTML refrescar\u00eda el nuevo valor de la variable title
Si abrimos el navegador y accedemos a http://localhost:4200/
podremos ver el resultado del c\u00f3digo.
Lo primero que vamos a hacer es escoger un tema y una paleta de componentes para trabajar. Lo m\u00e1s c\u00f3modo es trabajar con Material
que ya viene perfectamente integrado en Angular. Ejecutamos el comando y elegimos la paleta de colores que m\u00e1s nos guste o bien creamos una custom:
ng add @angular/material\n
Recuerda
Al a\u00f1adir una nueva librer\u00eda tenemos que parar el servidor y volver a arrancarlo para que compile y precargue las nuevas dependencias.
Una vez a\u00f1adida la dependencia, lo que queremos es crear una primera estructura inicial a la p\u00e1gina. Si te acuerdas cual era la estructura (y si no te acuerdas, vuelve a la secci\u00f3n Contexto de la aplicaci\u00f3n
y lo revisas), ten\u00edamos una cabecera superior con un logo y t\u00edtulo y unas opciones de men\u00fa.
Pues vamos a ello, crearemos esa estructura com\u00fan para toda la aplicaci\u00f3n. Este componente al ser algo core para toda la aplicaci\u00f3n deber\u00edamos crearlo dentro del m\u00f3dulo core
como ya vimos anteriormente.
Pero antes de todo, vamos a crear los m\u00f3dulos generales de la aplicaci\u00f3n, as\u00ed que ejecutamos en consola el comando que nos permite crear un m\u00f3dulo nuevo:
ng generate module core\n
Y a\u00f1adimos esos m\u00f3dulos al m\u00f3dulo padre de la aplicaci\u00f3n:
app.module.tsimport { BrowserModule } from '@angular/platform-browser';\nimport { NgModule } from '@angular/core';\n\nimport { AppRoutingModule } from './app-routing.module';\nimport { AppComponent } from './app.component';\nimport { BrowserAnimationsModule } from '@angular/platform-browser/animations';\nimport { CoreModule } from './core/core.module';\n@NgModule({\ndeclarations: [\nAppComponent\n],\nimports: [\nBrowserModule,\nAppRoutingModule,\nCoreModule,\nBrowserAnimationsModule,\n],\nproviders: [],\nbootstrap: [AppComponent]\n})\nexport class AppModule { }\n
Y despu\u00e9s crearemos el componente header, dentro del m\u00f3dulo core. Para eso ejecutaremos el comando:
ng generate component core/header\n
"},{"location":"develop/basic/angular/#codigo-de-la-pantalla","title":"C\u00f3digo de la pantalla","text":"Esto nos crear\u00e1 una carpeta con los ficheros del componente, donde tendremos que copiar el siguiente contenido:
header.component.htmlheader.component.scss<mat-toolbar>\n <mat-toolbar-row>\n <div class=\"header_container\">\n <div class=\"header_title\"> \n <mat-icon>storefront</mat-icon> Ludoteca Tan\n </div>\n\n <div class=\"header_separator\"> | </div>\n\n <div class=\"header_menu\">\n <div class=\"header_button\">\n <a routerLink=\"/games\" routerLinkActive=\"active\">Cat\u00e1logo</a>\n </div>\n <div class=\"header_button\">\n <a routerLink=\"/categories\" routerLinkActive=\"active\">Categor\u00edas</a>\n </div>\n <div class=\"header_button\">\n <a routerLink=\"/authors\" routerLinkActive=\"active\">Autores</a>\n </div>\n </div>\n\n <div class=\"header_login\">\n <mat-icon>account_circle</mat-icon> Sign in\n </div>\n </div>\n </mat-toolbar-row>\n</mat-toolbar>\n
.mat-toolbar {\nbackground-color: blue;\ncolor: white;\n}\n\n.header_container {\ndisplay: flex;\nwidth: 100%;\n.header_title {\n.mat-icon {\nvertical-align: sub;\n}\n}\n\n.header_separator {\nmargin-left: 30px;\nmargin-right: 30px;\n}\n\n.header_menu {\nflex-grow: 4;\ndisplay: flex;\nflex-direction: row;\n\n.header_button {\nmargin-left: 1em;\nmargin-right: 1em;\nfont-size: 16px;\n\na {\nfont-weight: lighter;\ntext-decoration: none;\ncursor: pointer;\ncolor: white;\n}\n\na:hover {\ncolor: grey;\n}\n\na.active {\nfont-weight: normal;\ntext-decoration: underline;\ncolor: lightyellow;\n}\n\n}\n}\n\n.header_login {\nfont-size: 16px;\ncursor: pointer;\n.mat-icon {\nvertical-align: sub;\n}\n}\n}\n
Al utilizar etiquetas de material como mat-toolbar
o mat-icon
y routerLink
necesitaremos importar las dependencias. Esto lo podemos hacer directamente en el m\u00f3dulo del que depende, es decir en el fichero core.module.ts
import { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatToolbarModule } from '@angular/material/toolbar';\nimport { HeaderComponent } from './header/header.component';\nimport { RouterModule } from '@angular/router';\n@NgModule({\ndeclarations: [HeaderComponent],\nimports: [\nCommonModule,\nRouterModule,\nMatIconModule, MatToolbarModule,\n],\nexports: [\nHeaderComponent\n]\n})\nexport class CoreModule { }\n
Adem\u00e1s de a\u00f1adir las dependencias, diremos que este m\u00f3dulo va a exportar el componente HeaderComponent
para poder utilizarlo desde otras p\u00e1ginas.
Ya por \u00faltimo solo nos queda modificar la p\u00e1gina general de la aplicaci\u00f3n app.component.html
para a\u00f1adirle el componente HeaderComponent
.
<div>\n<app-header></app-header>\n<div>\n <router-outlet></router-outlet>\n </div>\n</div>\n
Vamos al navegador y refrescamos la p\u00e1gina, deber\u00eda aparecer una barra superior (Header) con las opciones de men\u00fa. Algo similar a esto:
Recuerda
Cuando se a\u00f1aden componentes a los ficheros html
, siempre se deben utilizar los selectores definidos para el componente. En el caso anterior hemos a\u00f1adido app-header
que es el mismo nombre selector que tiene el componente en el fichero header.component.ts
. Adem\u00e1s, recuerda que para poder utilizar componentes de otros m\u00f3dulos, los debes exportar ya que de lo contrario tan solo podr\u00e1n utilizarse dentro del m\u00f3dulo donde se declaran.
Ya tenemos la estructura principal, ahora vamos a crear nuestra primera pantalla. Vamos a empezar por la de Categor\u00edas
que es la m\u00e1s sencilla, ya que se trata de un listado, que muestra datos sin filtrar ni paginar.
Como categor\u00edas es un dominio funcional de la aplicaci\u00f3n, vamos a crear un m\u00f3dulo que contenga toda la funcionalidad de ese dominio. Ejecutamos en consola:
ng generate module category\n
Y por tanto, al igual que hicimos anteriormente, hay que a\u00f1adir el m\u00f3dulo al fichero app.module.ts
import { BrowserModule } from '@angular/platform-browser';\nimport { NgModule } from '@angular/core';\n\nimport { AppRoutingModule } from './app-routing.module';\nimport { AppComponent } from './app.component';\nimport { BrowserAnimationsModule } from '@angular/platform-browser/animations';\nimport { CoreModule } from './core/core.module';\nimport { CategoryModule } from './category/category.module';\n@NgModule({\ndeclarations: [\nAppComponent\n],\nimports: [\nBrowserModule,\nAppRoutingModule,\nCoreModule,\nCategoryModule,\nBrowserAnimationsModule,\n],\nproviders: [],\nbootstrap: [AppComponent]\n})\nexport class AppModule { }\n
Ahora todas las pantallas, componentes y servicios que creemos, referidos a este dominio funcional, deber\u00e1n ir dentro del modulo cagegory
.
Vamos a crear un primer componente que ser\u00e1 un listado de categor\u00edas. Para ello vamos a ejecutar el siguiente comando:
ng generate component category/category-list\n
Para terminar de configurar la aplicaci\u00f3n, vamos a a\u00f1adir la ruta del componente dentro del componente routing de Angular, para poder acceder a \u00e9l, para ello modificamos el fichero app-routing.module.ts
import { NgModule } from '@angular/core';\nimport { Routes, RouterModule } from '@angular/router';\nimport { CategoryListComponent } from './category/category-list/category-list.component';\nconst routes: Routes = [\n{ path: 'categories', component: CategoryListComponent },\n];\n\n@NgModule({\nimports: [RouterModule.forRoot(routes)],\nexports: [RouterModule]\n})\nexport class AppRoutingModule { }\n
Si abrimos el navegador y accedemos a http://localhost:4200/
podremos navegar mediante el men\u00fa Categor\u00edas
el cual abrir\u00e1 el componente que acabamos de crear.
Ahora vamos a construir la pantalla. Para manejar la informaci\u00f3n del listado, necesitamos almacenar los datos en un objeto de tipo model
. Para ello crearemos un fichero en category\\model\\Category.ts
donde implementaremos la clase necesaria. Esta clase ser\u00e1 la que utilizaremos en el c\u00f3digo html y ts de nuestro componente.
export class Category {\nid: number;\nname: string;\n}\n
Tambi\u00e9n, escribiremos el c\u00f3digo de la pantalla de listado.
category-list.component.htmlcategory-list.component.scsscategory-list.component.ts<div class=\"container\">\n <h1>Listado de Categor\u00edas</h1>\n\n<mat-table [dataSource]=\"dataSource\">\n<ng-container matColumnDef=\"id\">\n<mat-header-cell *matHeaderCellDef> Identificador </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.id}} </mat-cell>\n </ng-container>\n\n<ng-container matColumnDef=\"name\">\n<mat-header-cell *matHeaderCellDef> Nombre categor\u00eda </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.name}} </mat-cell>\n </ng-container>\n\n<ng-container matColumnDef=\"action\">\n<mat-header-cell *matHeaderCellDef></mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n <button mat-icon-button color=\"primary\"><mat-icon>edit</mat-icon></button>\n <button mat-icon-button color=\"accent\"><mat-icon>clear</mat-icon></button>\n </mat-cell>\n </ng-container>\n\n<mat-header-row *matHeaderRowDef=\"displayedColumns; sticky: true\"></mat-header-row>\n<mat-row *matRowDef=\"let row; columns: displayedColumns;\"></mat-row>\n</mat-table>\n\n <div class=\"buttons\">\n <button mat-flat-button color=\"primary\">Nueva categor\u00eda</button>\n </div> \n</div>\n
.container {\nmargin: 20px;\n\nmat-table {\nmargin-top: 10px;\nmargin-bottom: 20px;\n\n.mat-header-row {\nbackground-color:#f5f5f5;\n\n.mat-header-cell {\ntext-transform: uppercase;\nfont-weight: bold;\ncolor: #838383;\n} }\n\n.mat-column-id {\nflex: 0 0 20%;\njustify-content: center;\n}\n\n.mat-column-action {\nflex: 0 0 10%;\njustify-content: center;\n}\n}\n\n.buttons {\ntext-align: right;\n}\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatTableDataSource } from '@angular/material/table';\nimport { Category } from '../model/Category';\n@Component({\nselector: 'app-category-list',\ntemplateUrl: './category-list.component.html',\nstyleUrls: ['./category-list.component.scss']\n})\nexport class CategoryListComponent implements OnInit {\n\ndataSource = new MatTableDataSource<Category>();\ndisplayedColumns: string[] = ['id', 'name', 'action'];\nconstructor() { }\n\nngOnInit(): void {\n}\n\n}\n
El c\u00f3digo HTML es f\u00e1cil de seguir pero por si acaso:
dataSource
definida en el fichero .tsY ya por \u00faltimo, a\u00f1adimos los componentes que se han utilizado de Angular Material a las dependencias del m\u00f3dulo donde est\u00e1 definido el componente en este caso category\\category.module.ts
:
import { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { MatTableModule } from '@angular/material/table';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatButtonModule } from '@angular/material/button';\nimport { CategoryListComponent } from './category-list/category-list.component';\n\n@NgModule({\ndeclarations: [CategoryListComponent],\nimports: [\nCommonModule,\nMatTableModule,\nMatIconModule, MatButtonModule\n],\n})\nexport class CategoryModule { }\n
Si abrimos el navegador y accedemos a http://localhost:4200/
y pulsamos en el men\u00fa de Categor\u00edas
obtendremos una pantalla con un listado vac\u00edo (solo con cabeceras) y un bot\u00f3n de crear Nueva Categor\u00eda que aun no hace nada.
En este punto y para ver como responde el listado, vamos a a\u00f1adir datos. Si tuvieramos el backend implementado podr\u00edamos consultar los datos directamente de una operaci\u00f3n de negocio de backend, pero ahora mismo no lo tenemos implementado as\u00ed que para no bloquear el desarrollo vamos a mockear los datos.
"},{"location":"develop/basic/angular/#creando-un-servicio","title":"Creando un servicio","text":"En angular, cualquier acceso a datos debe pasar por un service
, as\u00ed que vamos a crearnos uno para todas las operaciones de categor\u00edas. Vamos a la consola y ejecutamos:
ng generate service category/category\n
Esto nos crear\u00e1 un servicio, que adem\u00e1s podemos utilizarlo inyect\u00e1ndolo en cualquier componente que lo necesite.
"},{"location":"develop/basic/angular/#implementando-un-servicio","title":"Implementando un servicio","text":"Vamos a implementar una operaci\u00f3n de negocio que recupere el listado de categor\u00edas y lo vamos a hacer de forma reactiva (as\u00edncrona) para simular una petici\u00f3n a backend. Modificamos los siguientes ficheros:
category.service.tscategory-list.component.tsimport { Injectable } from '@angular/core';\nimport { Observable } from 'rxjs';\nimport { Category } from './model/Category';\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService {\n\nconstructor() { }\n\ngetCategories(): Observable<Category[]> {\nreturn new Observable();\n}\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatTableDataSource } from '@angular/material/table';\nimport { Category } from '../model/Category';\nimport { CategoryService } from '../category.service';\n@Component({\nselector: 'app-category-list',\ntemplateUrl: './category-list.component.html',\nstyleUrls: ['./category-list.component.scss']\n})\nexport class CategoryListComponent implements OnInit {\n\ndataSource = new MatTableDataSource<Category>();\ndisplayedColumns: string[] = ['id', 'name', 'action'];\n\nconstructor(\nprivate categoryService: CategoryService,\n) { }\n\nngOnInit(): void {\nthis.categoryService.getCategories().subscribe(\ncategories => this.dataSource.data = categories\n);\n}\n}\n
"},{"location":"develop/basic/angular/#mockeando-datos","title":"Mockeando datos","text":"Como hemos comentado anteriormente, el backend todav\u00eda no est\u00e1 implementado as\u00ed que vamos a mockear datos. Nos crearemos un fichero mock-categories.ts
dentro de model, con datos ficticios y modificaremos el servicio para que devuelva esos datos. De esta forma, cuando tengamos implementada la operaci\u00f3n de negocio en backend, tan solo tenemos que sustuir el c\u00f3digo que devuelve datos est\u00e1ticos por una llamada http.
import { Category } from \"./Category\";\n\nexport const CATEGORY_DATA: Category[] = [\n{ id: 1, name: 'Dados' },\n{ id: 2, name: 'Fichas' },\n{ id: 3, name: 'Cartas' },\n{ id: 4, name: 'Rol' },\n{ id: 5, name: 'Tableros' },\n{ id: 6, name: 'Tem\u00e1ticos' },\n{ id: 7, name: 'Europeos' },\n{ id: 8, name: 'Guerra' },\n{ id: 9, name: 'Abstractos' },\n]
import { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\nimport { CATEGORY_DATA } from './model/mock-categories';\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService {\n\nconstructor() { }\n\ngetCategories(): Observable<Category[]> {\nreturn of(CATEGORY_DATA);\n}\n}\n
Si ahora refrescamos la p\u00e1gina web, veremos que el listado ya tiene datos con los que vamos a interactuar.
"},{"location":"develop/basic/angular/#simulando-las-otras-peticiones","title":"Simulando las otras peticiones","text":"Para terminar, vamos a simular las otras dos peticiones, la de editar y la de borrar para cuando tengamos que utilizarlas. El servicio debe quedar m\u00e1s o menos as\u00ed:
category.service.tsimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\nimport { CATEGORY_DATA } from './model/mock-categories';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService {\n\nconstructor() { }\n\ngetCategories(): Observable<Category[]> {\nreturn of(CATEGORY_DATA);\n}\n\nsaveCategory(category: Category): Observable<Category> {\nreturn of(null);\n}\ndeleteCategory(idCategory : number): Observable<any> {\nreturn of(null);\n} }\n
"},{"location":"develop/basic/angular/#anadiendo-acciones-al-listado","title":"A\u00f1adiendo acciones al listado","text":""},{"location":"develop/basic/angular/#crear-componente_2","title":"Crear componente","text":"Ahora nos queda a\u00f1adir las acciones al listado: crear, editar y eliminar. Empezaremos primero por las acciones de crear y editar, que ambas deber\u00edan abrir una ventana modal con un formulario para poder modificar datos de la entidad Categor\u00eda
. Como siempre, para crear un componente usamos el asistente de Angular, esta vez al tratarse de una pantalla que solo vamos a utilizar dentro del dominio de categor\u00edas, tiene sentido que lo creemos dentro de ese m\u00f3dulo:
ng generate component category/category-edit\n
Ahora vamos a hacer que se abra al pulsar el bot\u00f3n Nueva categor\u00eda
. Para eso, vamos al fichero category-list.component.ts
y a\u00f1adimos un nuevo m\u00e9todo:
...\nimport { MatDialog } from '@angular/material/dialog';\nimport { CategoryEditComponent } from '../category-edit/category-edit.component';\n...\nconstructor(\nprivate categoryService: CategoryService,\npublic dialog: MatDialog,\n) { }\n...\ncreateCategory() { const dialogRef = this.dialog.open(CategoryEditComponent, {\ndata: {}\n});\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); } ...\n
Para poder abrir un componente dentro de un dialogo necesitamos obtener en el constructor un MatDialog. De ah\u00ed que hayamos tenido que a\u00f1adirlo como import y en el constructor.
Dentro del m\u00e9todo createCategory
lo que hacemos es crear un dialogo con el componente CategoryEditComponent
en su interior, pasarle unos datos de creaci\u00f3n, donde podemos poner estilos del dialog y un objeto data
donde pondremos los datos que queremos pasar entre los componentes. Por \u00faltimo, nos suscribimos al evento afterClosed
para ejecutar las acciones que creamos oportunas, en nuestro caso volveremos a cargar el listado inicial.
Como hemos utilizado un MatDialog
en el componente, necesitamos a\u00f1adirlo tambi\u00e9n al m\u00f3dulo, as\u00ed que abrimos el fichero category.module.ts
y a\u00f1adimos:
...\nimport { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';\n@NgModule({\ndeclarations: [CategoryListComponent, CategoryEditComponent],\nimports: [\n...\nMatDialogModule\n],\nproviders: [\n{\nprovide: MAT_DIALOG_DATA,\nuseValue: {},\n},\n]\n})\nexport class CategoryModule { }\n
Y ya por \u00faltimo enlazamos el click en el bot\u00f3n con el m\u00e9todo que acabamos de crear para abrir el dialogo. Modificamos el fichero category-list.component.html
y a\u00f1adimos el evento click:
...\n<div class=\"buttons\">\n<button mat-flat-button color=\"primary\" (click)=\"createCategory()\">Nueva categor\u00eda</button> \n</div> \n</div>\n
Si refrescamos el navegador y pulsamos el bot\u00f3n Nueva categor\u00eda
veremos como se abre una ventana modal de tipo Dialog con el componente nuevo que hemos creado, aunque solo se leer\u00e1 category-edit works!
que es el contenido por defecto del componente.
Ahora vamos a darle forma al formulario de editar y crear. Para ello vamos al html, ts y css del componente y pegamos el siguiente contenido:
category-edit.component.htmlcategory-edit.component.scsscategory-edit.component.ts<div class=\"container\">\n <h1>Crear categor\u00eda</h1>\n\n <form>\n <mat-form-field>\n <mat-label>Identificador</mat-label>\n <input type=\"text\" matInput placeholder=\"Identificador\" [(ngModel)]=\"category.id\" name=\"id\" disabled>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Nombre</mat-label>\n <input type=\"text\" matInput placeholder=\"Nombre de categor\u00eda\" [(ngModel)]=\"category.name\" name=\"name\" required>\n <mat-error>El nombre no puede estar vac\u00edo</mat-error>\n </mat-form-field>\n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onClose()\">Cerrar</button>\n <button mat-flat-button color=\"primary\" (click)=\"onSave()\">Guardar</button>\n </div>\n</div>\n
.container {\nmin-width: 350px;\nmax-width: 500px;\npadding: 20px;\n\nform {\ndisplay: flex;\nflex-direction: column;\nmargin-bottom:20px;\n}\n\n.buttons {\ntext-align: right;\n\nbutton {\nmargin-left: 10px;\n}\n}\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatDialogRef } from '@angular/material/dialog';\nimport { CategoryService } from '../category.service';\nimport { Category } from '../model/Category';\n\n@Component({\nselector: 'app-category-edit',\ntemplateUrl: './category-edit.component.html',\nstyleUrls: ['./category-edit.component.scss']\n})\nexport class CategoryEditComponent implements OnInit {\n\ncategory : Category;\n\nconstructor(\npublic dialogRef: MatDialogRef<CategoryEditComponent>,\nprivate categoryService: CategoryService\n) { }\n\nngOnInit(): void {\nthis.category = new Category();\n}\n\nonSave() {\nthis.categoryService.saveCategory(this.category).subscribe(result => {\nthis.dialogRef.close();\n}); } onClose() {\nthis.dialogRef.close();\n}\n\n}\n
Si te fijas en el c\u00f3digo TypeScript, hemos a\u00f1adido en el m\u00e9todo onSave
una llamada al servicio de CategoryService
que aunque no realice ninguna operaci\u00f3n de momento, por lo menos lo dejamos preparado para conectar con el servidor.
Adem\u00e1s, como siempre, al utilizar componentes matInput
, matForm
, matError
hay que a\u00f1adirlos como dependencias en el m\u00f3dulo category.module.ts
:
...\nimport { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';\nimport { MatFormFieldModule } from '@angular/material/form-field';\nimport { MatInputModule } from '@angular/material/input';\nimport { FormsModule, ReactiveFormsModule } from '@angular/forms';\n@NgModule({\ndeclarations: [CategoryListComponent, CategoryEditComponent],\nimports: [\n...\nMatDialogModule,\nMatFormFieldModule,\nMatInputModule,\nFormsModule,\nReactiveFormsModule,\n],\nproviders: [\n{\nprovide: MAT_DIALOG_DATA,\nuseValue: {},\n},\n]\n})\nexport class CategoryModule { }\n
Ahora podemos navegar y abrir el cuadro de dialogo mediante el bot\u00f3n Nueva categor\u00eda
para ver como queda nuestro formulario.
El mismo componente que hemos utilizado para crear una nueva categor\u00eda, nos sirve tambi\u00e9n para editar una categor\u00eda existente. Tan solo tenemos que utilizar la funcionalidad que Angular nos proporciona y pasarle los datos a editar en la llamada de apertura del Dialog. Vamos a implementar funcionalidad sobre el icono editar
, tendremos que modificar unos cuantos ficheros:
<div class=\"container\">\n <h1>Listado de Categor\u00edas</h1>\n\n <mat-table [dataSource]=\"dataSource\"> \n <ng-container matColumnDef=\"id\">\n <mat-header-cell *matHeaderCellDef> Identificador </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.id}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"name\">\n <mat-header-cell *matHeaderCellDef> Nombre categor\u00eda </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.name}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <mat-header-cell *matHeaderCellDef></mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n<button mat-icon-button color=\"primary\" (click)=\"editCategory(element)\">\n<mat-icon>edit</mat-icon>\n</button>\n<button mat-icon-button color=\"accent\"><mat-icon>clear</mat-icon></button>\n </mat-cell>\n </ng-container>\n\n <mat-header-row *matHeaderRowDef=\"displayedColumns; sticky: true\"></mat-header-row>\n <mat-row *matRowDef=\"let row; columns: displayedColumns;\"></mat-row>\n </mat-table>\n\n <div class=\"buttons\">\n <button mat-flat-button color=\"primary\" (click)=\"createCategory()\">Nueva categor\u00eda</button> \n </div> \n</div>\n
export class CategoryListComponent implements OnInit {\n\ndataSource = new MatTableDataSource<Category>();\ndisplayedColumns: string[] = ['id', 'name', 'action'];\n\nconstructor(\nprivate categoryService: CategoryService,\npublic dialog: MatDialog,\n) { }\n\nngOnInit(): void {\nthis.categoryService.getCategories().subscribe(\ncategories => this.dataSource.data = categories\n);\n}\n\ncreateCategory() { const dialogRef = this.dialog.open(CategoryEditComponent, {\ndata: {}\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); } editCategory(category: Category) {\nconst dialogRef = this.dialog.open(CategoryEditComponent, {\ndata: { category: category }\n});\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n});\n}\n}\n
Y los Dialog:
category-edit.component.htmlcategory-edit.component.ts<div class=\"container\">\n<h1 *ngIf=\"category.id == null\">Crear categor\u00eda</h1>\n<h1 *ngIf=\"category.id != null\">Modificar categor\u00eda</h1>\n<form>\n<mat-form-field>\n...\n
import { Component, OnInit, Inject } from '@angular/core';\nimport { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';\nimport { CategoryService } from '../category.service';\nimport { Category } from '../model/Category';\n\n@Component({\nselector: 'app-category-edit',\ntemplateUrl: './category-edit.component.html',\nstyleUrls: ['./category-edit.component.scss']\n})\nexport class CategoryEditComponent implements OnInit {\n\ncategory : Category;\n\nconstructor(\npublic dialogRef: MatDialogRef<CategoryEditComponent>,\n@Inject(MAT_DIALOG_DATA) public data: any,\nprivate categoryService: CategoryService\n) { }\n\nngOnInit(): void {\nif (this.data.category != null) {\nthis.category = this.data.category;\n}\nelse {\nthis.category = new Category();\n}\n}\n\nonSave() {\nthis.categoryService.saveCategory(this.category).subscribe(result => {\nthis.dialogRef.close();\n}); } onClose() {\nthis.dialogRef.close();\n}\n\n}\n
Navegando ahora por la p\u00e1gina y pulsando en el icono de editar, se deber\u00eda abrir una ventana con los datos que hemos seleccionado, similar a esta imagen:
Si te fijas, al modificar los datos dentro de la ventana de di\u00e1logo se modifica tambi\u00e9n en el listado. Esto es porque estamos pasando el mismo objeto desde el listado a la ventana dialogo y al ser el listado y el formulario reactivos los dos, cualquier cambio sobre los datos se refresca directamente en la pantalla.
Hay veces en la que este comportamiento nos interesa, pero en este caso no queremos que se modifique el listado. Para solucionarlo debemos hacer una copia del objeto, para que ambos modelos (formulario y listado) utilicen objetos diferentes. Es tan sencillo como modificar category-edit.component.ts
y a\u00f1adirle una copia del dato
...\nngOnInit(): void {\nif (this.data.category != null) {\nthis.category = Object.assign({}, this.data.category);\n}\nelse {\nthis.category = new Category();\n}\n}\n...\n
Cuidado
Hay que tener mucho cuidado con el binding de los objetos. Hay veces que al modificar un objeto NO queremos que se modifique en todas sus instancias y tenemos que poner especial cuidado en esos aspectos.
"},{"location":"develop/basic/angular/#accion-de-borrado","title":"Acci\u00f3n de borrado","text":"Por norma general, toda acci\u00f3n de borrado de un dato de pantalla requiere una confirmaci\u00f3n previa por parte del usuario. Es decir, para evitar que el dato se borre accidentalmente el usuario tendr\u00e1 que confirmar su acci\u00f3n. Por tanto vamos a crear un componente que nos permita pedir una confirmaci\u00f3n al usuario.
Como esta pantalla de confirmaci\u00f3n va a ser algo com\u00fan a muchas acciones de borrado de nuestra aplicaci\u00f3n, vamos a crearla dentro del m\u00f3dulo core
. Como siempre, ejecutamos el comando en consola:
ng generate component core/dialog-confirmation\n
E implementamos el c\u00f3digo que queremos que tenga el componente. Al ser un componente gen\u00e9rico vamos a aprovechar y leeremos las variables que le pasemos en data
.
<div class=\"container\">\n <h1>{{title}}</h1>\n <div [innerHTML]=\"description\" class=\"description\"></div>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onNo()\">No</button>\n <button mat-flat-button color=\"primary\" (click)=\"onYes()\">S\u00ed</button>\n </div>\n</div> \n
.container {\nmin-width: 350px;\nmax-width: 500px;\npadding: 20px;\n\n.description {\nmargin-bottom: 20px;\n}\n\n.buttons {\ntext-align: right;\n\nbutton {\nmargin-left: 10px;\n}\n}\n}
import { Component, OnInit, Inject } from '@angular/core';\nimport { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';\n\n@Component({\nselector: 'app-dialog-confirmation',\ntemplateUrl: './dialog-confirmation.component.html',\nstyleUrls: ['./dialog-confirmation.component.scss']\n})\nexport class DialogConfirmationComponent implements OnInit {\n\ntitle : string;\ndescription : string;\n\nconstructor(\npublic dialogRef: MatDialogRef<DialogConfirmationComponent>,\n@Inject(MAT_DIALOG_DATA) public data: any\n) { }\n\nngOnInit(): void {\nthis.title = this.data.title;\nthis.description = this.data.description;\n}\n\nonYes() {\nthis.dialogRef.close(true);\n}\n\nonNo() {\nthis.dialogRef.close(false);\n}\n}\n
Recuerda
Recuerda que los componentes utilizados en el di\u00e1logo de confirmaci\u00f3n se deben a\u00f1adir al m\u00f3dulo padre al que pertenecen, en este caso a core.module.ts
imports: [\n CommonModule,\n RouterModule,\n MatIconModule, \n MatToolbarModule,\n MatDialogModule,\n MatButtonModule,\n],\nproviders: [\n {\n provide: MAT_DIALOG_DATA,\n useValue: {},\n },\n],\n
Ya por \u00faltimo, una vez tenemos el componente gen\u00e9rico de dialogo, vamos a utilizarlo en nuestro listado al pulsar el bot\u00f3n eliminar:
category-list.component.htmlcategory-list.component.ts ...\n <ng-container matColumnDef=\"action\">\n <mat-header-cell *matHeaderCellDef></mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n <button mat-icon-button color=\"primary\" (click)=\"editCategory(element)\">\n <mat-icon>edit</mat-icon>\n </button>\n <button mat-icon-button color=\"accent\" (click)=\"deleteCategory(element)\">\n<mat-icon>clear</mat-icon>\n</button>\n </mat-cell>\n </ng-container>\n ...\n
...\ndeleteCategory(category: Category) { const dialogRef = this.dialog.open(DialogConfirmationComponent, {\ndata: { title: \"Eliminar categor\u00eda\", description: \"Atenci\u00f3n si borra la categor\u00eda se perder\u00e1n sus datos.<br> \u00bfDesea eliminar la categor\u00eda?\" }\n});\ndialogRef.afterClosed().subscribe(result => {\nif (result) {\nthis.categoryService.deleteCategory(category.id).subscribe(result => {\nthis.ngOnInit();\n}); }\n});\n} ...
Aqu\u00ed tambi\u00e9n hemos realizado la llamada a categoryService
, aunque no se realice ninguna acci\u00f3n, pero as\u00ed lo dejamos listo para enlazarlo.
Llegados a este punto, ya solo nos queda enlazar las acciones de la pantalla con las operaciones de negocio del backend.
"},{"location":"develop/basic/angular/#conectar-con-backend","title":"Conectar con Backend","text":"Antes de seguir
Antes de seguir con este punto, debes implementar el c\u00f3digo de backend en la tecnolog\u00eda que quieras (Springboot o Nodejs). Si has empezado este cap\u00edtulo implementando el frontend, por favor accede a la secci\u00f3n correspondiente de backend para poder continuar con el tutorial. Una vez tengas implementadas todas las operaciones para este listado, puedes volver a este punto y continuar con Angular.
El siguiente paso, como es obvio ser\u00e1 hacer que Angular llame directamente al servidor backend para leer y escribir datos y eliminar los datos mockeados en Angular.
Manos a la obra!
"},{"location":"develop/basic/angular/#llamada-del-listado","title":"Llamada del listado","text":"La idea es que el m\u00e9todo getCategories()
de category.service.ts
en lugar de devolver datos est\u00e1ticos, realice una llamada al servidor a la ruta http://localhost:8080/category
.
Abrimos el fichero y susituimos la l\u00ednea que antes devolv\u00eda los datos est\u00e1ticos por esto:
category.service.tsimport { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService { constructor(\nprivate http: HttpClient\n) { }\n\ngetCategories(): Observable<Category[]> {\nreturn this.http.get<Category[]>('http://localhost:8080/category');\n}\n\nsaveCategory(category: Category): Observable<Category> {\nreturn of(null);\n}\n\ndeleteCategory(idCategory : number): Observable<any> {\nreturn of(null);\n} }\n
Como hemos a\u00f1adido un componente nuevo HttpClient
tenemos que a\u00f1adir la dependencia al m\u00f3dulo padre.
import { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { MatTableModule } from '@angular/material/table';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatButtonModule } from '@angular/material/button';\nimport { CategoryListComponent } from './category-list/category-list.component';\nimport { CategoryEditComponent } from './category-edit/category-edit.component';\nimport { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';\nimport { MatFormFieldModule } from '@angular/material/form-field';\nimport { MatInputModule } from '@angular/material/input';\nimport { FormsModule, ReactiveFormsModule } from '@angular/forms';\nimport { HttpClientModule } from '@angular/common/http';\n@NgModule({\ndeclarations: [CategoryListComponent, CategoryEditComponent],\nimports: [\nCommonModule,\nMatTableModule,\nMatIconModule, MatButtonModule,\nMatDialogModule,\nMatFormFieldModule,\nMatInputModule,\nFormsModule,\nReactiveFormsModule,\nHttpClientModule,\n],\nproviders: [\n{\nprovide: MAT_DIALOG_DATA,\nuseValue: {},\n},\n]\n})\nexport class CategoryModule { }\n
Si ahora refrescas el navegador (recuerda tener arrancado tambi\u00e9n el servidor) y accedes a la pantalla de Categor\u00edas
deber\u00eda aparecer el listado con los datos que vienen del servidor.
Para la llamada de guardado har\u00edamos lo mismo, pero invocando la operaci\u00f3n de negocio put
.
import { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService { constructor(\nprivate http: HttpClient\n) { }\n\ngetCategories(): Observable<Category[]> {\nreturn this.http.get<Category[]>('http://localhost:8080/category');\n}\n\nsaveCategory(category: Category): Observable<Category> {\n\nlet url = 'http://localhost:8080/category';\nif (category.id != null) url += '/'+category.id;\nreturn this.http.put<Category>(url, category);\n}\n\ndeleteCategory(idCategory : number): Observable<any> {\nreturn of(null);\n} }
Ahora podemos probar a modificar o a\u00f1adir una nueva categor\u00eda desde la pantalla y deber\u00eda aparecer los nuevos datos en el listado.
"},{"location":"develop/basic/angular/#llamada-de-borrado","title":"Llamada de borrado","text":"Y ya por \u00faltimo, la llamada de borrado, deber\u00edamos cambiarla e invocar a la operaci\u00f3n de negocio delete
.
import { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Category } from './model/Category';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class CategoryService { constructor(\nprivate http: HttpClient\n) { }\n\ngetCategories(): Observable<Category[]> {\nreturn this.http.get<Category[]>('http://localhost:8080/category');\n}\n\nsaveCategory(category: Category): Observable<Category> {\n\nlet url = 'http://localhost:8080/category';\nif (category.id != null) url += '/'+category.id;\n\nreturn this.http.put<Category>(url, category);\n}\n\ndeleteCategory(idCategory : number): Observable<any> {\nreturn this.http.delete('http://localhost:8080/category/'+idCategory);\n} }
Ahora podemos probar a modificar o a\u00f1adir una nueva categor\u00eda desde la pantalla y deber\u00eda aparecer los nuevos datos en el listado.
Como ves, es bastante sencillo conectar server y client.
"},{"location":"develop/basic/angular/#depuracion","title":"Depuraci\u00f3n","text":"Una parte muy importante del desarrollo es tener la capacidad de depurar nuestro c\u00f3digo, en este apartado vamos a explicar como se realiza debug
en Front.
Esta parte se puede realizar con nuestro navegador favorito, en este caso vamos a utilizar Chrome.
El primer paso es abrir las herramientas del desarrollador del navegador presionando F12
.
En esta herramienta tenemos varias partes importantes:
Identificados los elementos importantes, vamos a depurar la operaci\u00f3n de crear categor\u00eda.
Para ello nos dirigimos a la pesta\u00f1a de Source
, en el \u00e1rbol de carpetas nos dirigimos a la ruta donde est\u00e1 localizado el c\u00f3digo de nuestra aplicaci\u00f3n webpack://src/app
.
Dentro de esta carpeta est\u00e9 localizado todo el c\u00f3digo fuente de la aplicaci\u00f3n, en nuestro caso vamos a localizar componente category-edit.component
que crea una nueva categor\u00eda.
Dentro del fichero ya podemos a\u00f1adir puntos de ruptura (breakpoint), en nuestro caso queremos comprobar que el nombre introducido se captura bien y se env\u00eda al service correctamente.
Colocamos el breakpoint en la l\u00ednea de invocaci\u00f3n del service (click sobre el n\u00famero de la l\u00ednea) y desde la interfaz creamos una nueva categor\u00eda.
Hecho esto, podemos observar que a nivel de interfaz, la aplicaci\u00f3n se detiene y aparece un panel de manejo de los puntos de interrupci\u00f3n:
En cuanto a la herramienta del desarrollador nos lleva al punto exacto donde hemos a\u00f1adido el breakpoint y se para en este punto ofreci\u00e9ndonos la posibilidad de explorar el contenido de las variables del c\u00f3digo:
Aqu\u00ed podemos comprobar que efectivamente la variable category
tiene el valor que hemos introducido por pantalla y se propaga correctamente hacia el service.
Para continuar con la ejecuci\u00f3n basta con darle al bot\u00f3n de play
del panel de manejo de interrupci\u00f3n o al que aparece dentro de la herramienta de desarrollo (parte superior derecha).
Por \u00faltimo, vamos a revisar que la petici\u00f3n REST se ha realizado correctamente al backend, para ello nos dirigimos a la pesta\u00f1a Network
y comprobamos las peticiones realizadas:
Aqu\u00ed podemos observar el registro de todas las peticiones y haciendo click sobre una de ellas, obtenemos el detalle de esta.
Ahora que ya tenemos listo el proyecto backend de nodejs (en el puerto 8080) ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/nodejs/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 en Node tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. Tanto en la web de node como de express encontrar\u00e1s informaci\u00f3n detallada del proceso que vamos a seguir.
"},{"location":"develop/basic/nodejs/#estructurar-el-codigo","title":"Estructurar el c\u00f3digo","text":"La estructura de nuestro proyecto ser\u00e1 la siguiente:
Vamos a aplicar una separaci\u00f3n por capas. En primer lugar, tendremos una capa de rutas para reenviar las solicitudes admitidas y cualquier informaci\u00f3n codificada en las urls de solicitud a la siguiente capa de controladores. La capa de control procesar\u00e1 las peticiones de las rutas y se comunicar\u00e1 con la capa de servicios devolviendo la respuesta de esta mediante respuestas http. En la capa de servicio se ejecutar\u00e1 toda la l\u00f3gica de la petici\u00f3n y se comunicar\u00e1 con los modelos de base de datos
En nuestro caso una ruta es una secci\u00f3n de c\u00f3digo Express que asocia un verbo HTTP (GET, POST, PUT, DELETE, etc.), una ruta/patr\u00f3n de URL y una funci\u00f3n que se llama para manejar ese patr\u00f3n.
\u00a1Ahora s\u00ed, vamos a programar!
"},{"location":"develop/basic/nodejs/#capa-de-routes","title":"Capa de Routes","text":"Lo primero de vamos a crear es la carpeta principal de nuestra aplicaci\u00f3n donde estar\u00e1n contenidos los distintos elementos de la misma. Para ello creamos una carpeta llamada src
en la ra\u00edz de nuestra aplicaci\u00f3n.
El primero elemento que vamos a crear va a ser el fichero de rutas para la categor\u00eda. Para ello creamos una carpeta llamada routes
en la carpeta src
y dentro de esta carpeta crearemos un archivo llamado category.routes.js
:
import { Router } from 'express';\nimport { createCategory } from '../controllers/category.controller.js';\n\nconst categoryRouter = Router();\ncategoryRouter.put('/', createCategory);\n\nexport default categoryRouter;\n
En este archivo estamos creando una ruta de tipo PUT que llamara al m\u00e9todo createCategory
de nuestro futuro controlador de categor\u00edas (aunque todav\u00eda no lo hemos creado y por tanto fallar\u00e1).
Ahora en nuestro archivo index.js
vamos a a\u00f1adir lo siguiente justo despu\u00e9s de declarar la constante app:
...\nimport categoryRouter from './src/routes/category.routes.js';\n...\n\n...\napp.use(cors({\norigin: '*'\n}));\n\napp.use(express.json());\napp.use('/category', categoryRouter);\n\n...\n
De este modo estamos asociando la url http://localhost:8080/category
a nuestro router. Tambi\u00e9n usaremos express.json()
para parsear las peticiones entrantes a formato json.
Lo siguiente ser\u00e1 crear el m\u00e9todo createCategory en nuestro controller. Para ello lo primero ser\u00e1 crear una carpeta controllers
en la carpeta src
de nuestro proyecto y dentro de esta un archivo llamado category.controller.js
:
export const createCategory = async (req, res) => {\nconsole.log(req.body);\nres.status(200).json(1);\n}\n
Hemos creado la funci\u00f3n createCategory
que recibir\u00e1 una request y una response. Estos par\u00e1metros vienen de la ruta de express y son la request y response de la petici\u00f3n HTTP. De momento simplemente vamos a hacer un console.log
de req.body
para ver el body de la petici\u00f3n y vamos a hacer una response 200 para indicar que todo ha ido correctamente.
Si arrancamos el servidor y hacemos una petici\u00f3n PUT
con Postman a http://localhost:8080/category
con un body que pongamos formado correctamente podremos ver la salida que hemos programado en nuestro controller y en la consola de node podemos ver el contenido de req.body
.
Ahora para que los datos que pasemos en el body los podamos guardar en BBDD necesitaremos un modelo y un esquema para la entidad Category
. Vamos a crear una carpeta llamada schemas
en la carpeta src
de nuestro proyecto. Un schema no es m\u00e1s que un modelo de BBDD que especifica que campos estar\u00e1n presentes y cu\u00e1les ser\u00e1n sus tipos. Dentro de la carpeta de schemas creamos un archivo con el nombre category.schema.js
:
import mongoose from \"mongoose\";\nconst { Schema, model } = mongoose;\nimport normalize from 'normalize-mongoose';\n\nconst categorySchema = new Schema({\nname: {\ntype: String,\nrequire: true\n}\n});\ncategorySchema.plugin(normalize);\nconst CategoryModel = model('Category', categorySchema);\n\nexport default CategoryModel;\n
En este archivo estamos definiendo nuestro schema indicando sus propiedades y tipos, en nuestro caso \u00fanicamente name
. Adem\u00e1s del tipo tambi\u00e9n indicaremos que el campo es obligatorio con la validation require para indicar que ese campo es obligatorio. Si quieres conocer otras validaciones aqu\u00ed tienes m\u00e1s info. Aparte de definir nuestro schema tambi\u00e9n lo estamos transformado en un modelo para poder trabajar con \u00e9l. En el constructor de model le pasamos el nombre del modelo y el schema que vamos a utilizar.
Como hemos visto en nuestra estructura la capa controller no puede comunicarse con la capa modelo, debe de haber una capa intermedia, para ello vamos a crear una carpeta services
en la carpeta src
de nuestro proyecto y dentro un archivo category.service.js
:
import CategoryModel from '../schemas/category.schema.js';\n\nexport const createCategory = async function(name) {\ntry {\nconst category = new CategoryModel({ name });\nreturn await category.save();\n} catch (e) {\nthrow Error('Error creating category');\n}\n}\n
Hemos importado el modelo de categor\u00eda para poder realizar acciones sobre la BBDD y hemos creado una funci\u00f3n que recoger\u00e1 el nombre de la categor\u00eda y crear\u00e1 una nueva categor\u00eda con \u00e9l. Llamamos al m\u00e9todo save para guardar nuestra categor\u00eda y devolvemos el resultado. Ahora en nuestro m\u00e9todo del controller solo tenemos que llamar al servicio pas\u00e1ndole los par\u00e1metros que nos llegan en la petici\u00f3n:
category.controller.jsimport * as CategoryService from '../services/category.service.js';\n\nexport const createCategory = async (req, res) => {\nconst { name } = req.body;\ntry {\nconst category = await CategoryService.createCategory(name);\nres.status(200).json({\ncategory\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Si todo ha ido correctamente llamaremos al m\u00e9todo de respuesta con el c\u00f3digo 200 y la categor\u00eda creada. En caso contrario mandaremos un c\u00f3digo de error. Si ahora de nuevo vamos a postman y volvemos a lanzar nuestra petici\u00f3n podemos ver como nos devuelve una nueva categor\u00eda:
"},{"location":"develop/basic/nodejs/#resto-de-operaciones","title":"Resto de Operaciones","text":""},{"location":"develop/basic/nodejs/#recuperacion-categorias","title":"Recuperaci\u00f3n categor\u00edas","text":"Ahora que ya podemos crear categor\u00edas lo siguiente ser\u00e1 crear un endpoint para recuperar las categor\u00edas creadas en nuestra base de datos. Podemos empezar a\u00f1adiendo un nuevo m\u00e9todo en nuestro servicio:
category.service.jsexport const getCategories = async function () {\ntry {\nreturn await CategoryModel.find().sort('name');\n} catch (e) {\nthrow Error('Error fetching categories');\n}\n}\n
Al igual que en el anterior m\u00e9todo haremos uso del modelo, pero esta vez para hacer un find
y ordenando los resultados por el campo name
. Al m\u00e9todo find se le pueden pasar queries
, projections
y options
. Te dejo por aqu\u00ed m\u00e1s info. En nuestro caso simplemente queremos que nos devuelva todas las categor\u00edas por lo que no le pasaremos nada.
Creamos tambi\u00e9n un m\u00e9todo en el controlador para recuperar las categor\u00edas y que har\u00e1 uso del servicio:
category.controller.jsexport const getCategories = async (req, res) => {\ntry {\nconst categories = await CategoryService.getCategories();\nres.status(200).json(\ncategories\n);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Y ahora que ya tenemos el m\u00e9todo creado en el controlador lo siguiente ser\u00e1 relacionar este m\u00e9todo con una ruta. Para ello en nuestro archivo category.routes.js
tendremos que a\u00f1adir una nueva l\u00ednea:
import { Router } from 'express';\nimport { createCategory, getCategories } from '../controllers/category.controller.js';\n\nconst categoryRouter = Router();\ncategoryRouter.put('/', createCategory);\ncategoryRouter.get('/', getCategories);\n\nexport default categoryRouter;\n
De este modo cuando hagamos una petici\u00f3n GET a http://localhost:8080/category
nos devolver\u00e1 el listado de categor\u00edas existentes:
Ahora vamos a por el m\u00e9todo para actualizar nuestras categor\u00edas. En el servicio creamos el siguiente m\u00e9todo:
category.service.jsexport const updateCategory = async (id, name) => {\ntry {\nconst category = await CategoryModel.findById(id);\nif (!category) {\nthrow Error('There is no category with that Id');\n} return await CategoryModel.findByIdAndUpdate(id, {name});\n} catch (e) {\nthrow Error(e);\n}\n}\n
A este m\u00e9todo le pasaremos de entrada el id
y el nombre
. Con ese id
realizaremos una b\u00fasqueda para asegurarnos que esa categor\u00eda existe en nuestra base de datos. Si existe la categor\u00eda haremos una petici\u00f3n con findByIdAndUpdate
donde el primer par\u00e1metro es el id
y el segundo es el resto de los campos de nuestra entidad.
En el controlador creamos el m\u00e9todo correspondiente:
category.controller.jsexport const updateCategory = async (req, res) => {\nconst categoryId = req.params.id;\nconst { name } = req.body;\ntry {\nawait CategoryService.updateCategory(categoryId, name);\nres.status(200).json(1);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Aqu\u00ed recogeremos el par\u00e1metro id
que nos vendr\u00e1 en la url, por ejemplo: http://localhost:8080/category/1
. Esto lo hacemos con req.params.id
. El id
es el nombre de la variable que le daremos en el router como veremos m\u00e1s adelante. Y una vez creado el m\u00e9todo en el controlador tendremos que a\u00f1adir la ruta en nuestro fichero de rutas correspondiente, pero como ya hemos dicho tendremos que indicar que nuestra ruta espera un par\u00e1metro id, lo haremos de la siguiente forma:
import { Router } from 'express';\nimport { createCategory, getCategories, updateCategory } from '../controllers/category.controller.js';\n\nconst categoryRouter = Router();\ncategoryRouter.put('/', createCategory);\ncategoryRouter.get('/', getCategories);\ncategoryRouter.put('/:id', updateCategory);\n\nexport default categoryRouter;\n
Y volvemos a probar en Postman:
Y si hacemos de nuevo un GET
vemos como la categor\u00eda se ha modificado correctamente:
Ya solo nos faltar\u00eda la operaci\u00f3n de delete
para completar nuestro CRUD, en el servicio a\u00f1adimos un nuevo m\u00e9todo:
export const deleteCategory = async (id) => {\ntry {\nconst category = await CategoryModel.findById(id);\nif (!category) {\nthrow Error('There is no category with that Id');\n}\nreturn await CategoryModel.findByIdAndDelete(id);\n} catch (e) {\nthrow Error('Error deleting category');\n}\n}\n
Como vemos es muy parecido al update, recuperamos el id
de los par\u00e1metros de la ruta y en este caso llamaremos al m\u00e9todo findByIdAndDelete
. En nuestro controlador creamos el m\u00e9todo correspondiente:
export const deleteCategory = async (req, res) => {\nconst categoryId = req.params.id;\ntry {\nconst deletedCategory = await CategoryService.deleteCategory(categoryId);\nres.status(200).json({\ncategory: deletedCategory\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Y de nuevo a\u00f1adimos la ruta correspondiente al archivo de rutas:
category.routes.jsimport { Router } from 'express';\nimport { createCategory, getCategories, updateCategory, deleteCategory } from '../controllers/category.controller.js';\n\nconst categoryRouter = Router();\ncategoryRouter.put('/', createCategory);\ncategoryRouter.get('/', getCategories);\ncategoryRouter.put('/:id', updateCategory);\ncategoryRouter.delete('/:id', deleteCategory);\n\nexport default categoryRouter;\n
Y de nuevo, probamos en postman:
Hacemos un get para comprobar que se ha borrado de nuestra base de datos:
"},{"location":"develop/basic/nodejs/#capa-de-middleware-validaciones","title":"Capa de Middleware (Validaciones)","text":"Antes de pasar a nuestro siguiente CRUD vamos a ver en que consiste la Capa de Middleware
. Un middleware
es un c\u00f3digo que se ejecuta antes de que una petici\u00f3n http llegue a nuestro manejador de rutas o antes de que el cliente reciba una respuesta.
En nuestro caso vamos a crear un middleware para asegurarnos que todos los campos que necesitamos en nuestras entidades vienen en el body de la petici\u00f3n. Vamos a crear una carpeta middlewares
en la carpeta src
de nuestro proyecto y dentro crearemos el fichero validateFields.js
:
import { response } from 'express';\nimport { validationResult } from 'express-validator';\n\nconst validateFields = (req, res = response, next) => {\nconst errors = validationResult(req);\nif (!errors.isEmpty()) {\nreturn res.status(400).json({\nerrors: errors.mapped()\n});\n}\nnext();\n}\n\nexport default validateFields;\n
En este m\u00e9todo nos ayudaremos de la librer\u00eda express-validator
para ver los errores que tenemos en nuestras rutas. Para ello llamaremos a la funci\u00f3n validationResult
que nos devolver\u00e1 un array de errores que m\u00e1s tarde definiremos. Si el array no va vac\u00edo es porque se ha producido alg\u00fan error en las validaciones y ejecutara la response con un c\u00f3digo de error.
Ahora definiremos las validaciones en nuestro archivo de rutas, deber\u00eda quedar de la siguiente manera:
category.routes.jsimport { Router } from 'express';\nimport { check } from 'express-validator';\nimport validateFields from '../middlewares/validateFields.js';\nimport { getCategories, createCategory, deleteCategory, updateCategory } from '../controllers/category.controller.js';\nconst categoryRouter = Router();\n\ncategoryRouter.put('/:id', [\ncheck('name').not().isEmpty(),\nvalidateFields\n], updateCategory);\n\ncategoryRouter.put('/', [\ncheck('name').not().isEmpty(),\nvalidateFields\n], createCategory);\n\ncategoryRouter.get('/', getCategories);\ncategoryRouter.delete('/:id', deleteCategory);\n\nexport default categoryRouter;\n
Aqu\u00ed nos ayudamos de nuevo de express-validator
y de su m\u00e9todo check
. Para las rutas en las que necesitemos validaciones, a\u00f1adimos un array como segundo par\u00e1metro. En este array vamos a\u00f1adiendo todas las validaciones que necesitemos. En nuestro caso solo queremos que el campo name no sea vac\u00edo, pero existen muchas m\u00e1s validaciones que puedes encontrar en la documentaci\u00f3n de express-validator. Importamos nuestro middleware y lo a\u00f1adimos en la \u00faltima posici\u00f3n de este array.
De este modo no se realizar\u00e1n las peticiones que no pasen las validaciones:
Y con esto habremos terminado nuestro primer CRUD.
"},{"location":"develop/basic/nodejs/#depuracion","title":"Depuraci\u00f3n","text":"Una parte muy importante del desarrollo es tener la capacidad de depurar nuestro c\u00f3digo, en este apartado vamos a explicar como se realiza debug
en Backend.
Esta parte se realiza con las herramientas incluidas dentro de nuestro IDE favorito, en este caso vamos a utilizar el Visual Estudio.
Lo primero que debemos hacer es configurar el modo Debug
de nuestro proyecto.
Para ello nos dirigimos a la opci\u00f3n Run and Debug
y creamos el fichero de launch necesario:
Esto nos crear\u00e1 el fichero necesario y ya podremos arrancar la aplicaci\u00f3n mediante esta herramienta presionando el bot\u00f3n Launch Program
(seleccionamos tipo de aplicaci\u00f3n Node y el script de arranque que ser\u00e1 el que hemos utilizado en el desarrollo):
Arrancada la aplicaci\u00f3n de este modo, vamos a depurar la operaci\u00f3n de crear categor\u00eda.
Para ello vamos a abrir nuestro fichero donde tenemos la implementaci\u00f3n del servicio de creaci\u00f3n de la capa de la l\u00f3gica de negocio category.service.js
.
Dentro del fichero ya podemos a\u00f1adir puntos de ruptura (breakpoint), en nuestro caso queremos comprobar que el nombre introducido se recibe correctamente.
Colocamos el breakpoint en la primera l\u00ednea del m\u00e9todo (click sobre el n\u00famero de la l\u00ednea) y desde la interfaz/postman creamos una nueva categor\u00eda.
Hecho esto, podemos observar que a nivel de interfaz/postman, la petici\u00f3n se queda esperando y el IDE mostrar\u00e1 un panel de manejo de los puntos de interrupci\u00f3n:
El IDE nos lleva al punto exacto donde hemos a\u00f1adido el breakpoint y se para en este punto ofreci\u00e9ndonos la posibilidad de explorar el contenido de las variables del c\u00f3digo:
Aqu\u00ed podemos comprobar que efectivamente la variable name
tiene el valor que hemos introducido por pantalla/postman.
Para continuar con la ejecuci\u00f3n basta con darle al bot\u00f3n de play
del panel de manejo de los puntos de interrupci\u00f3n.
Ahora que ya tenemos listo el proyecto frontend de React (en el puerto 5173), ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/react/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 que React tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. Tanto en la propia web de documentaci\u00f3n de React como en la web de componentes Mui puedes buscar casi cualquier ejemplo que necesites.
Si abrimos el proyecto con el IDE que tengamos (Visual Studio Code en el caso del tutorial) podemos ver que en la carpeta src
existen unos ficheros ya creados por defecto. Estos ficheros son:
main.tsx
\u2192 contiene el componente principal del proyecto.index.css
\u2192 contiene los estilos CSS globales de la aplicaci\u00f3n.APP.tsx
\u2192 contiene el componente inicial del proyecto APP.css
\u2192 contiene los estilos para el componente APP.Aunque main.tsx
y App.tsx
puedan parecer lo mismo main.tsx
se suele dejar tal y como esta ya que lo \u00fanico que hace es asociar el div con id \u201croot\u201d del archivo index.html
de la ra\u00edz de nuestro proyecto para que sea el nodo principal de React. En el archivo App.tsx
es donde realmente empezamos a desarrollar c\u00f3digo.
Si abrimos main.tsx
podemos ver que se esta usando <App />
como una etiqueta html. El nombre con que exportemos nuestros componentes ser\u00e1 el nombre de la etiqueta html utilizado para renderizar los componentes.
Vamos a modificar este c\u00f3digo inicial para ver c\u00f3mo funciona. Abrimos el fichero App.tsx
y vamos a dejarlo de esta manera:
import { useState } from 'react'\nimport './App.css'\n\nfunction App() {\nconst [count, setCount] = useState(0)\nconst probando = \"probando 123\";\n\nreturn (\n<>\n<p>{probando}</p>\n<div className=\"card\">\n<button onClick={() => setCount((count) => count + 1)}>\ncount is {count}\n</button>\n</div>\n</>\n)\n}\n\nexport default App\n
En los componentes React siempre se suele seguir el mismo orden, primero introduciremos los imports necesarios, luego podemos declarar variables y funciones que no se vayan a modificar, despu\u00e9s creamos nuestra funci\u00f3n principal con el nombre del componente y dentro de esta lo primero que se suelen declarar son todas las variables, despu\u00e9s a\u00f1adiremos m\u00e9todos del componente y por \u00faltimo tenemos que llamar a return para devolver lo que queramos renderizar. Si ahora abrimos nuestro navegador veremos en pantalla el valor de la variable \"probando\" que hemos introducido mediante una expresi\u00f3n en un tag p de html y un bot\u00f3n que si pulsamos incrementar\u00e1 el valor de la cuenta en uno. Si refrescamos la pantalla el valor de la cuenta volver\u00e1 autom\u00e1ticamente a 0. Es hora de explicar como funciona un componente React y el hook useState.
"},{"location":"develop/basic/react/#jsx","title":"JSX","text":"JSX significa Javascript XML. JSX nos permite escribir elementos HTML en JavaScript y colocarlos en el DOM. Con JSX puedes escribir expresiones dentro de llaves \u201c{}\u201d. Estas expresiones pueden ser variables, propiedades o cualquier expresi\u00f3n Javascript valida. JSX ejecutar\u00e1 esta expresi\u00f3n y devolver\u00e1 el resultado.
Por ejemplo, si queremos mostrar un elemento de forma condicional lo podemos hacer de la siguiente manera:
{\nvariableBooleana && <p>El valor es true</p>\n}\n
Tambi\u00e9n podemos usar el operador ternario para condiciones:
{\nvariableBooleana ? <p>El valor es true</p> : <p>El valor es false</p>\n}\n
Y si lo que queremos es recorrer un array e ir representando los elementos lo podemos hacer de la siguiente manera:
{\narrayNumerico.map(numero => <p>Mi valor es {numero}</p>)\n}\n
React solo puede devolver un elemento en su bloque return, es por eso por lo que algunas veces se rodea todo el c\u00f3digo con un elemento llamado Fragment \u201c<>\u201d. Estos fragment no soportan ni propiedades ni atributos y no tendr\u00e1n visibilidad en el dom.
Dentro de una expresi\u00f3n podemos ver dos formas de llamar a una funci\u00f3n:
<Button onClick={callToCancelar}>Cancelar</Button>\n<Button onClick={() => callToCancelar('param1')}>Cancelar</Button>\n
En la primera se pasa una funci\u00f3n por referencia y Button es el responsable de los par\u00e1metros del evento. En la segunda tras hacer onClick se ejecuta la funci\u00f3n callToCancelar con los par\u00e1metros que nosotros queramos quitando esa responsabilidad a Button. En t\u00e9rminos de rendimiento es mejor la primera manera ya que en la segunda se vuelve a crear la funci\u00f3n en cada renderizado, pero hay veces que es necesario hacerlo as\u00ed para tomar control de los par\u00e1metros.
"},{"location":"develop/basic/react/#usestate-hook","title":"useState hook","text":"Todo componente en React tiene una serie de variables. Algunas de estas son propiedades de entrada como podr\u00edan serlo disabled en un bot\u00f3n y que se trasmiten de componentes padres a hijos.
Luego tenemos variables y constantes declaradas dentro del componente como por ejemplo la constante probando de nuestro ejemplo. Y finalmente tenemos unas variables especiales dentro de nuestro componente que corresponden al estado de este.
Si modificamos el estado de un componente este autom\u00e1ticamente se volver\u00e1 a renderizar y producir\u00e1 una nueva representaci\u00f3n en pantalla.
Como ya hemos comentado previamente los hooks aparecieron en la versi\u00f3n 16.8 de React. Antes de esto si quer\u00edamos acceder al estado de un componente solo pod\u00edamos acceder a este mediante componentes de clase, pero desde esta versi\u00f3n podemos hacer uso de estas funciones especiales para utilizar estas caracter\u00edsticas de React.
M\u00e1s tarde veremos otras, pero de momento vamos a ver useState.
const [count, setCount] = useState(0)\n
En nuestro ejemplo tenemos una variable count que va mostrando su valor en el interior de un bot\u00f3n. Si pulsamos el bot\u00f3n ejecutara la funci\u00f3n setCount
que actualiza el valor de nuestro contador. A esta funci\u00f3n se le puede pasar o bien el nuevo valor que tomar\u00e1 esta variable de estado o bien una funci\u00f3n cuyo primer par\u00e1metro es el valor actual de la variable. Siempre que se actualice la variable del estado de producir\u00e1 un nuevo renderizado del componente, eso lo pod\u00e9is comprobar escribiendo un console.log
antes del return. En nuestro caso hemos inicializado nuestra variable de estado con el valor 0, pero puede inicializarse con un valor de cualquier tipo javascript. No existe limite en el n\u00famero de variables de estado por componente.
Debemos tener en cuenta que si modificamos el estado de un componente que renderiza otros componentes, estos tambi\u00e9n se volver\u00e1n a renderizar al cambiar el estado del componente padre. Es por esto por lo que debemos tener cuidado a la hora de modificar estados y renderizar los hijos correctamente.
Nota
Para evitar el re-renderizado de los componentes hijos existe una funci\u00f3n especial en React llamada memo
que evita este comportamiento si las props de los hijos no se ven modificadas. En este curso no cubriremos esta funcionalidad.
Nota
Por convenci\u00f3n todos los hooks empiezan con use
. Si en alg\u00fan proyecto tienes que crear un custom hook es importante seguir esta nomenclatura.
Antes de continuar con nuestro curso vamos a instalar las dependencias necesarias para empezar a construir la base de nuestra aplicaci\u00f3n. Para ello ejecutamos lo siguiente en la consola en la ra\u00edz de nuestro proyecto:
npm i @mui/material @mui/icons-material react-router-dom react-redux @reduxjs/toolkit @emotion/react @emotion/styled\n
Como librer\u00eda de componentes vamos a utilizar Mui, anteriormente conocido como Material ui, es una librer\u00eda muy utilizada en los proyectos de React con una gran documentaci\u00f3n. Tambi\u00e9n necesitaremos las librer\u00edas de emotion necesarias para trabajar con Mui.
Vamos a utilizar la librer\u00eda react router dom que nos permitir\u00e1 definir y usar rutas de navegaci\u00f3n en nuestra aplicaci\u00f3n.
Vamos a instalar tambi\u00e9n react redux y redux toolkit para gestionar el estado global de nuestra aplicaci\u00f3n.
"},{"location":"develop/basic/react/#layout-general","title":"Layout general","text":""},{"location":"develop/basic/react/#crear-componente","title":"Crear componente","text":"Lo primero que haremos ser\u00e1 borrar el contenido del archivo App.css
y vamos a modificar index.css
con el siguiente contenido:
:root {\nfont-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;\n}\n\nbody {\nmargin: 0;\n}\n\n@media (prefers-color-scheme: light) {\n:root {\ncolor: #213547;\nbackground-color: #ffffff;\n}\n}\n\n.container {\nmargin: 20px;\n}\n\n.newButton {\ndisplay: flex;\njustify-content: flex-end;\nmargin-top: 20px;\n}\n
Ahora vamos a crear los distintos componentes que compondr\u00e1n nuestra aplicaci\u00f3n. Para ello dentro de la carpeta src vamos a crear una nueva carpeta llamada pages y dentro de esta crearemos tres carpetas relativas a nuestras paginas navegables: \u201cAuthor\u201d, \u201cCategory\u201d y \u201cGame\u201d. Dentro de estas a su vez crearemos un fichero llamado Author.tsx
, Category.tsx
y Game.tsx
respectivamente, cuyo contenido ser\u00e1 una funci\u00f3n que tendr\u00e1 por nombre el mismo nombre que el fichero y que devolver\u00e1 un div cuyo contenido ser\u00e1 tambi\u00e9n el nombre del fichero:
export const Game = () => {\nreturn (\n<div>Game</div>\n)\n}\n
Ahora vamos a crear en la carpeta src otra carpeta cuyo nombre ser\u00e1 \u201ccomponents\u201d y dentro de esta un fichero llamado Layout.tsx
cuyo contenido ser\u00e1 el siguiente:
import { useState } from \"react\";\nimport AppBar from \"@mui/material/AppBar\";\nimport Box from \"@mui/material/Box\";\nimport Toolbar from \"@mui/material/Toolbar\";\nimport IconButton from \"@mui/material/IconButton\";\nimport Typography from \"@mui/material/Typography\";\nimport Menu from \"@mui/material/Menu\";\nimport MenuIcon from \"@mui/icons-material/Menu\";\nimport Container from \"@mui/material/Container\";\nimport Button from \"@mui/material/Button\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport CasinoIcon from \"@mui/icons-material/Casino\";\nimport { useNavigate, Outlet } from \"react-router-dom\";\n\nconst pages = [\n{ name: \"Catalogo\", link: \"games\" },\n{ name: \"Categor\u00edas\", link: \"categories\" },\n{ name: \"Autores\", link: \"authors\" },\n];\n\nexport const Layout = () => {\nconst navigate = useNavigate();\n\nconst [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(\nnull\n);\n\nconst handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {\nsetAnchorElNav(event.currentTarget);\n};\n\nconst handleCloseNavMenu = (link: string) => {\nnavigate(`/${link}`);\nsetAnchorElNav(null);\n};\n\nreturn (\n<>\n<AppBar position=\"static\">\n<Container maxWidth=\"xl\">\n<Toolbar disableGutters>\n<CasinoIcon sx={{ display: { xs: \"none\", md: \"flex\" }, mr: 1 }} />\n<Typography\nvariant=\"h6\"\nnoWrap\ncomponent=\"a\"\nhref=\"/\"\nsx={{\nmr: 2,\ndisplay: { xs: \"none\", md: \"flex\" },\nfontFamily: \"monospace\",\nfontWeight: 700,\nletterSpacing: \".3rem\",\ncolor: \"inherit\",\ntextDecoration: \"none\",\n}}\n>\nLudoteca Tan\n</Typography>\n\n<Box sx={{ flexGrow: 1, display: { xs: \"flex\", md: \"none\" } }}>\n<IconButton\nsize=\"large\"\naria-label=\"account of current user\"\naria-controls=\"menu-appbar\"\naria-haspopup=\"true\"\nonClick={handleOpenNavMenu}\ncolor=\"inherit\"\n>\n<MenuIcon />\n</IconButton>\n<Menu\nid=\"menu-appbar\"\nanchorEl={anchorElNav}\nanchorOrigin={{\nvertical: \"bottom\",\nhorizontal: \"left\",\n}}\nkeepMounted\ntransformOrigin={{\nvertical: \"top\",\nhorizontal: \"left\",\n}}\nopen={Boolean(anchorElNav)}\nonClose={handleCloseNavMenu}\nsx={{\ndisplay: { xs: \"block\", md: \"none\" },\n}}\n>\n{pages.map((page) => (\n<MenuItem\nkey={page.name}\nonClick={() => handleCloseNavMenu(page.link)}\n>\n<Typography textAlign=\"center\">\n{page.name}\n</Typography>\n</MenuItem>\n))}\n</Menu>\n</Box>\n<CasinoIcon sx={{ display: { xs: \"flex\", md: \"none\" }, mr: 1 }} />\n<Typography\nvariant=\"h5\"\nnoWrap\ncomponent=\"a\"\nhref=\"\"\nsx={{\nmr: 2,\ndisplay: { xs: \"flex\", md: \"none\" },\nflexGrow: 1,\nfontFamily: \"monospace\",\nfontWeight: 700,\nletterSpacing: \".3rem\",\ncolor: \"inherit\",\ntextDecoration: \"none\",\n}}\n>\nLudoteca Tan\n</Typography>\n<Box sx={{ flexGrow: 1, display: { xs: \"none\", md: \"flex\" } }}>\n{pages.map((page) => (\n<Button\nkey={page.name}\nonClick={() => handleCloseNavMenu(page.link)}\nsx={{ my: 2, color: \"white\", display: \"block\" }}\n>\n{page.name}\n</Button>\n))}\n</Box>\n</Toolbar>\n</Container>\n</AppBar>\n<Outlet />\n</>\n);\n};\n
Aunque puede parecer complejo por su tama\u00f1o en realidad no es tanto, casi todo es c\u00f3digo cogido directamente de un ejemplo de layout de navegaci\u00f3n de un componente de MUI.
Lo m\u00e1s destacable es un nuevo hook (en realidad es un custom hook de react router dom) llamado useNavigate
que como su propio nombre indica navegara a la ruta correspondiente seg\u00fan el valor pulsado.
Las etiquetas sx son para dar estilo a los componentes de MUI. Tambi\u00e9n se puede sobrescribir el estilo mediante hojas css pero es m\u00e1s complejo y requiere una configuraci\u00f3n inicial que no cubriremos en este tutorial.
Si nos fijamos en la l\u00ednea 90 se introduce una expresi\u00f3n javascript en la cual se recorre el array de pages declarado al inicio del componente y para cada uno de los valores se llama a MenuItem
que es otro componente React al que se le pasan las props key, onClick y aunque no lo veamos tambi\u00e9n la prop \u201cchildren\u201d.
La prop children estar\u00e1 presente cuando pasemos elementos entre los tags de un elemento:
<MenuItem > <Typography>I\u2019m a child</Typography>\n</MenuItem>\n
El uso de la prop children no es muy recomendado y se prefiere que se pasen los elementos como una prop m\u00e1s. Siempre que rendericemos un array en react es recomendable usar una prop especial llamada \u201ckey\u201d, de hecho, si no la usamos la consola de desarrollo se nos llenar\u00e1 de warnings por no usarla.
Esta key lo que permite a React es identificar cada elemento de formar m\u00e1s eficiente, as\u00ed si modificamos, a\u00f1adimos o eliminamos un elemento de un array no ser\u00e1 necesario volver a renderizar todo el array, solo se eliminar\u00e1 el elemento necesario.
En la parte final del archivo tenemos una llamada al elemento Outlet
. Este elemento es el que albergara el componente asociado a la ruta seleccionada.
Por \u00faltimo, el archivo App.tsx
se tiene que quedar de esta manera:
import { BrowserRouter, Routes, Route, Navigate } from \"react-router-dom\";\nimport { Game } from \"./pages/Game/Game\";\nimport { Author } from \"./pages/Author/Author\";\nimport { Category } from \"./pages/Category/Category\";\nimport { Layout } from \"./components/Layout\";\n\nfunction App() {\nreturn (\n<BrowserRouter>\n<Routes>\n<Route element={<Layout />}>\n<Route index path=\"games\" element={<Game />} />\n<Route path=\"categories\" element={<Category />} />\n<Route path=\"authors\" element={<Author />} />\n<Route path=\"*\" element={<Navigate to=\"/games\" />} />\n</Route>\n</Routes>\n</BrowserRouter>\n);\n}\n\nexport default App;\n
De esta manera definimos cada una de nuestras rutas y las asociamos a una p\u00e1gina. Vamos a arrancar el proyecto de nuevo con npm run dev y navegamos a http://localhost:5173/.
Ahora podemos ver como autom\u00e1ticamente nos lleva a http://localhost:5173/games debido al \u00faltimo route en el que redirigimos cualquier path que no coincida con los anteriores a /games
. Si pulsamos sobre las distintas opciones del men\u00fa podemos ver c\u00f3mo va cambiando el outlet de nuestra aplicaci\u00f3n con los distintos div creados para cada uno de los componentes p\u00e1gina.
Ya tenemos la estructura principal, ahora vamos a crear nuestra primera pantalla. Vamos a empezar por la de categor\u00edas.
Lo primero que vamos a hacer es crear una carpeta llamada types
dentro de src/
. Aqu\u00ed crearemos los tipos de typescript. Creamos un nuevo fichero llamado Category.ts
cuyo contenido ser\u00e1 el siguiente:
export interface Category {\nid: string;\nname: string;\n}\n
Ahora vamos a crear un archivo de estilos que ser\u00e1 solo utilizado por el componente Category. Para ello dentro de la carpeta src/pages/Category
vamos a crear un archivo llamado Category.module.css
. Al llamar al archivo de esta manera React reconoce este archivo como un archivo \u00fanico para un componente y hace que sus reglas css sean m\u00e1s prioritarias, aunque por ejemplo exista una clase con el mismo nombre en el archivo index.css
.
El contenido de nuestro archivo css ser\u00e1 el siguiente:
.tableActions {\nmargin-right: 20px;\ndisplay: flex;\njustify-content: flex-end;\nalign-content: flex-start;\ngap: 19px;\n}\n
Y por \u00faltimo el contenido de nuestro fichero src/pages/Category.tsx
quedar\u00eda as\u00ed:
import { useState } from \"react\";\nimport Table from \"@mui/material/Table\";\nimport TableBody from \"@mui/material/TableBody\";\nimport TableCell from \"@mui/material/TableCell\";\nimport TableContainer from \"@mui/material/TableContainer\";\nimport TableHead from \"@mui/material/TableHead\";\nimport TableRow from \"@mui/material/TableRow\";\nimport Paper from \"@mui/material/Paper\";\nimport Button from \"@mui/material/Button\";\nimport EditIcon from \"@mui/icons-material/Edit\";\nimport ClearIcon from \"@mui/icons-material/Clear\";\nimport IconButton from \"@mui/material/IconButton\";\nimport styles from \"./Category.module.css\";\nimport { Category as CategoryModel } from \"../../types/Category\";\n\nexport const Category = () => {\nconst data = [\n{\nid: \"1\",\nname: \"Test 1\",\n},\n{\nid: \"2\",\nname: \"Test 2\",\n},\n];\n\nreturn (\n<div className=\"container\">\n<h1>Listado de Categor\u00edas</h1>\n<TableContainer component={Paper}>\n<Table sx={{ minWidth: 650 }} aria-label=\"simple table\">\n<TableHead\nsx={{\n\"& th\": {\nbackgroundColor: \"lightgrey\",\n},\n}}\n>\n<TableRow>\n<TableCell>Identificador</TableCell>\n<TableCell>Nombre categor\u00eda</TableCell>\n<TableCell></TableCell>\n</TableRow>\n</TableHead>\n<TableBody>\n{data.map((category: CategoryModel) => (\n<TableRow\nkey={category.id}\nsx={{ \"&:last-child td, &:last-child th\": { border: 0 } }}\n>\n<TableCell component=\"th\" scope=\"row\">\n{category.id}\n</TableCell>\n<TableCell component=\"th\" scope=\"row\">\n{category.name}\n</TableCell>\n<TableCell>\n<div className={styles.tableActions}>\n<IconButton aria-label=\"update\" color=\"primary\">\n<EditIcon />\n</IconButton>\n<IconButton aria-label=\"delete\" color=\"error\">\n<ClearIcon />\n</IconButton>\n</div>\n</TableCell>\n</TableRow>\n))}\n</TableBody>\n</Table>\n</TableContainer>\n<div className=\"newButton\">\n<Button variant=\"contained\">Nueva categor\u00eda</Button>\n</div>\n</div>\n);\n};\n
De momento vamos a usar un listado mockeado para mostrar nuestras categorias. El c\u00f3digo JSX esta sacado pr\u00e1cticamente en su totalidad del ejemplo de una tabla de Mui y solo hemos modificado el nombre del array que tenemos que recorrer, sus atributos y hemos a\u00f1adido unos botones de acci\u00f3n para editar y borrar autores que de momento no hacen nada. Si abrimos un navegador (con el servidor arrancado npm run dev) y vamos a http://localhost:5173/categories podremos ver nuestro listado con los datos mockeados.
Ahora vamos a crear un componente que se mostrar\u00e1 cuando pulsemos el bot\u00f3n de nueva categor\u00eda. En la carpeta src/pages/category
vamos a crear una nueva carpeta llamada components
y dentro de esta crearemos un nuevo fichero llamado CreateCategory.tsx
que tendr\u00e1 el siguiente contenido:
import { useState } from \"react\";\nimport Button from \"@mui/material/Button\";\nimport TextField from \"@mui/material/TextField\";\nimport Dialog from \"@mui/material/Dialog\";\nimport DialogActions from \"@mui/material/DialogActions\";\nimport DialogContent from \"@mui/material/DialogContent\";\nimport DialogTitle from \"@mui/material/DialogTitle\";\nimport { Category } from \"../../../types/Category\";\n\ninterface Props {\ncategory: Category | null;\ncloseModal: () => void;\ncreate: (name: string) => void;\n}\n\nexport default function CreateCategory(props: Props) {\nconst [name, setName] = useState(props?.category?.name || \"\");\n\nreturn (\n<div>\n<Dialog open={true} onClose={props.closeModal}>\n<DialogTitle>\n{props.category ? \"Actualizar Categor\u00eda\" : \"Crear Categor\u00eda\"}\n</DialogTitle>\n<DialogContent>\n{props.category && (\n<TextField\nmargin=\"dense\"\ndisabled\nid=\"id\"\nlabel=\"Id\"\nfullWidth\nvalue={props.category.id}\nvariant=\"standard\"\n/>\n)}\n<TextField\nmargin=\"dense\"\nid=\"name\"\nlabel=\"Nombre\"\nfullWidth\nvariant=\"standard\"\nonChange={(event) => setName(event.target.value)}\nvalue={name}\n/>\n</DialogContent>\n<DialogActions>\n<Button onClick={props.closeModal}>Cancelar</Button>\n<Button onClick={() => props.create(name)} disabled={!name}>\n{props.category ? \"Actualizar\" : \"Crear\"}\n</Button>\n</DialogActions>\n</Dialog>\n</div>\n);\n}\n
Para este componente hemos definido una categor\u00eda como par\u00e1metro de entrada para poder reutilizar el componente en el caso de una edici\u00f3n y poder pasar la categor\u00eda a editar, en nuestro caso inicial al ser un alta esta categor\u00eda tendr\u00e1 el valor null
. Tambi\u00e9n hemos definido dos funciones en los par\u00e1metros de entrada para controlar o bien el cerrado del modal o bien la creaci\u00f3n de un autor.
Esta es la forma directa que tienen de comunicaci\u00f3n los componentes padre/hijo en React, el padre puede pasar datos de lectura o funciones a sus componentes hijos a las que estos llamaran para comunicarse con \u00e9l.
As\u00ed en nuestro ejemplo el componente CreateCategory
llamar\u00e1 a la funci\u00f3n create
a la que pasar\u00e1 un nuevo objecto Category
y ser\u00e1 el padre (nuestra p\u00e1gina Category
) el que decidir\u00e1 qu\u00e9 hacer con esos datos al igual que ocurre con los eventos en los tags de html.
En el estado de nuestro componente solo vamos a almacenar los datos introducidos en el formulario, en el caso de una edici\u00f3n el valor inicial del nombre de la categor\u00eda ser\u00e1 el que venga de entrada.
Adem\u00e1s introducido unas expresiones que modificar\u00e1n la visualizaci\u00f3n del componente (titulo, id, texto de los botones, \u2026) dependiendo de si tenemos un autor de entrada o no.
Ahora tenemos que a\u00f1adir nuestro nuevo componente en nuestra p\u00e1gina Category:
Importamos el componente:
import CreateCategory from \"./components/CreateCategory\";\n
Creamos las funciones que le pasaremos al componente, dej\u00e1ndolas de momento vac\u00edas:
const createCategory = () => {\n\n}\n\nconst handleCloseCreate = () => {\n\n}\n
Y a\u00f1adimos en el c\u00f3digo JSX lo siguiente tras nuestro button
:
<CreateCategory\ncreate={createCategory}\ncategory={null}\ncloseModal={handleCloseCreate}\n/>\n
Si ahora vamos a nuestro navegador, a la p\u00e1gina de categor\u00edas, podremos ver el formulario para crear una categor\u00eda, pero \u00e9sta fijo y no hay manera de cerrarlo. Vamos a cambiar este comportamiento mediante una variable booleana en el estado del componente que decidir\u00e1 cuando se muestra este. Adem\u00e1s, a\u00f1adiremos a nuestro bot\u00f3n el c\u00f3digo necesario para mostrar el componente y a\u00f1adiremos a la funci\u00f3n handleCloseCreate
el c\u00f3digo para ocultarlo.
A\u00f1adimos un nuevo estado:
const [openCreate, setOpenCreate] = useState(false);\n
Modificamos la function handleCloseCreate
:
const handleCloseCreate = () => {\nsetOpenCreate(false);\n};\n
Y por \u00faltimo modificamos el c\u00f3digo del return de la siguiente manera:
<div className=\"newButton\">\n<Button variant=\"contained\" onClick={() => setOpenCreate(true)}>\nNueva categor\u00eda\n</Button>\n</div>\n{openCreate && (\n<CreateCategory\ncreate={createCategory}\ncategory={null}\ncloseModal={handleCloseCreate}\n/>\n)}\n
Si probamos ahora vemos que ya se realiza la funcionalidad de abrir y cerrar nuestro formulario de manera correcta.
Ahora vamos a a\u00f1adir la funcionalidad para que al pulsar el bot\u00f3n de edici\u00f3n pasemos la categor\u00eda a editar a nuestro formulario. Para esto vamos a necesitar una nueva variable en nuestro estado donde almacenaremos la categor\u00eda a editar:
const [categoryToUpdate, setCategoryToUpdate] =\nuseState<CategoryModel | null>(null);\n
Modificamos el c\u00f3digo de nuestro bot\u00f3n:
<IconButton\naria-label=\"update\"\ncolor=\"primary\"\nonClick={() => {\nsetCategoryToUpdate(category);\nsetOpenCreate(true);\n}}\n>\n<EditIcon />\n</IconButton>\n
Y la entrada a nuestro componente:
{openCreate && (\n<CreateCategory\ncreate={createCategory}\ncategory={categoryToUpdate}\ncloseModal={handleCloseCreate}\n/>\n)}\n
Si ahora hacemos una prueba en nuestro navegador y pulsamos el bot\u00f3n de editar vemos como nuestro formulario ya se carga correctamente pero hay un problema, si pulsamos el bot\u00f3n de editar, cerramos el formulario y le damos al boton de nueva categor\u00eda vemos que el formulario mantiene los datos anteriores. Vamos a solucionar este problema volviendo a dejar vacia la variable categoryToUpdate
cuando se cierre el componente:
Modificamos la funci\u00f3n handleCloseCreate
:
const handleCloseCreate = () => {\nsetOpenCreate(false);\nsetCategoryToUpdate(null);\n};\n
Y vemos que el funcionamiento ya es el correcto.
Ahora vamos a darle funcionalidad al bot\u00f3n de borrado. Cuando pulsemos sobre este bot\u00f3n se nos debe mostrar un mensaje de alerta para confirmar nuestra decisi\u00f3n. Como este es un mensaje que vamos a reutilizar en el resto de la aplicaci\u00f3n vamos a crear un componente en la carpeta src/components
llamado ConfirmDialog.tsx
con el siguiente contenido:
import Button from \"@mui/material/Button\";\nimport DialogContentText from \"@mui/material/DialogContentText\";\nimport Dialog from \"@mui/material/Dialog\";\nimport DialogActions from \"@mui/material/DialogActions\";\nimport DialogContent from \"@mui/material/DialogContent\";\nimport DialogTitle from \"@mui/material/DialogTitle\";\n\ninterface Props {\ncloseModal: () => void;\nconfirm: () => void;\ntitle: string;\ntext: string;\n}\n\nexport const ConfirmDialog = (props: Props) => {\nreturn (\n<div>\n<Dialog open={true} onClose={props.closeModal}>\n<DialogTitle>{props.title}</DialogTitle>\n<DialogContent>\n<DialogContentText>{props.text}</DialogContentText>\n</DialogContent>\n<DialogActions>\n<Button onClick={props.closeModal}>Cancelar</Button>\n<Button onClick={() => props.confirm()}>Confirmar</Button>\n</DialogActions>\n</Dialog>\n</div>\n);\n};\n
Y vamos a a\u00f1adirlo a nuestra p\u00e1gina de categorias, pero al igual que paso con nuestro formulario de altas no queremos que este componente se muestre siempre, sino que estar\u00e1 condicionado al valor de una nueva variable en nuestro estado. En este caso vamos a almacenar el id de la categor\u00eda a borrar.
Importamos nuestro nuevo componente:
import { ConfirmDialog } from \"../../components/ConfirmDialog\";\n
Creamos una nueva variable en el estado:
const [idToDelete, setIdToDelete] = useState(\"\");\n
Creamos una nueva funci\u00f3n:
const deleteCategory = () => {};\n
Modificamos el bot\u00f3n de borrado:
<IconButton\naria-label=\"delete\"\ncolor=\"error\"\nonClick={() => {\nsetIdToDelete(category.id);\n}}\n>\n<ClearIcon />\n</IconButton>\n
Y a\u00f1adimos el c\u00f3digo necesario en nuestro return para incluir el nuevo componente:
{!!idToDelete && (\n<ConfirmDialog\ntitle=\"Eliminar categor\u00eda\"\ntext=\"Atenci\u00f3n si borra la categor\u00eda se perder\u00e1n sus datos. \u00bfDesea eliminar la categor\u00eda?\"\nconfirm={deleteCategory}\ncloseModal={() => setIdToDelete('')}\n/>\n)}\n
"},{"location":"develop/basic/react/#recuperando-datos","title":"Recuperando datos","text":"Ya estamos preparados para llamar a nuestro back. Hay muchas maneras de recuperar datos del back en React. Si no queremos usar ninguna librer\u00eda externa podemos hacer uso del m\u00e9todo fetch, pero tendr\u00edamos que repetir mucho c\u00f3digo o bien construir interceptores, para el manejo de errores, construcci\u00f3n de middlewares,\u2026 adem\u00e1s, no es lo mas utilizado. Hoy en d\u00eda se opta por librer\u00edas como Axios
o Redux Toolkit query
que facilitan el uso de este m\u00e9todo.
Nosotros vamos a utilizar una herramienta de redux llamada Redux Toolkit Query
, pero primero vamos a explicar que es redux.
Redux es una librer\u00eda que implementa el patr\u00f3n de dise\u00f1o Flux y que nos permite crear un estado global.
Nuestros componentes pueden realizar acciones asociadas a un reducer que modificar\u00e1n este estado global llamado generalmente store
y a su vez estar\u00e1n subscritos a variables de este estado para estar atentos a posibles cambios.
Antes se sol\u00edan construir ficheros de actions, de reducers y un fichero de store, pero con redux toolkit se ha simplificado todo. Por un lado, podemos tener slices, que son ficheros que agrupan acciones, reducers y parte del estado y por otro lado podemos tener servicios donde declaramos llamadas a nuestra api y redux guarda las llamadas en nuestro estado global para que sean accesibles desde cualquier parte de nuestra aplicaci\u00f3n.
Vamos a crear una carpeta llamada redux
dentro de la carpeta src
y a su vez dentro de src/redux
vamos a crear dos carpetas: features
donde crearemos nuestros slices y services
donde crearemos las llamadas al api.
Dentro de la carpeta services
vamos a crear un fichero llamado ludotecaApi.ts
con el siguiente contenido:
import { createApi, fetchBaseQuery } from \"@reduxjs/toolkit/query/react\";\nimport { Category } from \"../../types/Category\";\n\nexport const ludotecaAPI = createApi({\nreducerPath: \"ludotecaApi\",\nbaseQuery: fetchBaseQuery({\nbaseUrl: \"http://localhost:8080\",\n}),\ntagTypes: [\"Category\"],\nendpoints: (builder) => ({\ngetCategories: builder.query<Category[], null>({\nquery: () => \"category\",\nprovidesTags: [\"Category\"],\n}),\ncreateCategory: builder.mutation({\nquery: (payload) => ({\nurl: \"/category\",\nmethod: \"PUT\",\nbody: payload,\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Category\"],\n}),\ndeleteCategory: builder.mutation({\nquery: (id: string) => ({\nurl: `/category/${id}`,\nmethod: \"DELETE\",\n}),\ninvalidatesTags: [\"Category\"],\n}),\nupdateCategory: builder.mutation({\nquery: (payload: Category) => ({\nurl: `category/${payload.id}`,\nmethod: \"PUT\",\nbody: payload,\n}),\ninvalidatesTags: [\"Category\"],\n}),\n}),\n});\n\nexport const {\nuseGetCategoriesQuery,\nuseCreateCategoryMutation,\nuseDeleteCategoryMutation,\nuseUpdateCategoryMutation\n} = ludotecaAPI;\n
Con esto ya habr\u00edamos creado las acciones que llaman al back y almacenan el resultado en nuestro estado. Para configurar nuestra api le tenemos que dar un nombre, una url base, una series de tags y nuestros endpoints que pueden ser de tipo query para realizar consultas o mutation
. Tambi\u00e9n exportamos los hooks que nos van a permitir hacer uso de estos endpoints. Si los endpoints los creamos de tipo query
, cuando hacemos uso de estos hooks se realizar\u00e1 una consulta al back y recibiremos los datos de la consulta en nuestros par\u00e1metros del hook entre otras cosas. Si los creamos de tipo mutation
lo que nos devolver\u00e1 el hook ser\u00e1 la acci\u00f3n que tenemos que llamar para realizar esta llamada.
Los tags sirven para cachear el resultado, pero cuando llamamos a una mutation
y pasamos informaci\u00f3n en invalidateTags
, esto va a hacer que se vuelva a lanzar la query afectada por estos tags para actualizar su resultado, por eso hemos a\u00f1adido el providesTags
en la query, para que desde nuestras p\u00e1ginas usemos los hooks exportados.
Ahora vamos a crear dentro de la carpeta src/redux
un fichero llamado store.ts
con el siguiente contenido:
import { configureStore } from \"@reduxjs/toolkit\";\nimport { setupListeners } from \"@reduxjs/toolkit/dist/query\";\nimport { ludotecaAPI } from \"./services/ludotecaApi\";\n\nexport const store = configureStore({\nreducer: {\n[ludotecaAPI.reducerPath]: ludotecaAPI.reducer,\n},\nmiddleware: (getDefaultMiddleware) =>\ngetDefaultMiddleware().concat([ludotecaAPI.middleware]),\n});\n\nsetupListeners(store.dispatch);\n\n// Infer the `RootState` and `AppDispatch` types from the store itself\nexport type RootState = ReturnType<typeof store.getState>;\n// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}\nexport type AppDispatch = typeof store.dispatch;\n
Aqu\u00ed b\u00e1sicamente creamos el store con nuestro reducer
. Cabe destacar que podemos crear tantos reducers
como queramos siempre que les demos distintos nombres.
Ahora ya podr\u00edamos hacer uso de los hooks que vienen con redux llamados useDispatch
para llamar a nuestras actions y useSelect
para suscribirnos a los cambios en el estado, pero como estamos usando typescript tendr\u00edamos que tipar todos estos m\u00e9todos y variables que usamos en todos nuestros componentes resultando un c\u00f3digo un tanto sucio y repetitivo. Tambi\u00e9n podemos simplemente ignorar a typescript y deshabilitar las reglas para estos ficheros, pero vamos a hacerlo bien.
Vamos a crear un fichero llamado hooks.ts
dentro de la carpeta de redux y su contenido ser\u00e1 el siguiente:
import { useDispatch, useSelector } from 'react-redux'\nimport type { TypedUseSelectorHook } from 'react-redux'\nimport type { RootState, AppDispatch } from './store'\n\n// Use throughout your app instead of plain `useDispatch` and `useSelector`\ntype DispatchFunc = () => AppDispatch\nexport const useAppDispatch: DispatchFunc = useDispatch\nexport const useAppSelector: TypedUseSelectorHook<RootState> = useSelector\n
Estos ser\u00e1n los m\u00e9todos que usaremos en lugar de useDispatch
y useSelector
.
Ahora vamos a modificar nuestro fichero App.tsx
a\u00f1adiendo los imports necesarios y rodeando nuestro c\u00f3digo con el tag provider
:
import { Provider } from \"react-redux\";\n\nimport { store } from \"./redux/store\";\n\n<Provider store={store}>\n<BrowserRouter>\n<Routes>\n<Route element={<Layout />}>\n<Route index path=\"games\" element={<Game />} />\n<Route path=\"categories\" element={<Category />} />\n<Route path=\"authors\" element={<Author />} />\n<Route path=\"*\" element={<Navigate to=\"/games\" />} />\n</Route>\n</Routes>\n</BrowserRouter>\n</Provider>\n
Ahora ya podemos hacer uso de los m\u00e9todos de redux
para modificar y leer el estado global de nuestra aplicaci\u00f3n.
Volvemos a nuestro componente Category
y vamos a importar los hooks de nuestra api para hacer uso de ellos:
import { useAppDispatch } from \"../../redux/hooks\";\nimport {\nuseCreateCategoryMutation,\nuseDeleteCategoryMutation,\nuseGetCategoriesQuery,\nuseUpdateCategoryMutation,\n} from \"../../redux/services/ludotecaApi\";\n
Eliminamos la variable mockeada data y a\u00f1adimos en su lugar lo siguiente:
const dispatch = useAppDispatch();\nconst { data, error, isLoading } = useGetCategoriesQuery(null);\n\nconst [\ndeleteCategoryApi,\n{ isLoading: isLoadingDelete, error: errorDelete },\n] = useDeleteCategoryMutation();\nconst [createCategoryApi, { isLoading: isLoadingCreate }] =\nuseCreateCategoryMutation();\n\nconst [updateCategoryApi, { isLoading: isLoadingUpdate }] =\nuseUpdateCategoryMutation();\n
Como ya hemos dicho anteriormente, los hooks
de la api de tipo query nos devolver\u00e1n datos mientras que los hooks
de tipo mutation
nos devuelven acciones que podemos lanzar con el m\u00e9todo dispatch
. El resto de los par\u00e1metros nos dan informaci\u00f3n para saber el estado de la llamada, por ejemplo, para saber si esta cargando, si se ha producido un error, etc\u2026
Tenemos que modificar el c\u00f3digo que recorre data ya que este valor ahora puede estar sin definir:
<TableBody>\n{data &&\ndata.map((category: CategoryModel) => (\n
Y ahora si tenemos datos en la base de datos y vamos a nuestro navegador podemos ver que ya se est\u00e1n representando estos datos en la tabla de categor\u00edas.
Modificamos el m\u00e9todo createCategory
:
const createCategory = (category: string) => {\nsetOpenCreate(false);\nif (categoryToUpdate) {\nupdateCategoryApi({ id: categoryToUpdate.id, name: category })\n.then(() => {\nsetCategoryToUpdate(null);\n})\n.catch((err) => console.log(err));\n} else {\ncreateCategoryApi({ name: category })\n.then(() => {\nsetCategoryToUpdate(null);\n})\n.catch((err) => console.log(err));\n}\n};\n
Si tenemos almacenada alguna categor\u00eda para actualizar llamaremos a la acci\u00f3n para actualizar la categor\u00eda que recuperamos del hook y si no tenemos categor\u00eda almacenada llamaremos al m\u00e9todo para crear una categor\u00eda nueva. Estos m\u00e9todos nos devuelven una promesa que cuando resolvemos volvemos a poner el valor de la categor\u00eda a actualizar a null
.
Implementamos el m\u00e9todo para borrar categor\u00edas:
const deleteCategory = () => {\ndeleteCategoryApi(idToDelete)\n.then(() => setIdToDelete(''))\n.catch((err) => console.log(err));\n};\n
Ahora si probamos en nuestro navegador ya podremos realizar todas las funciones de la p\u00e1gina: listar, crear, actualizar y borrar, pero aun vamos a darle m\u00e1s funcionalidad.
Vamos a crear una variable en el estado global de nuestra aplicaci\u00f3n para mostrar alertas de informaci\u00f3n o de error. Para ello creamos un nuevo fichero en la carpeta src/redux/features
llamado messageSlice.ts
cuyo contenido ser\u00e1 el siguiente:
import { createSlice } from '@reduxjs/toolkit'\nimport type {PayloadAction} from \"@reduxjs/toolkit\"\n\nexport const messageSlice = createSlice({\nname: 'message',\ninitialState: {\ntext: '',\ntype: ''\n},\nreducers: {\ndeleteMessage: (state) => {\nstate.text = ''\nstate.type = ''\n},\nsetMessage: (state, action : PayloadAction<{text: string; type: string}>) => {\nstate.text = action.payload.text;\nstate.type = action.payload.type;\n},\n},\n})\n\nexport const { deleteMessage, setMessage } = messageSlice.actions;\nexport default messageSlice.reducer;\n
Como ya hemos dicho anteriormente los slices
son un concepto introducido en Redux Toolkit
y no es ni m\u00e1s ni menos que un fichero que agrupa reducers
, actions
y selectors
.
En este fichero declaramos el nombre del selector (message
) para despu\u00e9s poder recuperar los datos en un componente, declaramos el estado inicial de nuestro slice
, creamos las funciones de los reducers
y declaramos dos acciones.
Los reducers
son funciones puras que modifican el estado, en nuestro caso utilizamos un reducer
para resetear el estado y otro para setear el texto y el tipo de mensaje. Con Redux Toolkit
podemos acceder directamente al estado dentro de nuestros reducers
. En los reducers
que no usan esta herramienta lo que se hace es devolver un objeto que ser\u00e1 el nuevo estado.
Las acciones son las que invocan a los reducers
. Estas solo dicen que hacer, pero no como hacerlo. Con Redux Toolkit
las acciones se generan autom\u00e1ticamente y solo tenemos que hacer un destructuring del objecto actions de nuestro slice
para recuperarlas y exportarlas.
Ahora vamos a modificar el fichero src/redux/store.ts
para a\u00f1adir el nuevo reducer
:
import { configureStore } from \"@reduxjs/toolkit\";\nimport { setupListeners } from \"@reduxjs/toolkit/dist/query\";\nimport { ludotecaAPI } from \"./services/ludotecaApi\";\nimport messageReducer from \"./features/messageSlice\";\n\nexport const store = configureStore({\nreducer: {\nmessageReducer,\n[ludotecaAPI.reducerPath]: ludotecaAPI.reducer,\n},\nmiddleware: (getDefaultMiddleware) =>\ngetDefaultMiddleware().concat([ludotecaAPI.middleware]),\n});\n\nsetupListeners(store.dispatch);\n\n// Infer the `RootState` and `AppDispatch` types from the store itself\nexport type RootState = ReturnType<typeof store.getState>;\n// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}\nexport type AppDispatch = typeof store.dispatch;\n
Con esto ya podemos hacer uso de esta funcionalidad. Vamos a modificar el componente Layout para que pueda recibir mensajes y mostrarlos por pantalla.
import Alert from \"@mui/material/Alert\";\nimport { useAppDispatch, useAppSelector } from \"../redux/hooks\";\nimport { deleteMessage } from \"../redux/features/messageSlice\";\n\nconst dispatch = useAppDispatch();\nconst { text, type } = useAppSelector((state) => state.messageReducer);\n\nuseEffect(() => {\nsetTimeout(() => {\ndispatch(deleteMessage());\n}, 3000);\n}, [text, type]);\n\n{text && (\n<Alert severity={type === \"error\" ? \"error\" : \"success\"}>{text}</Alert>\n)}\n
Hemos a\u00f1adido c\u00f3digo para que el componente layout
este subscrito a las variables text
y type
de nuestro contexto global. Si tenemos text
se mostrar\u00e1 la alerta y adem\u00e1s hemos incluido un nuevo hook
useEffect
gracias al cual cuando el componente reciba un text llamar\u00e1 a una funci\u00f3n que pasados 3 segundos borrar\u00e1 el mensaje de nuestro estado ocultando as\u00ed el Alert
.
Pero antes de seguir adelante vamos a explicar que hace useEffect
exactamente ya que es un hook
de React muy utilizado.
El ciclo de vida de los componentes en React permit\u00eda en los componentes de tipo clase poder ejecutar c\u00f3digo en diferentes fases de montaje, actualizaci\u00f3n y desmontaje. De esta forma, pod\u00edamos a\u00f1adir cierta funcionalidad en las distintas etapas de nuestro componente.
Con los hooks
tambi\u00e9n podremos acceder a ese ciclo de vida en nuestros componentes funcionales, aunque de una forma m\u00e1s clara y sencilla. Para ello usaremos useEffect
, un hook
que recibe como par\u00e1metro una funci\u00f3n que se ejecutar\u00e1 cada vez que se modifique el valor de las las dependencias que pasemos como segundo par\u00e1metro.
Hay otros casos especiales de useEffect
, por ejemplo, si hubi\u00e9semos dejado el array de dependencias de useEffect
vac\u00edo, solo se llamar\u00eda a la funci\u00f3n la primera vez que se renderiza el componente.
useEffect(() => {\nconsole.log(\u2018Solo me muestro en el primer render\u2019);\n}, []);\n
Y si queremos que solo se llame a la funci\u00f3n cuando se desmonta el componente lo que tenemos que hacer es devolver de useEffect
una funci\u00f3n con el c\u00f3digo que queremos que se ejecute una vez que se desmonte:
useEffect(() => {\nreturn () => {\nconsole.log(\u2018Me desmonto!!\u2019)\n}\n}, []);\n
Dentro de la carpeta src/types
vamos a crear un fichero llamado appTypes.ts
que contendr\u00e1 todos aquellos tipos o interfaces auxiliares para construir nuestra aplicaci\u00f3n:
export interface BackError {\nmsg: string;\n}\n
Ahora ya podemos incluir en nuestra p\u00e1gina de categor\u00edas el c\u00f3digo para guardar los mensajes de informaci\u00f3n y error en el estado global, importamos lo necesario:
import { setMessage } from \"../../redux/features/messageSlice\";\nimport { BackError } from \"../../types/appTypes\";\n
A\u00f1adimos:
useEffect(() => {\nif (errorDelete) {\nif (\"status\" in errorDelete) {\ndispatch(\nsetMessage({\ntext: (errorDelete?.data as BackError).msg,\ntype: \"error\",\n})\n);\n}\n}\n}, [errorDelete, dispatch]);\n\nuseEffect(() => {\nif (error) {\ndispatch(setMessage({ text: \"Se ha producido un error\", type: \"error\" }));\n}\n}, [error]);\n
Y modificamos:
const createCategory = (category: string) => {\nsetOpenCreate(false);\nif (categoryToUpdate) {\nupdateCategoryApi({ id: categoryToUpdate.id, name: category })\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Categor\u00eda actualizada correctamente\",\ntype: \"ok\",\n})\n);\nsetCategoryToUpdate(null);\n})\n.catch((err) => console.log(err));\n} else {\ncreateCategoryApi({ name: category })\n.then(() => {\ndispatch(\nsetMessage({ text: \"Categor\u00eda creada correctamente\", type: \"ok\" })\n);\nsetCategoryToUpdate(null);\n})\n.catch((err) => console.log(err));\n}\n};\n\nconst deleteCategory = () => {\ndeleteCategoryApi(idToDelete)\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Categor\u00eda borrada correctamente\",\ntype: \"ok\",\n})\n);\nsetIdToDelete(\"\");\n})\n.catch((err) => console.log(err));\n};\n
Si ahora probamos nuestra aplicaci\u00f3n al borrar, actualizar o crear una categor\u00eda nos deber\u00eda de mostrar un mensaje de informaci\u00f3n.
Ya casi estamos terminando con nuestra p\u00e1gina de categor\u00edas, pero vamos a a\u00f1adir tambi\u00e9n un loader
para cuando nuestra acciones est\u00e9n en estado de loading
. Para esto vamos a hacer uso de otra de las maneras que tiene React de almacenar informaci\u00f3n global, el contexto.
Una de las caracter\u00edsticas que llegaron en las \u00faltimas versiones de React fue el contexto, una forma de pasar datos que pueden considerarse globales a un \u00e1rbol de componentes sin la necesidad de utilizar Redux
. El uso de contextos mediante la Context API
es una soluci\u00f3n m\u00e1s ligera y sencilla que redux y que no est\u00e1 mal para aplicaciones que no son excesivamente grandes.
En general cuando queramos usar estados globales que no sean demasiado grandes y no se haga demasiada escritura sobre ellos ser\u00e1 preferible usar Context API
en lugar de redux
.
Vamos a crear un contexto para utilizar un loader
en nuestra aplicaci\u00f3n.
Lo primero ser\u00e1 crear una carpeta llamada context
dentro de la carpeta src
de nuestro proyecto y dentro de esta crearemos un nuevo fichero llamado LoaderProvider.tsx
con el siguiente contenido:
import { createContext, useState } from \"react\";\nimport Backdrop from \"@mui/material/Backdrop\";\nimport CircularProgress from \"@mui/material/CircularProgress\";\n\nexport const LoaderContext = createContext({\nloading: false,\nshowLoading: (_show: boolean) => {},\n});\n\ntype Props = {\nchildren: JSX.Element;\n};\n\nexport const LoaderProvider = ({ children }: Props) => {\nconst showLoading = (show: boolean) => {\nsetState((prev) => ({\n...prev,\nloading: show,\n}));\n};\n\nconst [state, setState] = useState({\nloading: false,\nshowLoading,\n});\n\nreturn (\n<LoaderContext.Provider value={state}>\n<Backdrop\nsx={{ color: \"#fff\", zIndex: (theme) => theme.zIndex.drawer + 1 }}\nopen={state.loading}\n>\n<CircularProgress color=\"inherit\" />\n</Backdrop>\n\n{children}\n</LoaderContext.Provider>\n);\n};\n
Y ahora modificamos nuestro fichero App.tsx
de la siguiente manera:
import { LoaderProvider } from \"./context/LoaderProvider\";\n<LoaderProvider>\n<Provider store={store}>\n<BrowserRouter>\n<Routes>\n<Route element={<Layout />}>\n<Route index path=\"games\" element={<Game />} />\n<Route path=\"categories\" element={<Category />} />\n<Route path=\"authors\" element={<Author />} />\n<Route path=\"*\" element={<Navigate to=\"/games\" />} />\n</Route>\n</Routes>\n</BrowserRouter>\n</Provider>\n</LoaderProvider>\n
Lo que hemos hecho ha sido envolver toda nuestra aplicaci\u00f3n dentro de nuestro provider
de tal modo que esta el children
en el fichero LoaderProvider
, pero ahora y gracias a la funcionalidad de createContext
la variable loading
y el m\u00e9todo showLoading
estar\u00e1n disponibles en todos los sitios de nuestra aplicaci\u00f3n.
Ahora para hacer uso de esta funcionalidad nos vamos a nuestra pagina de Categorias
e importamos lo siguiente:
import { useState, useEffect, useContext } from \"react\";\n\nimport { LoaderContext } from \"../../context/LoaderProvider\";\n
Declaramos una nueva constante:
const loader = useContext(LoaderContext);\n
Podemos hace uso del m\u00e9todo showLoading
donde queramos, en nuestro caso vamos a crear otro useEffect
que estar\u00e1 pendiente de los cambios en cualquiera de los loadings
:
useEffect(() => {\nloader.showLoading(\nisLoadingCreate || isLoading || isLoadingDelete || isLoadingUpdate\n);\n}, [isLoadingCreate, isLoading, isLoadingDelete, isLoadingUpdate]);\n
Probamos la aplicaci\u00f3n y vemos que cuando se carga el listado o realizamos cualquier llamada al back se muestra brevemente nuestro loader
.
Ahora que ya tenemos listo el proyecto backend de Spring Boot (en el puerto 8080) ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/springboot/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 en Spring Boot tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. Tanto la propia web de Spring como en el portal de tutoriales de Baeldung puedes buscar casi cualquier ejemplo que necesites.
"},{"location":"develop/basic/springboot/#estructurar-el-codigo","title":"Estructurar el c\u00f3digo","text":"Vamos a hacer un breve refresco de la estructura del c\u00f3digo que ya se ha visto en puntos anteriores.
Las clases deben estar agrupadas por \u00e1mbito funcional, en nuestro caso como vamos a hacer la funcionalidad de Categor\u00edas
pues deber\u00eda estar todo dentro de un package del tipo com.ccsw.tutorial.category
.
Adem\u00e1s, deber\u00edamos aplicar la separaci\u00f3n por capas como ya se vi\u00f3 en el esquema:
La primera capa, la de Controlador
, se encargar\u00e1 de procesar las peticiones y transformar datos. Esta capa llamar\u00e1 a la capa de L\u00f3gica
de negocio que ejecutar\u00e1 las operaciones, ayud\u00e1ndose de otros objetos de esa misma capa de L\u00f3gica
o bien de llamadas a datos a trav\u00e9s de la capa de Acceso a Datos
Ahora s\u00ed, vamos a programar!.
"},{"location":"develop/basic/springboot/#capa-de-operaciones-controller","title":"Capa de operaciones: Controller","text":"En esta capa es donde se definen las operaciones que pueden ser consumidas por los clientes. Se caracterizan por estar anotadas con las anotaciones @Controller o @RestController y por las anotaciones @RequestMapping que nos permiten definir las rutas de acceso.
Recomendaci\u00f3n: Breve detalle REST
Antes de continuar te recomiendo encarecidamente que leas el Anexo: Detalle REST donde se explica brevemente como estructurar los servicios REST que veremos a continuaci\u00f3n.
"},{"location":"develop/basic/springboot/#controller-de-ejemplo","title":"Controller de ejemplo","text":"Vamos a crear una clase CategoryController.java
dentro del package com.ccsw.tutorial.category
para definir las rutas de las operaciones.
package com.ccsw.tutorial.category;\n\nimport java.util.List;\n\nimport org.springframework.web.bind.annotation.CrossOrigin;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestMethod;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport io.swagger.v3.oas.annotations.tags.Tag;\n\n/**\n * @author ccsw\n * \n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\n/**\n * M\u00e9todo para probar el servicio\n * \n */\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic String prueba() {\n\nreturn \"Probando el Controller\";\n}\n\n}\n
Ahora si arrancamos la aplicaci\u00f3n server, abrimos el Postman y creamos una petici\u00f3n GET a la url http://localhost:8080/category nos responder\u00e1 con el mensaje que hemos programado.
"},{"location":"develop/basic/springboot/#implementar-operaciones","title":"Implementar operaciones","text":"Ahora que ya tenemos un controlador y una operaci\u00f3n de negocio ficticia, vamos a borrarla y a\u00f1adir las operaciones reales que consumir\u00e1 nuestra pantalla. Deberemos a\u00f1adir una operaci\u00f3n para listar, una para actualizar, una para guardar y una para borrar. Aunque para hacerlo m\u00e1s c\u00f3modo, utilizaremos la misma operaci\u00f3n para guardar y para actualizar. Adem\u00e1s, no vamos a trabajar directamente con datos simples, sino que usaremos objetos para recibir informaci\u00f3n y para enviar informaci\u00f3n.
Estos objetos t\u00edpicamente se denominan DTO (Data Transfer Object) y nos sirven justamente para encapsular informaci\u00f3n que queremos transportar. En realidad no son m\u00e1s que clases pojo sencillas con propiedades, getters y setters.
Para nuestro ejemplo crearemos una clase CategoryDto
dentro del package com.ccsw.tutorial.category.model
con el siguiente contenido:
package com.ccsw.tutorial.category.model;\n\n/**\n * @author ccsw\n * \n */\npublic class CategoryDto {\n\nprivate Long id;\n\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
A continuaci\u00f3n utilizaremos esta clase en nuestro Controller para implementar las tres operaciones de negocio.
CategoryController.javapackage com.ccsw.tutorial.category;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport org.springframework.web.bind.annotation.CrossOrigin;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestMethod;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\n\n/**\n * @author ccsw\n * \n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\nprivate long SEQUENCE = 1;\nprivate Map<Long, CategoryDto> categories = new HashMap<Long, CategoryDto>();\n\n/**\n * M\u00e9todo para recuperar todas las categorias\n *\n * @return {@link List} de {@link CategoryDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Categories\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<CategoryDto> findAll() {\n\nreturn new ArrayList<CategoryDto>(this.categories.values());\n}\n\n/**\n * M\u00e9todo para crear o actualizar una categoria\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Category\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody CategoryDto dto) {\n\nCategoryDto category;\n\nif (id == null) {\ncategory = new CategoryDto();\ncategory.setId(this.SEQUENCE++);\nthis.categories.put(category.getId(), category);\n} else {\ncategory = this.categories.get(id);\n}\n\ncategory.setName(dto.getName());\n}\n\n/**\n * M\u00e9todo para borrar una categoria\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Category\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) {\n\nthis.categories.remove(id);\n}\n}\n
Como todav\u00eda no tenemos acceso a BD, hemos creado una variable tipo HashMap y una variable Long, que simular\u00e1n una BD y una secuencia. Tambi\u00e9n hemos implementado tres operaciones GET, PUT y DELETE que realizan las acciones necesarias por nuestra pantalla. Ahora podr\u00edamos probarlo desde el Postman con cuatro ejemplo sencillos.
F\u00edjate que el m\u00e9todo save
tiene dos rutas. La ruta normal category/
y la ruta informada category/3
. Esto es porque hemos juntado la acci\u00f3n create y update en un mismo m\u00e9todo para facilitar el desarrollo. Es totalmente v\u00e1lido y funcional.
Atenci\u00f3n
Los datos que se reciben pueden venir informados como un par\u00e1metro en la URL Get, como una variable en el propio path o dentro del body de la petici\u00f3n. Cada uno de ellos se recupera con una anotaci\u00f3n especial: @RequestParam
, @PathVariable
y @RequestBody
respectivamente.
Como no tenemos ning\u00fan dato dado de alta, podemos probar en primer lugar a realizar una inserci\u00f3n de datos con el m\u00e9todo PUT.
PUT /category nos sirve para insertar Categor\u00edas
nuevas (si no tienen el id informado) o para actualizar Categor\u00edas
(si tienen el id informado). F\u00edjate que los datos que se env\u00edan est\u00e1n en el body como formato JSON (parte izquierda de la imagen). Si no env\u00edas datos, te dar\u00e1 un error.
GET /category nos devuelve un listado de Categor\u00edas
, siempre que hayamos insertado algo antes.
DELETE /category nos sirve eliminar Categor\u00edas
. F\u00edjate que el dato del ID que se env\u00eda est\u00e1 en el path.
Prueba a jugar borrando categor\u00edas que no existen o modificando categor\u00edas que no existen. Tal y como est\u00e1 programado, el borrado no dar\u00e1 error, pero la modificaci\u00f3n deber\u00eda dar un NullPointerException al no existir el dato a modificar.
"},{"location":"develop/basic/springboot/#documentacion-openapi","title":"Documentaci\u00f3n (OpenAPI)","text":"Si te acuerdas, en el punto de Entorno de desarrollo
, a\u00f1adimos el m\u00f3dulo de OpenAPI a nuestro proyecto, y en el desarrollo de nuestro Controller
hemos anotado tanto la clase como los m\u00e9todos con sus correspondientes etiquetas @Tag
y @Operation
.
Esto nos va a ayudar a generar documentaci\u00f3n autom\u00e1tica de nuestras APIs haciendo que nuestro c\u00f3digo sea m\u00e1s mantenible y nuestra documentaci\u00f3n mucho m\u00e1s fiable.
Para ver el resultado, con el proyecto arrancado nos dirigimos a la ruta por defecto de OpenAPI: http://localhost:8080/swagger-ui/index.html
Aqu\u00ed podemos observar el cat\u00e1logo de endpoints generados, ver los tipos de entrada y salida e incluso realizar peticiones a los mismos. Este ser\u00e1 el contrato de nuestros endpoints, que nos ayudar\u00e1 a integrarnos con el equipo frontend (en el caso del tutorial seguramente seremos nosotros mismos).
"},{"location":"develop/basic/springboot/#aspectos-importantes","title":"Aspectos importantes","text":"Los aspectos importantes de la capa Controller
son:
@Controller
o @RestController
. Mejor usar la \u00faltima anotaci\u00f3n, ya que est\u00e1s diciendo que las operaciones son de tipo Rest y no har\u00e1 falta configurar nada@RequestMapping
global de la clase, aunque tambi\u00e9n se puede obviar esta anotaci\u00f3n y a\u00f1adir a cada una de las operaciones la ruta ra\u00edz.@RequestMapping
con la info:path
\u2192 Que nos permite definir un path para la operaci\u00f3n, siempre sum\u00e1ndole el path de la clase (si es que tuviera)method
\u2192 Que nos permite definir el verbo de http que vamos a atender. Podemos tener el mismo path con diferente method, sin problema. Por lo general utilizaremos:Pero en realidad la cosa no funciona as\u00ed. Hemos implementado parte de la l\u00f3gica de negocio (las operaciones/acciones de guardado, borrado y listado) dentro de lo que ser\u00eda la capa de operaciones o servicios al cliente. Esta capa no debe ejecutar l\u00f3gica de negocio, tan solo debe hacer transformaciones de datos y enrutar peticiones, toda la l\u00f3gica deber\u00eda ir en la capa de servicio.
"},{"location":"develop/basic/springboot/#implementar-servicios","title":"Implementar servicios","text":"Pues vamos a arreglarlo. Vamos a crear un servicio y vamos a mover la l\u00f3gica de negocio al servicio.
CategoryService.javaCategoryServiceImpl.javaCategoryController.javapackage com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n * \n */\npublic interface CategoryService {\n\n/**\n * M\u00e9todo para recuperar todas las categor\u00edas\n *\n * @return {@link List} de {@link Category}\n */\nList<CategoryDto> findAll();\n\n/**\n * M\u00e9todo para crear o actualizar una categor\u00eda\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, CategoryDto dto);\n\n/**\n * M\u00e9todo para borrar una categor\u00eda\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id);\n\n}\n
package com.ccsw.tutorial.category;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport org.springframework.stereotype.Service;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\n/**\n * @author ccsw\n *\n */\n@Service\npublic class CategoryServiceImpl implements CategoryService {\n\nprivate long SEQUENCE = 1;\nprivate Map<Long, CategoryDto> categories = new HashMap<Long, CategoryDto>();\n\n/**\n * {@inheritDoc}\n */\npublic List<CategoryDto> findAll() {\n\nreturn new ArrayList<CategoryDto>(this.categories.values());\n}\n\n/**\n * {@inheritDoc}\n */\npublic void save(Long id, CategoryDto dto) {\n\nCategoryDto category;\n\nif (id == null) {\ncategory = new CategoryDto();\ncategory.setId(this.SEQUENCE++);\nthis.categories.put(category.getId(), category);\n} else {\ncategory = this.categories.get(id);\n}\n\ncategory.setName(dto.getName());\n}\n\n/**\n * {@inheritDoc}\n */\npublic void delete(Long id) {\n\nthis.categories.remove(id);\n}\n\n}\n
package com.ccsw.tutorial.category;\n\nimport java.util.List;\n\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.CrossOrigin;\nimport org.springframework.web.bind.annotation.PathVariable;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestMethod;\nimport org.springframework.web.bind.annotation.RestController;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\n\n/**\n * @author ccsw\n * \n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\n@Autowired\nprivate CategoryService categoryService;\n\n/**\n * M\u00e9todo para recuperar todas las categorias\n *\n * @return {@link List} de {@link CategoryDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Categories\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<CategoryDto> findAll() {\n\nreturn this.categoryService.findAll();\n}\n\n/**\n * M\u00e9todo para crear o actualizar una categoria\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Category\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody CategoryDto dto) {\n\nthis.categoryService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para borrar una categoria\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Category\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) {\n\nthis.categoryService.delete(id);\n}\n}\n
Ahora ya tenemos bien estructurado nuestro proyecto. Ya tenemos las dos capas necesarias Controladores y Servicios y cada uno se encarga de llevar a cabo su cometido de forma correcta.
"},{"location":"develop/basic/springboot/#aspectos-importantes_1","title":"Aspectos importantes","text":"Los aspectos importantes de la capa Service
son:
@Service
y adem\u00e1s debe implementar la Interface del servicio. Un error muy com\u00fan al arrancar un proyecto y ver que no funcionan las llamadas, es porqu\u00e9 no existe la anotaci\u00f3n @Service
o porqu\u00e9 no se ha implementado la Interface.inyectar
y utilizar componentes manejados por Spring Boot es mediante la anotaci\u00f3n @Autowired
. NO intentes crear un objeto de CategoryServiceImpl, ni hacer un new
, ya que no estar\u00e1 manejado por Springboot y dar\u00e1 fallos de NullPointer. Lo mejor es dejar que Spring Boot lo gestione y utilizar las inyecciones de dependencias.Pero no siempre vamos a acceder a los datos mediante un HasMap en memoria. En algunas ocasiones queremos que nuestro proyecto acceda a un servicio de datos como puede ser una BBDD, un servicio externo, un acceso a disco, etc. Estos accesos se deben hacer desde la capa de acceso a datos, y en concreto para nuestro ejemplo, lo haremos a trav\u00e9s de un Repository para que acceda a una BBDD.
Para el tutorial no necesitamos configurar una BBDD externa ni complicarnos demasiado. Vamos a utilizar una librer\u00eda muy \u00fatil llamada H2
que nos permite levantar una BBDD en memoria persistiendo los datos en memoria o en disco, de hecho ya la configuramos en el apartado de Entorno de desarrollo
.
Lo primero que haremos ser\u00e1 crear nuestra entity con la que vamos a persistir y recuperar informaci\u00f3n. Las entidades igual que los DTOs deber\u00edan estar agrupados dentro del package model
de cada funcionalidad, as\u00ed que vamos a crear una nueva clase java.
package com.ccsw.tutorial.category.model;\n\nimport jakarta.persistence.*;\n\n/**\n * @author ccsw\n * \n */\n@Entity\n@Table(name = \"category\")\npublic class Category {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"name\", nullable = false)\nprivate String name;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n}\n
Si te fijas, la Entity suele ser muy similar a un DTO, tiene unas propiedades y sus getters y setters. Pero a diferencia de los DTOs, esta clase tiene una serie de anotaciones que permiten a JPA hacer su magia y generar consultas SQL a la BBDD. En este ejemplo vemos 4 anotaciones importantes:
@Entity
\u2192 Le indica a Springboot que se trata de una clase que implementa una Entidad de BBDD. Sin esta anotaci\u00f3n no es posible hacer queries.@Table
\u2192 Le indica a JPA el nombre y el schema de la tabla que representa esta clase. Por claridad se deber\u00eda poner siempre, aunque si el nombre de la tabla es igual al nombre de la clase no es necesaria la anotaci\u00f3n.@Id
y @GeneratedValue
\u2192 Le indica a JPA que esta propiedad es la que mapea una Primary Key y adem\u00e1s que esta PK se genera con la estrategia que se le indique en la anotaci\u00f3n @GeneratedValue
, que puede ser:Secuence
, la que utiliza Oracle, en este caso habr\u00e1 que indicarle un nombre de secuencia.Indentity
, la que utiliza MySql o SQLServer, el auto-incremental. Table
, en algunas BBDD se permite tener una tabla donde se almacenan como registros todas las secuencias.Auto
, elige la mejor estrategia en funci\u00f3n de la BBDD que hemos seleccionado.@Column
\u2192 Le indica a JPA que esta propiedad mapea una columna de la tabla y le especifica el nombre de la columna. Al igual que la anotaci\u00f3nd de Table
, esta anotaci\u00f3n no es necesaria aunque si es muy recomendable. Por claridad se deber\u00eda poner siempre, aunque si el nombre de la columna es igual al nombre de la propiedad no es necesaria la anotaci\u00f3n.Hay muchas otras anotaciones, pero estas son las b\u00e1sicas, ya ir\u00e1s aprendiendo otras.
Consejo
Para definir las PK de las tablas, intenta evitar una PK compuesta de m\u00e1s de una columna. La programaci\u00f3n se hace muy compleja y las magias que hace JPA en la oscuridad se complican mucho. Mi recomendaci\u00f3n es que siempre utilices una PK n\u00famerica, en la medida de lo posible, y si es necesario, crees \u00edndices compuestos de b\u00fasqueda o checks compuestos para evitar duplicidades.
"},{"location":"develop/basic/springboot/#juego-de-datos-de-bbdd","title":"Juego de datos de BBDD","text":"Spring Boot autom\u00e1ticamente cuando arranque el proyecto escaner\u00e1 todas las @Entity
y crear\u00e1 las estructuras de las tablas en la BBDD en memoria, gracias a las anotaciones que hemos puesto. Adem\u00e1s de esto, lanzar\u00e1 los scripts de construcci\u00f3n de BBDD que tenemos en la carpeta src/main/resources/
. As\u00ed que, teniendo clara la estructura de la Entity
podemos configurar los ficheros con los juegos de datos que queramos, y para ello vamos a utilizar el fichero data.sql
que creamos en su momento.
Sabemos que la tabla se llamar\u00e1 category
y que tendr\u00e1 dos columnas, una columna id
, que ser\u00e1 la PK autom\u00e1tica, y una columna name
. Podemos escribir el siguiente script para rellenar datos:
INSERT INTO category(name) VALUES ('Eurogames');\nINSERT INTO category(name) VALUES ('Ameritrash');\nINSERT INTO category(name) VALUES ('Familiar');\n
"},{"location":"develop/basic/springboot/#implementar-repository","title":"Implementar Repository","text":"Ahora que ya tenemos el juego de datos y la entidad implementada, vamos a ver como acceder a BBDD desde Java. Esto lo haremos con un Repository
. Existen varias formas de utilizar los repositories, desde el todo autom\u00e1tico y magia de JPA hasta el repositorio manual en el que hay que codificar todo. En el tutorial voy a explicar varias formas de implementarlo para este CRUD y los siguientes CRUDs.
Como ya se dijo en puntos anteriores, el acceso a datos se debe hacer siempre a trav\u00e9s de un Repository
, as\u00ed que vamos a implementar uno. En esta capa, al igual que pasaba con los services, es recomendable utilizar el patr\u00f3n fachada, para poder sustituir implementaciones sin afectar al c\u00f3digo.
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface CategoryRepository extends CrudRepository<Category, Long> {\n\n}\n
\u00bfQu\u00e9 te parece?, sencillo, \u00bfno?. Spring ya tiene una implementaci\u00f3n por defecto de un CrudRepository, tan solo tenemos que crear una interface que extienda de la interface CrudRepository
pas\u00e1ndole como tipos la Entity
y el tipo de la Primary Key. Con eso Spring construye el resto y nos provee de los m\u00e9todos t\u00edpicos y necesarios para un CRUD.
Ahora vamos a utilizarla en \u00e9l Service
, pero hay un problema. \u00c9l Repository
devuelve un objeto tipo Category
y \u00e9l Service
y Controller
devuelven un objeto tipo CategoryDto
. Esto es porque en cada capa se debe con un \u00e1mbito de modelos diferente. Podr\u00edamos hacer que todo el back trabajara con Category
que son entidades de persistencia, pero no es lo correcto y nos podr\u00eda llevar a cometer errores, o modificar el objeto y que sin que nosotros lo orden\u00e1semos se persistiera ese cambio en BBDD.
El \u00e1mbito de trabajo de las capas con el que solemos trabajar y est\u00e1 m\u00e1s extendido es el siguiente:
Controller
esos datos json se transforman en un DTO. Al salir del Controller
hacia el cliente, esos DTOs se transforman en formato json. Estas conversiones son autom\u00e1ticas, las hace Springboot (en realidad las hace la librer\u00eda de jackson codehaus).Controller
ejecuta una llamada a un Service
, generalmente le pasa sus datos en DTO, y el Service
se encarga de transformar esto a una Entity
. A la inversa, cuando un Service
responde a un Controller
, \u00e9l responde con una Entity
y el Controller
ya se encargar\u00e1 de transformarlo a DTO.Repository
, siempre se trabaja de entrada y salida con objetos tipo Entity
Parece un l\u00edo, pero ya ver\u00e1s como es muy sencillo ahora que veremos el ejemplo. Una \u00faltima cosa, para hacer esas transformaciones, las podemos hacer a mano usando getters y setters o bien utilizar el objeto DozerBeanMapper
que hemos configurado al principio.
El c\u00f3digo deber\u00eda quedar as\u00ed:
CategoryServiceImpl.javaCategoryService.javaCategoryController.javapackage com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class CategoryServiceImpl implements CategoryService {\n\n@Autowired\nCategoryRepository categoryRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Category> findAll() {\n\nreturn (List<Category>) this.categoryRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, CategoryDto dto) {\n\nCategory category;\n\nif (id == null) {\ncategory = new Category();\n} else {\ncategory = this.categoryRepository.findById(id).orElse(null);\n}\n\ncategory.setName(dto.getName());\n\nthis.categoryRepository.save(category);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.categoryRepository.findById(id).orElse(null) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.categoryRepository.deleteById(id);\n}\n\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n * \n */\npublic interface CategoryService {\n\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link Category}\n */\nList<Category> findAll();\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, CategoryDto dto);\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Category\", description = \"API of Category\")\n@RequestMapping(value = \"/category\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class CategoryController {\n\n@Autowired\nCategoryService categoryService;\n\n@Autowired\nModelMapper mapper;\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link CategoryDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Categories\"\n)\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<CategoryDto> findAll() {\n\nList<Category> categories = this.categoryService.findAll();\n\nreturn categories.stream().map(e -> mapper.map(e, CategoryDto.class)).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Category\"\n)\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody CategoryDto dto) {\n\nthis.categoryService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Category\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\nthis.categoryService.delete(id);\n}\n\n}\n
El Service
no tiene nada raro, simplemente accede al Repository
(siempre anotado como @Autowired
) y recupera datos o los guarda. F\u00edjate en el caso especial del save que la \u00fanica diferencia es que en un caso tendr\u00e1 id != null
y por tanto internamente har\u00e1 un update, y en otro caso tendr\u00e1 id == null
y por tanto internamente har\u00e1 un save.
En cuanto a la interface, lo \u00fanico que cambiamos fue los objetos de respuesta, que ahora pasan a ser de tipo Category
. Los de entrada se mantienen como CategoryDto
.
Por \u00faltimo, en \u00e9l Controller
se puede ver como se utiliza el conversor de DozerBeanMapper
(siempre anotado como @Autowired
), que permite traducir una lista a un tipo concreto, o un objeto \u00fanico a un tipo concreto. La forma de hacer estas conversiones siempre es por nombre de propiedad. Las propiedades del objeto destino se deben llamar igual que las propiedades del objeto origen. En caso contrario se quedar\u00e1n a null.
Ojo con el mapeo
Ojo a esta \u00faltima frase, debe quedar meridianamente claro. La forma de mapear de un objeto origen a un objeto destino siempre es a trav\u00e9s del nombre. Los getters del origen deben ser iguales a los getters del destino. Si hay una letra diferente o unas may\u00fasculas o min\u00fasculas diferentes NO realizar\u00e1 el mapeo y se quedar\u00e1 la propiedad a null.
Para terminar, cuando queramos realizar un mapeo masivo de los diferentes registros, tenemos que itulizar la API Stream de Java, que nos proporciona una forma sencilla de realizar estas operativas, sobre colecciones de elementos, mediante el uso del m\u00e9todo intermedio map
y el reductor por defecto para listas. Te recomiendo echarle un ojo a la teor\u00eda de Introducci\u00f3n a API Java Streams.
BBDD
Si quires ver el contenido de la base de datos puedes acceder a un IDE web autopublicado por H2 en la ruta http://localhost:8080/h2-console
Los aspectos importantes de la capa Repository
son:
Service
, se debe utilizar el patr\u00f3n fachada, por lo que tendremos una Interface y posiblemente una implementaci\u00f3n.Repository
trabajan siempre con Entity
que no son m\u00e1s que mapeos de una tabla o de una vista que existe en BBDD.Repository
no contienen l\u00f3gica de negocio, ni transformaciones, simplemente acceder a datos, persisten o devuelven informaci\u00f3n.Repository
JAM\u00c1S deben llamar a otros Repository
ni Service
.Entity
sean lo m\u00e1s sencillas posible, sobre todo en cuanto a Primary Keys, se simplificar\u00e1 mucho el desarrollo.Por \u00faltimo y aunque no deber\u00eda ser lo \u00faltimo que se desarrolla sino todo lo contrario, deber\u00eda ser lo primero en desarrollar, tenemos la bater\u00eda de pruebas. Con fines did\u00e1cticos, he querido ense\u00f1arte un ciclo de desarrollo para ir recorriendo las diferentes capas de una aplicaci\u00f3n, pero en realidad, para realizar el desarrollo deber\u00eda aplicar TDD (Test Driven Development). Si quieres aprender las reglas b\u00e1sicas de como aplicar TDD al desarrollo diario, te recomiendo que leas el Anexo. TDD.
En este caso, y sin que sirva de precedente, ya tenemos implementados los m\u00e9todos de la aplicaci\u00f3n, y ahora vamos a testearlos. Existen muchas formas de testing en funci\u00f3n del componente o la capa que se quiera testear. En realidad, a medida que vayas programando ir\u00e1s aprendiendo todas ellas, de momento realizaremos dos tipos de test simples que prueben las casu\u00edsticas de los m\u00e9todos.
El enfoque que seguiremos en este tutorial ser\u00e1 realizar las pruebas mediante test unitarios y test de integraci\u00f3n.
Lo primero ser\u00e1 conocer que queremos probar y para ello nos vamos a hacer una lista:
Test unitarios:
Categor\u00eda
Categor\u00eda
Categor\u00eda
existenteCategor\u00eda
existenteTest de integraci\u00f3n:
Categor\u00eda
Categor\u00eda
Categor\u00eda
existenteCategor\u00eda
que no existeCategor\u00eda
existenteCategor\u00eda
que no existeSe podr\u00edan hacer muchos m\u00e1s tests, pero creo que con esos son suficientes para que entiendas como se comporta esta capa. Si te fijas, hay que probar tanto los resultados correctos como los resultados incorrectos. El usuario no siempre se va a comportar como nosotros pensamos.
Pues vamos a ello.
"},{"location":"develop/basic/springboot/#pruebas-de-listado","title":"Pruebas de listado","text":"Vamos a empezar haciendo una clase de test dentro de la carpeta src/test/java
. No queremos que los test formen parte del c\u00f3digo productivo de la aplicaci\u00f3n, por eso utilizamos esa ruta que queda fuera del scope general de la aplicaci\u00f3n (main).
Crearemos las clases (en la package category
):
com.ccsw.tutorial.category.CategoryTest
com.ccsw.tutorial.category.CategoryIT
package com.ccsw.tutorial.category;\n\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\n@ExtendWith(MockitoExtension.class)\npublic class CategoryTest {\n\n}\n
package com.ccsw.tutorial.category;\n\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.web.client.TestRestTemplate;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.test.annotation.DirtiesContext;\n\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)\n@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)\npublic class CategoryIT {\n\n@LocalServerPort\nprivate int port;\n\n@Autowired\nprivate TestRestTemplate restTemplate;\n\n}\n
Estas clases son sencillas y tan solo tienen anotaciones espec\u00edficas de Spring Boot para cada tipo de test:
@SpringBootTest
. Esta anotaci\u00f3n lo que hace es inicializar el contexto de Spring cada vez que se inician los test de jUnit. Aunque el contexto tarda unos segundos en arrancar, lo bueno que tiene es que solo se inicializa una vez y se lanzan todos los jUnits en bater\u00eda con el mismo contexto.@DirtiesContext
. Esta anotaci\u00f3n le indica a Spring que los test van a ser transaccionales, y por tanto cuando termine la ejecuci\u00f3n de cada uno de los test, autom\u00e1ticamente por la anotaci\u00f3n de arriba, Spring har\u00e1 un rearranque parcial del contexto y dejar\u00e1 el estado de la BBDD como estaba inicialmente.@ExtendWith
. Esta anotaci\u00f3n le indica a Spring que no debe inicializar el contexto, ya que se trata de test est\u00e1ticos que no lo requieren.Para las pruebas de integraci\u00f3n nos faltar\u00e1 configurar la aplicaci\u00f3n de test, al igual que hicimos con la aplicaci\u00f3n 'productiva'. Deberemos abrir el fichero src/test/resources/application.properties
y a\u00f1adir la configuraci\u00f3n de la BBDD. Para este tutorial vamos a utilizar la misma BBDD que la aplicaci\u00f3n productiva, pero de normal la aplicaci\u00f3n se conectar\u00e1 a una BBDD, generalmente f\u00edsica, mientras que los test jUnit se conectar\u00e1n a otra BBDD, generalmente en memoria.
#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\n
Con todo esto ya podemos crear nuestro primer test. Iremos a las clases CategoryIT
y CategoryTest
donde a\u00f1adiremos un m\u00e9todo p\u00fablico. Los test siempre tienen que ser m\u00e9todos p\u00fablicos que devuelvan el tipo void
.
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.*;\n@ExtendWith(MockitoExtension.class)\npublic class CategoryTest {\n\n@Mock\nprivate CategoryRepository categoryRepository;\n@InjectMocks\nprivate CategoryServiceImpl categoryService;\n@Test\npublic void findAllShouldReturnAllCategories() {\nList<Category> list = new ArrayList<>();\nlist.add(mock(Category.class));\nwhen(categoryRepository.findAll()).thenReturn(list);\nList<Category> categories = categoryService.findAll();\nassertNotNull(categories);\nassertEquals(1, categories.size());\n}\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.test.annotation.DirtiesContext;\nimport java.util.List;\nimport static org.junit.jupiter.api.Assertions.*;\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)\n@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)\npublic class CategoryIT {\n\npublic static final String LOCALHOST = \"http://localhost:\";\npublic static final String SERVICE_PATH = \"/category\";\n@LocalServerPort\nprivate int port;\n\n@Autowired\nprivate TestRestTemplate restTemplate;\n\nParameterizedTypeReference<List<CategoryDto>> responseType = new ParameterizedTypeReference<List<CategoryDto>>(){};\n@Test\npublic void findAllShouldReturnAllCategories() {\nResponseEntity<List<CategoryDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseType);\nassertNotNull(response);\nassertEquals(3, response.getBody().size());\n}\n}\n
Es muy importante marcar cada m\u00e9todo de prueba con la anotaci\u00f3n @Test
, en caso contrario no se ejecutar\u00e1. Lo que se debe hacer en cada m\u00e9todo que implementemos es probar una y solo una acci\u00f3n.
En los ejemplos anteriores (CategoryTest)
, por un lado hemos comprobado el m\u00e9todo findAll()
el cual por debajo invoca una llamada al repository de categor\u00eda, la cual hemos simulado con una respuesta ficticia limit\u00e1ndonos \u00fanicamente a la l\u00f3gica contenida en la operaci\u00f3n de negocio.
Mientras que por otro lado (CategoryIT)
, hemos probado la llamando al m\u00e9todo GET
del endpoint http://localhost:XXXX/category
comprobando que realmente nos devuelve 3 resultados, que son los que hay en BBDD inicialmente.
Muy importante: Nomenclatura de los tests
La nomenclatura de los m\u00e9todos de test debe sigue una estructura determinada. Hay muchas formas de nombrar a los m\u00e9todos, pero nosotros solemos utilizar la estructura 'should', para indicar lo que va a hacer. En el ejemplo anterior el m\u00e9todo 'findAll' debe devolver 'AllCategories'. De esta forma sabemos cu\u00e1l es la intenci\u00f3n del test, y si por cualquier motivo diera un fallo, sabemos que es lo que NO est\u00e1 funcionando de nuestra aplicaci\u00f3n.
Para comprobar que el test funciona, podemos darle bot\u00f3n derecho sobre la clase de CategoryIT
y pulsar en Run as
-> JUnit Test
. Si todo funciona correctamente, deber\u00e1 aparecer una pantalla de ejecuci\u00f3n y todos nuestros tests (en este caso solo uno) corriendo correctamente (en verde). El proceso es el mismo para la clase CategoryTest
.
Vamos con los siguientes test, los que probar\u00e1n una creaci\u00f3n de una nueva Categor\u00eda
. A\u00f1adimos el siguiente m\u00e9todo a la clase de test:
public static final String CATEGORY_NAME = \"CAT1\";\n\n@Test\npublic void saveNotExistsCategoryIdShouldInsert() {\n\nCategoryDto categoryDto = new CategoryDto();\ncategoryDto.setName(CATEGORY_NAME);\n\nArgumentCaptor<Category> category = ArgumentCaptor.forClass(Category.class);\n\ncategoryService.save(null, categoryDto);\n\nverify(categoryRepository).save(category.capture());\n\nassertEquals(CATEGORY_NAME, category.getValue().getName());\n}\n
public static final Long NEW_CATEGORY_ID = 4L;\npublic static final String NEW_CATEGORY_NAME = \"CAT4\";\n\n@Test\npublic void saveWithoutIdShouldCreateNewCategory() {\n\nCategoryDto dto = new CategoryDto();\ndto.setName(NEW_CATEGORY_NAME);\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nResponseEntity<List<CategoryDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseType);\nassertNotNull(response);\nassertEquals(4, response.getBody().size());\n\nCategoryDto categorySearch = response.getBody().stream().filter(item -> item.getId().equals(NEW_CATEGORY_ID)).findFirst().orElse(null);\nassertNotNull(categorySearch);\nassertEquals(NEW_CATEGORY_NAME, categorySearch.getName());\n}\n
En el primer caso, estamos construyendo un objeto CategoryDto
para darle un nombre a la Categor\u00eda
e invocamos a la operaci\u00f3n save
pasandole un ID a nulo. Para identificar que el funcionamiento es el esperado, capturamos la categor\u00eda que se proporciona al repository al intentar realizar la acci\u00f3n ficticia de guardado y comprobamos que el valor es el que se proporciona en la invocaci\u00f3n.
De forma similar en el segundo caso, estamos construyendo un objeto CategoryDto
para darle un nombre a la Categor\u00eda
e invocamos al m\u00e9todo PUT
sin a\u00f1adir en la ruta referencia al identificador. Seguidamente, recuperamos de nuevo la lista de categor\u00edas y en este caso deber\u00eda tener 4 resultados. Hacemos un filtrado buscando la nueva Categor\u00eda
que deber\u00eda tener un ID = 4 y deber\u00eda ser la que acabamos de crear.
Si ejecutamos, veremos que ambos test ahora aparecen en verde.
"},{"location":"develop/basic/springboot/#pruebas-de-modificacion","title":"Pruebas de modificaci\u00f3n","text":"Para este caso de prueba, vamos a realizar varios test, como hemos comentado anteriormente. Tenemos que probar que es lo que pasa cuando intentamos modificar un elemento que existe, pero tambi\u00e9n debemos probar que es lo que pasa cuando intentamos modificar un elemento que no existe.
Empezamos con el sencillo, un test que pruebe una modificaci\u00f3n existente.
CategoryTest.javaCategoryIT.javapublic static final Long EXISTS_CATEGORY_ID = 1L;\n\n@Test\npublic void saveExistsCategoryIdShouldUpdate() {\n\nCategoryDto categoryDto = new CategoryDto();\ncategoryDto.setName(CATEGORY_NAME);\n\nCategory category = mock(Category.class);\nwhen(categoryRepository.findById(EXISTS_CATEGORY_ID)).thenReturn(Optional.of(category));\n\ncategoryService.save(EXISTS_CATEGORY_ID, categoryDto);\n\nverify(categoryRepository).save(category);\n}\n
public static final Long MODIFY_CATEGORY_ID = 3L;\n\n@Test\npublic void modifyWithExistIdShouldModifyCategory() {\n\nCategoryDto dto = new CategoryDto();\ndto.setName(NEW_CATEGORY_NAME);\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + MODIFY_CATEGORY_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nResponseEntity<List<CategoryDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseType);\nassertNotNull(response);\nassertEquals(3, response.getBody().size());\n\nCategoryDto categorySearch = response.getBody().stream().filter(item -> item.getId().equals(MODIFY_CATEGORY_ID)).findFirst().orElse(null);\nassertNotNull(categorySearch);\nassertEquals(NEW_CATEGORY_NAME, categorySearch.getName());\n}\n
En el caso de los test unitarios, comprobamos la l\u00f3gica de la modificaci\u00f3n simulando que el repository nos devuelve una categor\u00eda que modificar y verificado que se invoca el guardado sobre la misma.
En el caso de los test de integraci\u00f3n, la misma filosof\u00eda que en el test anterior, pero esta vez modificamos la Categor\u00eda
de ID = 3. Luego la filtramos y vemos que realmente se ha modificado. Adem\u00e1s comprobamos que el listado de todas los registros sigue siendo 3 y no se ha creado un nuevo registro.
En el siguiente test, probaremos un resultado err\u00f3neo.
CategoryIT.java@Test\npublic void modifyWithNotExistIdShouldInternalError() {\n\nCategoryDto dto = new CategoryDto();\ndto.setName(NEW_CATEGORY_NAME);\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + NEW_CATEGORY_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n
Intentamos modificar el ID = 4, que no deber\u00eda existir en BBDD y por tanto lo que se espera en el test es que lance un 500 Internal Server Error
al llamar al m\u00e9todo PUT
.
Ya por \u00faltimo implementamos las pruebas de borrado.
CategoryTest.javaCategoryIT.java@Test\npublic void deleteExistsCategoryIdShouldDelete() throws Exception {\n\nCategory category = mock(Category.class);\nwhen(categoryRepository.findById(EXISTS_CATEGORY_ID)).thenReturn(Optional.of(category));\n\ncategoryService.delete(EXISTS_CATEGORY_ID);\n\nverify(categoryRepository).deleteById(EXISTS_CATEGORY_ID);\n}\n
public static final Long DELETE_CATEGORY_ID = 2L;\n\n@Test\npublic void deleteWithExistsIdShouldDeleteCategory() {\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + DELETE_CATEGORY_ID, HttpMethod.DELETE, null, Void.class);\n\nResponseEntity<List<CategoryDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseType);\nassertNotNull(response);\nassertEquals(2, response.getBody().size());\n}\n\n@Test\npublic void deleteWithNotExistsIdShouldInternalError() {\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + NEW_CATEGORY_ID, HttpMethod.DELETE, null, Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n
En cuanto al test unitario, se invoca a la operaci\u00f3n delete
y se verifica que la operaci\u00f3n requerida del repository es invocado con el atributo correcto.
En lo relativo a las pruebas de integraci\u00f3n, en el primer test, se invoca el m\u00e9todo DELETE
y posteriormente se comprueba que el listado tiene un tama\u00f1o de 2 (uno menos que el original). Mientras que en el segundo test, se comprueba que con ID no v\u00e1lido, devuelve un 500 Internal Server Error
.
Con esto tendr\u00edamos m\u00e1s o menos probados los casos b\u00e1sicos de nuestra aplicaci\u00f3n y tendr\u00edamos una peque\u00f1a red de seguridad que nos ayudar\u00eda por si a futuro necesitamos hacer alg\u00fan cambio o evolutivo.
"},{"location":"develop/basic/springboot/#que-hemos-aprendido","title":"\u00bfQ\u00fae hemos aprendido?","text":"Resumiendo un poco los pasos que hemos seguido:
com.ccsw.tutorial.category
para aglutinar todas las clases.Controller
\u2192 Maneja las peticiones de entrada del cliente y realiza transformaciones. No ejecuta directamente l\u00f3gica de negocio, para eso utiliza llamadas a la siguiente capa.Service
\u2192 Ejecuta la l\u00f3gica de negocio en sus m\u00e9todos o llamando a otros objetos de la misma capa. No ejecuta directamente accesos a datos, para eso utiliza la siguiente capa.Repository
\u2192 Realiza los accesos a datos de lectura y escritura. NUNCA debe llamar a otros objetos de la misma capa ni de capas anteriores.Json
\u2192 Los datos que vienen y van del cliente al Controller
.DTO
\u2192 Los datos se mueven dentro del Controller
y sirven para invocar llamadas. Tambi\u00e9n son los datos que devuelve un Controller
.Entity
\u2192 Los datos que sirven para persistir y leer datos de una BBDD y que NUNCA deber\u00edan ir m\u00e1s all\u00e1 del Controller
.Una parte muy importante del desarrollo es tener la capacidad de depurar nuestro c\u00f3digo, en este apartado vamos a explicar como se realiza debug
en Backend.
Esta parte se realiza con las herramientas incluidas dentro de nuestro IDE favorito, en este caso vamos a utilizar el Eclipse.
Lo primero que debemos hacer es arrancar la aplicaci\u00f3n en modo Debug
:
Arrancada la aplicaci\u00f3n en este modo, vamos a depurar la operaci\u00f3n de crear categor\u00eda.
Para ello vamos a abrir nuestro fichero donde tenemos la implementaci\u00f3n del servicio de creaci\u00f3n de la capa de la l\u00f3gica de negocio CategoryServiceImpl
.
Dentro del fichero ya podemos a\u00f1adir puntos de ruptura (breakpoint), en nuestro caso queremos comprobar que el nombre introducido se recibe correctamente.
Colocamos el breakpoint en la primera l\u00ednea del m\u00e9todo (click sobre el n\u00famero de la l\u00ednea) y desde la interfaz/postman creamos una nueva categor\u00eda.
Hecho esto, podemos observar que a nivel de interfaz/postman, la petici\u00f3n se queda esperando y el IDE pasa modo Debug
(la primera vez nos preguntar\u00e1 si queremos hacerlo, le decimos que si):
El IDE nos lleva al punto exacto donde hemos a\u00f1adido el breakpoint y se para en este punto ofreci\u00e9ndonos la posibilidad de explorar el contenido de las variables del c\u00f3digo:
Aqu\u00ed podemos comprobar que efectivamente el atributo name
de la variable dto
tiene el valor que hemos introducido por pantalla/postman.
Para continuar con la ejecuci\u00f3n basta con darle al bot\u00f3n de play
de la barra de herramientas superior.
Nota: para volver al modo Java
de Eclipse, presionamos el bot\u00f3n que se sit\u00faa a la izquierda del modo Debug
en el que ha entrado el IDE autom\u00e1ticamente.
Ahora que ya tenemos listo el proyecto frontend de VUE, ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/vuejs/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 que VUE tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. En la propia web de documentaci\u00f3n de VUE puedes buscar casi cualquier ejemplo que necesites.
Vamos a realizar una pantalla lo m\u00e1s parecida a la siguiente captura para empezar:
Lo primero que vamos a hacer es crear los componentes de las tres pr\u00f3ximas pantallas mediante el siguiente comando:
npx quasar new page CatalogPage CategoriesPage AuthorsPage\n
Y ahora vamos a crear las rutas que nos van a hacer llegar hasta ellos:
import { RouteRecordRaw } from 'vue-router';\nimport MainLayout from 'layouts/MainLayout.vue';\nimport IndexPage from 'pages/IndexPage.vue';\nimport CatalogPage from 'pages/CatalogPage.vue';\nimport CategoriesPage from 'pages/CategoriesPage.vue';\nimport AuthorsPage from 'pages/AuthorsPage.vue';\n\nconst routes: RouteRecordRaw[] = [\n {\n path: '/',\n component: MainLayout,\n children: [\n { path: '', component: IndexPage },\n { path: 'games', component: CatalogPage },\n { path: 'categories', component: CategoriesPage },\n { path: 'authors', component: AuthorsPage },\n ],\n },\n\n // Always leave this as last one,\n // but you can also remove it\n // {\n // path: '/:catchAll(.*)*',\n // component: () => import('pages/ErrorNotFound.vue'),\n // },\n];\n\nexport default routes;\n
Una vez realizado esto, vamos a ponerle dentro de cada uno de los archivos creados el nombre del archivo donde est\u00e1 el comentario para saber que lleva al lugar correcto:
<template>\n <q-page padding> CatalogPage </q-page>\n</template>\n\n<script lang=\"ts\">\nimport { defineComponent } from 'vue';\nexport default defineComponent({\n name: 'CatalogPage',\n});\n</script>\n
Por \u00faltimo, modificaremos el men\u00fa lateral para que lleve las opciones correctas y nos enlace a dichas pantallas (para esto, iremos al archivo MainLayout.vue
):
const linksList = [\n {\n title: 'Cat\u00e1logo',\n icon: 'list',\n link: 'games',\n },\n {\n title: 'Categor\u00edas',\n icon: 'dashboard',\n link: 'categories',\n },\n {\n title: 'Autores',\n icon: 'face',\n link: 'authors',\n },\n];\n
En caso de que no funcione correctamente, deber\u00eda solucionarse cambiando en el archivo EssentialLink.vue
el prop \u201chref\u201d
por el prop \u201cto\u201d
:
<template>\n <q-item clickable tag=\"a\" :to=\"link\">\n <q-item-section v-if=\"icon\" avatar>\n <q-icon :name=\"icon\" />\n </q-item-section>\n\n <q-item-section>\n <q-item-label>{{ title }}</q-item-label>\n </q-item-section>\n </q-item>\n</template>\n
"},{"location":"develop/basic/vuejs/#codigo-de-la-pantalla","title":"C\u00f3digo de la pantalla","text":"Para empezar, usaremos un componente de tabla de la librer\u00eda de Quasar. Este componente nos ayudar\u00e1 a mostrar los datos de los juegos en un futuro.
<template>\n <q-page padding>\n <q-table\n :rows=\"catalogData\"\n :columns=\"columns\"\n title=\"Cat\u00e1logo\"\n row-key=\"id\"\n />\n </q-page>\n</template>\n
As\u00ed es como deber\u00eda quedar nuestro componente de tabla con todas las supuestas variables que m\u00e1s adelante le settearemos:
<template>\n <q-page padding>\n <q-table\n hide-bottom\n :rows=\"catalogData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Cat\u00e1logo\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n />\n </q-page>\n</template>\n
Y as\u00ed es como vamos a necesitar que est\u00e9, ya que no va a tener paginado. \u00bfPor qu\u00e9?
hide-bottom
\u2192 hace que no se muestre la zona baja de la tabla que es donde est\u00e1 el paginado.v-model:pagination
\u2192 har\u00e1 que vengan los datos que vengan, se muestren todos de la misma manera.class
\u2192 esta clase har\u00e1 que, si haciendo scroll pierdes los header, estos te acompa\u00f1en y siempre sepas qu\u00e9 columna es la que est\u00e1s mirando.no-data-label
\u2192 un mensaje por si alg\u00fan d\u00eda no hay datos o tiene un fallo el back.Todo esto no hace falta aprend\u00e9rselo, est\u00e1 en la documentaci\u00f3n de este componente. Pero vamos a ir usando algunos props como estos para configurar correctamente la tabla.
"},{"location":"develop/basic/vuejs/#mockeando-datos","title":"Mockeando datos","text":"Y esto va a hacer que podamos mostrar datos:
<script lang=\"ts\">\nimport { defineComponent } from 'vue';\n\nconst columns = [\n { name: 'id', align: 'left', label: 'ID', field: 'id', sortable: true },\n {\n name: 'name',\n align: 'left',\n label: 'Nombre',\n field: 'name',\n sortable: true,\n },\n { name: 'options', align: 'left', label: 'Options', field: 'options' },\n];\n\nconst data = [\n { id: 1, name: 'Dados' },\n { id: 2, name: 'Fichas' },\n { id: 3, name: 'Cartas' },\n { id: 4, name: 'Rol' },\n { id: 5, name: 'Tableros' },\n { id: 6, name: 'Tem\u00e1ticos' },\n { id: 7, name: 'Europeos' },\n { id: 8, name: 'Guerra' },\n { id: 9, name: 'Abstractos' },\n];\n\nexport default defineComponent({\n name: 'CatalogPage',\n\n setup() {\n const catalogData = ref(data);\n\n return {\n catalogData,\n columns: columns,\n pagination: {\n page: 1,\n rowsPerPage: 0, // 0 means all rows\n },\n };\n },\n});\n</script>\n
Lo que estamos haciendo es settear unos datos, los nombres y estilos de las columnas, y los ajustes de la paginaci\u00f3n."},{"location":"develop/basic/vuejs/#anadir-editar-y-eliminar-filas","title":"A\u00f1adir, editar y eliminar filas","text":"El c\u00f3digo final para esto, que m\u00e1s adelante explicaremos, es el siguiente:
<template>\n <q-page padding>\n <q-table\n hide-bottom\n :rows=\"catalogData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Cat\u00e1logo\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n >\n <template v-slot:top>\n <q-btn flat round color=\"primary\" icon=\"add\" @click=\"showAdd = true\" />\n </template>\n <template v-slot:body=\"props\">\n <q-tr :props=\"props\">\n <q-td key=\"id\" :props=\"props\">{{ props.row.id }}</q-td>\n <q-td key=\"name\" :props=\"props\">\n {{ props.row.name }}\n <q-popup-edit\n v-model=\"props.row.name\"\n title=\"Cambiar nombre\"\n v-slot=\"scope\"\n >\n <q-input\n v-model=\"scope.value\"\n dense\n autofocus\n counter\n @keyup.enter=\"scope.set\"\n >\n <template v-slot:append>\n <q-icon name=\"edit\" />\n </template>\n </q-input>\n </q-popup-edit>\n </q-td>\n <q-td key=\"options\" :props=\"props\">\n <q-btn\n flat\n round\n color=\"negative\"\n icon=\"delete\"\n @click=\"showDeleteDialog(props.row)\"\n />\n </q-td>\n </q-tr>\n </template>\n </q-table>\n <q-dialog v-model=\"showDelete\" persistent>\n <q-card>\n <q-card-section class=\"row items-center\">\n <q-icon\n name=\"delete\"\n size=\"sm\"\n color=\"negative\"\n @click=\"showDelete = true\"\n />\n <span class=\"q-ml-sm\">\n \u00bfEst\u00e1s seguro de que quieres borrar este elemento?\n </span>\n </q-card-section>\n\n <q-card-actions align=\"right\">\n <q-btn flat label=\"Cancelar\" color=\"primary\" v-close-popup />\n <q-btn\n flat\n label=\"Confirmar\"\n color=\"primary\"\n v-close-popup\n @click=\"deleteGame\"\n />\n </q-card-actions>\n </q-card>\n </q-dialog>\n <q-dialog v-model=\"showAdd\">\n <q-card style=\"min-width: 350px\">\n <q-card-section>\n <div class=\"text-h6\">Nombre del juego</div>\n </q-card-section>\n\n <q-card-section class=\"q-pt-none\">\n <q-input dense v-model=\"nameToAdd\" autofocus @keyup.enter=\"addGame\" />\n </q-card-section>\n\n <q-card-actions align=\"right\" class=\"text-primary\">\n <q-btn flat label=\"Cancelar\" v-close-popup />\n <q-btn flat label=\"A\u00f1adir juego\" v-close-popup @click=\"addGame\" />\n </q-card-actions>\n </q-card>\n </q-dialog>\n </q-page>\n</template>\n\n<script lang=\"ts\">\nimport { ref } from 'vue';\nimport { defineComponent } from 'vue';\n\nconst columns = [\n { name: 'id', align: 'left', label: 'ID', field: 'id', sortable: true },\n {\n name: 'name',\n align: 'left',\n label: 'Nombre',\n field: 'name',\n sortable: true,\n },\n { name: 'options', align: 'left', label: 'Options', field: 'options' },\n];\n\nconst data = [\n { id: 1, name: 'Dados' },\n { id: 2, name: 'Fichas' },\n { id: 3, name: 'Cartas' },\n { id: 4, name: 'Rol' },\n { id: 5, name: 'Tableros' },\n { id: 6, name: 'Tem\u00e1ticos' },\n { id: 7, name: 'Europeos' },\n { id: 8, name: 'Guerra' },\n { id: 9, name: 'Abstractos' },\n];\n\nexport default defineComponent({\n name: 'CatalogPage',\n\n setup() {\n const catalogData = ref(data);\n const showDelete = ref(false);\n const showAdd = ref(false);\n const nameToAdd = ref('');\n const selectedRow = ref({});\n\n const deleteGame = () => {\n catalogData.value.splice(\n catalogData.value.findIndex((i) => i.id === selectedRow.value.id),\n 1\n );\n showDelete.value = false;\n };\n\n const showDeleteDialog = (item: any) => {\n selectedRow.value = item;\n showDelete.value = true;\n };\n\n const addGame = () => {\n catalogData.value.push({\n id: Math.max(...catalogData.value.map((o) => o.id)) + 1,\n name: nameToAdd.value,\n });\n nameToAdd.value = '';\n showAdd.value = false;\n };\n\n return {\n catalogData,\n columns: columns,\n pagination: {\n page: 1,\n rowsPerPage: 0, // 0 means all rows\n },\n showDelete,\n showAdd,\n nameToAdd,\n showDeleteDialog,\n deleteGame,\n addGame,\n };\n },\n});\n</script>\n
"},{"location":"develop/basic/vuejs/#anadir-fila","title":"A\u00f1adir fila","text":"Para esto hemos necesitado el primer template dentro del componente tabla para mostrar un bot\u00f3n que har\u00e1 que se muestre un dialog para introducir el nombre del juego que es el \u00faltimo q-dialog mostrado en el componente. Tanto al pulsar en el bot\u00f3n como al pulsar Enter se ejecutar\u00e1 la funci\u00f3n para a\u00f1adirlo llamada addGame, que se encarga de a\u00f1adirlo poni\u00e9ndole un id superior a cualquiera de los ya creados, el nombre seleccionado almacenado en la variable nameToAdd y de dejar de mostrar el dialog una vez realizado el proceso.
"},{"location":"develop/basic/vuejs/#editar-fila","title":"Editar fila","text":"Para esto hemos necesitado el segundo template de dentro del componente (a excepci\u00f3n del \u00faltimo q-td). Este hace que cuando sea la columna id simplemente muestre su valor, pero en cambio cuando sea la del nombre, en caso de que se pulse sobre esa casilla se muestre un dialog con un campo de texto con el valor de la casilla pulsada.
"},{"location":"develop/basic/vuejs/#borrar-fila","title":"Borrar fila","text":"Por \u00faltimo, para el borrado hemos necesitado el q-td con la key de options para mostrar un bot\u00f3n para ejecutar la funci\u00f3n showDeleteDialog pas\u00e1ndole el item completo de la fila seleccionada, este hace que se muestre el dialog y se almacene el item seleccionado y por \u00faltimo el dialog se encarga de realizar la pregunta de confirmaci\u00f3n para su posterior borrado. En caso de confirmarlo, la funci\u00f3n deleteGame busca la posici\u00f3n del item seleccionado y lo borra. Una vez hecho eso, limpia el valor de fila seleccionada y deja de mostrar el dialog.
"},{"location":"develop/basic/vuejs/#conexion-con-backend","title":"Conexi\u00f3n con backend","text":"Antes de nada, para poder realizar peticiones vamos a tener que instalar: @vueuse/core
.
Vamos a proceder a modificar lo m\u00ednimo e indispensable para que los datos mostrados no sean los mockeados y vengan del back mediante esta petici\u00f3n:
const { data } = useFetch('http://localhost:8080/game').get().json();\nwhenever(data, () => (catalogData.value = data.value));\n
Tambi\u00e9n tendremos que modificar los campos a mostrar, ya que ya no es name, si no title el nombre del juego. Y tambi\u00e9n habr\u00e1 que mostrar la edad, la categor\u00eda y el autor.
"},{"location":"develop/basic/vuejs/#edicion-de-una-fila","title":"Edici\u00f3n de una fila","text":"Solo modificaremos los campos referidos al juego (de momento) para que sea lo m\u00e1s sencillo posible, es decir, solo se modificar\u00e1 el t\u00edtulo y la edad tal y como lo hab\u00edamos hecho antes con el q-popup-edit
.
Ya que no tenemos en el back de Node realizado el back necesario para poder borrar una fila, terminaremos con el a\u00f1adido de una nueva fila.
Para esto, tendremos que modificar la funci\u00f3n para a\u00f1adir, adem\u00e1s de eliminar la variable nameToAdd
y modificar el dialog. As\u00ed deber\u00eda quedar la funci\u00f3n:
const addGame = async () => {\n const response = await useFetch('http://localhost:8080/game', {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(gameToAdd.value),\n })\n .put()\n .json();\n\n getGames();\n gameToAdd.value = newGame;\n};\n
Y as\u00ed el dialog:
<q-dialog v-model=\"showAdd\">\n <q-card style=\"width: 300px\" class=\"q-px-sm q-pb-md\">\n <q-card-section>\n <div class=\"text-h6\">Nuevo juego</div>\n </q-card-section>\n\n <q-item-label header>T\u00edtulo</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"sports_esports\" />\n </q-item-section>\n <q-item-section>\n <q-input dense v-model=\"gameToAdd.title\" autofocus />\n </q-item-section>\n </q-item>\n\n <q-item-label header>Edad</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"cake\" />\n </q-item-section>\n <q-item-section>\n <q-slider\n color=\"teal\"\n v-model=\"gameToAdd.age\"\n :min=\"0\"\n :max=\"100\"\n :step=\"1\"\n label\n label-always\n />\n </q-item-section>\n </q-item>\n\n <q-item-label header>Categor\u00eda</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"category\" />\n </q-item-section>\n <q-item-section>\n <q-select\n name=\"category\"\n v-model=\"gameToAdd.category.id\"\n :options=\"categories\"\n filled\n clearable\n emit-value\n map-options\n option-disable=\"inactive\"\n option-value=\"id\"\n option-label=\"name\"\n color=\"primary\"\n label=\"Category\"\n />\n </q-item-section>\n </q-item>\n\n <q-item-label header>Autor</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"face\" />\n </q-item-section>\n <q-item-section>\n <q-select\n name=\"author\"\n v-model=\"gameToAdd.author.id\"\n :options=\"authors\"\n filled\n clearable\n emit-value\n map-options\n option-disable=\"inactive\"\n option-value=\"id\"\n option-label=\"name\"\n color=\"primary\"\n label=\"Author\"\n />\n </q-item-section>\n </q-item>\n\n <q-card-actions align=\"right\" class=\"text-primary\">\n <q-btn flat label=\"Cancelar\" v-close-popup />\n <q-btn flat label=\"A\u00f1adir juego\" v-close-popup @click=\"addGame\" />\n </q-card-actions>\n </q-card>\n </q-dialog>\n
"},{"location":"develop/basic/vuejs/#ultimo-paso","title":"\u00daltimo paso","text":"Este resultado vamos a copiarlo y pegarlo en las pantallas de Categor\u00eda y Autor para que tengamos exactamente el mismo formato cambiando todo donde diga \u201cjuego\u201d o \u201cgame\u201d por su traducci\u00f3n a \u201ccategor\u00eda\u201d o \u201cautor\u201d.
"},{"location":"develop/basic/vuejs/#ejercicio","title":"Ejercicio","text":"Al realizar el cambio descrito anteriormente podremos ver que no todo funciona, ya que el objeto que se env\u00eda para modificar no ser\u00eda correcto adem\u00e1s de que las tablas de Categor\u00eda y Autor s\u00ed que tienen una funci\u00f3n para poder borrar esas filas.
El ejercicio se va a realizar en la pantalla de Categor\u00eda. Consta en, despu\u00e9s de haber realizado todos los cambios, hacer que a\u00f1ada, edite y borre las filas seg\u00fan sea necesario.
El c\u00f3digo resultante deber\u00eda ser algo parecido al siguiente c\u00f3digo:
<template>\n <q-page padding>\n <q-table\n hide-bottom\n :rows=\"categoriesData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Cat\u00e1logo\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n >\n <template v-slot:top>\n <q-btn flat round color=\"primary\" icon=\"add\" @click=\"showAdd = true\" />\n </template>\n <template v-slot:body=\"props\">\n <q-tr :props=\"props\">\n <q-td key=\"id\" :props=\"props\">{{ props.row.id }}</q-td>\n <q-td key=\"name\" :props=\"props\">\n {{ props.row.name }}\n <q-popup-edit\n v-model=\"props.row.name\"\n title=\"Cambiar nombre\"\n v-slot=\"scope\"\n >\n <q-input\n v-model=\"scope.value\"\n dense\n autofocus\n counter\n @keyup.enter=\"editRow(props, scope, 'name')\"\n >\n <template v-slot:append>\n <q-icon name=\"edit\" />\n </template>\n </q-input>\n </q-popup-edit>\n </q-td>\n <q-td key=\"options\" :props=\"props\">\n <q-btn\n flat\n round\n color=\"negative\"\n icon=\"delete\"\n @click=\"showDeleteDialog(props.row)\"\n />\n </q-td>\n </q-tr>\n </template>\n </q-table>\n <q-dialog v-model=\"showDelete\" persistent>\n <q-card>\n <q-card-section class=\"row items-center\">\n <q-icon\n name=\"delete\"\n size=\"sm\"\n color=\"negative\"\n @click=\"showDelete = true\"\n />\n <span class=\"q-ml-sm\">\n \u00bfEst\u00e1s seguro de que quieres borrar este elemento?\n </span>\n </q-card-section>\n\n <q-card-actions align=\"right\">\n <q-btn flat label=\"Cancelar\" color=\"primary\" v-close-popup />\n <q-btn\n flat\n label=\"Confirmar\"\n color=\"primary\"\n v-close-popup\n @click=\"deleteCategory\"\n />\n </q-card-actions>\n </q-card>\n </q-dialog>\n <q-dialog v-model=\"showAdd\">\n <q-card style=\"width: 300px\" class=\"q-px-sm q-pb-md\">\n <q-card-section>\n <div class=\"text-h6\">Nueva categor\u00eda</div>\n </q-card-section>\n\n <q-item-label header>Nombre</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"category\" />\n </q-item-section>\n <q-item-section>\n <q-input\n dense\n v-model=\"categoryToAdd.name\"\n autofocus\n @keyup.enter=\"addCategory\"\n />\n </q-item-section>\n </q-item>\n <q-card-actions align=\"right\" class=\"text-primary\">\n <q-btn flat label=\"Cancelar\" v-close-popup />\n <q-btn\n flat\n label=\"A\u00f1adir categor\u00eda\"\n v-close-popup\n @click=\"addCategory\"\n />\n </q-card-actions>\n </q-card>\n </q-dialog>\n </q-page>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useFetch, whenever } from '@vueuse/core';\n\nconst columns = [\n { name: 'id', align: 'left', label: 'ID', field: 'id', sortable: true },\n {\n name: 'name',\n align: 'left',\n label: 'Nombre',\n field: 'name',\n sortable: true,\n },\n { name: 'options', align: 'left', label: 'Options', field: 'options' },\n];\nconst pagination = {\n page: 1,\n rowsPerPage: 0,\n};\nconst newCategory = {\n name: '',\n id: '',\n};\n\nconst categoriesData = ref([]);\nconst showDelete = ref(false);\nconst showAdd = ref(false);\nconst selectedRow = ref({});\nconst categoryToAdd = ref({ ...newCategory });\n\nconst getCategories = () => {\n const { data } = useFetch('http://localhost:8080/category').get().json();\n whenever(data, () => (categoriesData.value = data.value));\n};\ngetCategories();\n\nconst showDeleteDialog = (item: any) => {\n selectedRow.value = item;\n showDelete.value = true;\n};\n\nconst addCategory = async () => {\n await useFetch('http://localhost:8080/category', {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(categoryToAdd.value),\n })\n .put()\n .json();\n\n getCategories();\n categoryToAdd.value = newCategory;\n showAdd.value = false;\n};\n\nconst editRow = (props: any, scope: any, field: any) => {\n const row = {\n name: props.row.name,\n };\n row[field] = scope.value;\n scope.set();\n editCategory(props.row.id, row);\n};\n\nconst editCategory = async (id: string, reqBody: any) => {\n await useFetch(`http://localhost:8080/category/${id}`, {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(reqBody),\n })\n .put()\n .json();\n\n getCategories();\n};\n\nconst deleteCategory = async () => {\n await useFetch(`http://localhost:8080/category/${selectedRow.value.id}`, {\n method: 'DELETE',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n })\n .delete()\n .json();\n\n getCategories();\n};\n</script>\n
"},{"location":"develop/basic/vuejs/#depuracion","title":"Depuraci\u00f3n","text":"Una parte muy importante del desarrollo es tener la capacidad de depurar nuestro c\u00f3digo, en este apartado vamos a explicar como se realiza debug
en Front.
Esta parte se puede realizar con nuestro navegador favorito, en este caso vamos a utilizar Chrome.
El primer paso es abrir las herramientas del desarrollador del navegador presionando F12
.
En esta herramienta tenemos varias partes importantes:
Identificados los elementos importantes, vamos a depurar la operaci\u00f3n de crear categor\u00eda.
Para ello nos dirigimos a la pesta\u00f1a de Source
, en el \u00e1rbol de carpetas nos dirigimos a la ruta donde est\u00e1 localizado el c\u00f3digo de nuestra aplicaci\u00f3n webpack://src/app
.
Dentro de esta carpeta est\u00e9 localizado todo el c\u00f3digo fuente de la aplicaci\u00f3n, en nuestro caso vamos a localizar componente category-edit.component
que crea una nueva categor\u00eda.
Dentro del fichero ya podemos a\u00f1adir puntos de ruptura (breakpoint), en nuestro caso queremos comprobar que el nombre introducido se captura bien y se env\u00eda al service correctamente.
Colocamos el breakpoint en la l\u00ednea de invocaci\u00f3n del service (click sobre el n\u00famero de la l\u00ednea) y desde la interfaz creamos una nueva categor\u00eda.
Hecho esto, podemos observar que a nivel de interfaz, la aplicaci\u00f3n se detiene y aparece un panel de manejo de los puntos de interrupci\u00f3n:
En cuanto a la herramienta del desarrollador nos lleva al punto exacto donde hemos a\u00f1adido el breakpoint y se para en este punto ofreci\u00e9ndonos la posibilidad de explorar el contenido de las variables del c\u00f3digo:
Aqu\u00ed podemos comprobar que efectivamente la variable category
tiene el valor que hemos introducido por pantalla y se propaga correctamente hacia el service.
Para continuar con la ejecuci\u00f3n basta con darle al bot\u00f3n de play
del panel de manejo de interrupci\u00f3n o al que aparece dentro de la herramienta de desarrollo (parte superior derecha).
Por \u00faltimo, vamos a revisar que la petici\u00f3n REST se ha realizado correctamente al backend, para ello nos dirigimos a la pesta\u00f1a Network
y comprobamos las peticiones realizadas:
Aqu\u00ed podemos observar el registro de todas las peticiones y haciendo click sobre una de ellas, obtenemos el detalle de esta.
Ahora que ya tenemos listo el proyecto frontend de VUE, ya podemos empezar a codificar la soluci\u00f3n.
"},{"location":"develop/basic/vuejsold/#primeros-pasos","title":"Primeros pasos","text":"Antes de empezar
Quiero hacer hincapi\u00e9 que VUE tiene una documentaci\u00f3n muy extensa y completa, as\u00ed que te recomiendo que hagas uso de ella cuando tengas cualquier duda. En la propia web de documentaci\u00f3n de VUE puedes buscar casi cualquier ejemplo que necesites.
Si abrimos el proyecto con el IDE que tengamos (Visual Studio Code en el caso del tutorial) podemos ver que en la carpeta src/
existen unos ficheros ya creados por defecto. Estos ficheros son:
App.vue
\u2192 contiene el c\u00f3digo inicial del proyecto.main.ts
\u2192 es el punto de entrada a la aplicaci\u00f3n.Lo primero que vamos a hacer es instalar SASS para poder trabajar con este preprocesador CSS, para ello tendremos que irnos a la terminal, en la misma carpeta donde tenemos el proyecto y ejecutar el siguiente comando:
npm install -D sass\n
Con esto ya lo tendremos instalado y para usarlo es tan f\u00e1cil como poner la etiqueta style de esta manera:
<style lang=\"scss\"></style> <---> con Sass activado\n<style></style> <---> sin Sass, css normal\n
En los estilos tambi\u00e9n veremos la propiedad scoped en VUE, el atributo scoped se utiliza para limitar el \u00e1mbito de los estilos de un componente a los elementos del propio componente y no a los elementos hijos o padres, lo que ayuda a evitar conflictos de estilo entre los diferentes componentes de una aplicaci\u00f3n.
Esto significa que los estilos definidos en una etiqueta <style scoped>
solo se aplicar\u00e1n a los elementos dentro del componente actual, y no se propagar\u00e1n a otros componentes en la jerarqu\u00eda del DOM. De esta manera, se puede evitar que los estilos de un componente afecten a otros componentes en la aplicaci\u00f3n.
<style scoped></style> <---> Estos estilos solo afectar\u00e1n al componente donde se aplican\n<style></style> <---> Estos estilos son generales y afectan a toda la aplicaci\u00f3n.\n
Con estas cositas sobre los estilos en cabeza vamos lo primero a limpiar la aplicaci\u00f3n para poder empezar a trabajar desde cero.
assets
y borraremos todos los archivos excepto base.css
.components
y borraremos todos los archivos dejando solo la carpeta que usaremos m\u00e1s adelante.router
la dejaremos tal cual esta, sin tocar nada.views
y borraremos todos los archivos dejando solo la carpeta que usaremos m\u00e1s adelante.Con esto tenemos nuestra estructura preparada y quedar\u00eda tal que asi:
Vamos a a\u00f1adir unas l\u00edneas al tsconfig.json
para que el typescript deje de marcarnos lo como error, lo dejaremos asi:
{\n\"extends\": \"@vue/tsconfig/tsconfig.web.json\",\n\"include\": [\"env.d.ts\", \"src/**/*\", \"src/**/*.vue\"],\n\"compilerOptions\": {\n\"preserveValueImports\": false,\n\"importsNotUsedAsValues\": \"remove\",\n\"verbatimModuleSyntax\": true,\n\"baseUrl\": \".\",\n\"paths\": {\n\"@/*\": [\"./src/*\"]\n}\n},\n\n\"references\": [\n{\n\"path\": \"./tsconfig.node.json\"\n}\n]\n}\n
Para que la aplicaci\u00f3n funcione de nuevo y poder empezar a trabajar faltar\u00eda hacer un par de cositas que os explico:
base.css
no hace falta cambiar nada para que funcione, pero tenemos muchas cosas que seguramente no vamos a usar, este archivo lo conservamos solamente para trabajar en variables css todo el tema de los colores de nuestra web o algunas otras cositas como el ancho del menu o del header, etc\u2026 Lo primero vamos a eliminar todas las variables CSS y crearnos las nuestras propias con nuestro color primario y secundario tanto para botones y dem\u00e1s como para texto y tambi\u00e9n para el background principal. Tenemos que dejar nuestro archivo de esta manera::root {\n--primary: #2a6fa8;\n--secondary: #12abdb;\n--text-ligth: #2c3e50;\n--text-dark: #fff;\n--background-color: #fff;\n}\n\n*,\n*::before,\n*::after {\nbox-sizing: border-box;\nmargin: 0;\nposition: relative;\nfont-weight: normal;\n}\n\nbody {\nmin-height: 100vh;\ncolor: var(--text-ligth);\nbackground: var(--background-color);\nline-height: 1.6;\nfont-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,\nCantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\nfont-size: 16px;\ntext-rendering: optimizeLegibility;\n-webkit-font-smoothing: antialiased;\n-moz-osx-font-smoothing: grayscale;\n}\n
main.ts
y cambiaremos el import que hace del CSS por el base que es el que estamos usando, quedar\u00eda de esta manera:import { createApp } from 'vue'\nimport App from './App.vue'\nimport router from './router'\n\nimport './assets/base.css'\n\nconst app = createApp(App)\n\napp.use(router)\n\napp.mount('#app')\n
App.vue
y lo dejaremos solo como la entrada a la aplicaci\u00f3n, esto ya son maneras de trabajar de cada uno, pero a m\u00ed me gusta hacerlo asi para tener si hiciera falta diferentes layouts, uno con header y men\u00fa, otro sin header y men\u00fa, otro de la parte de admin, etc\u2026 Lo dejaremos exactamente asi:<script setup lang=\"ts\">\nimport { RouterView } from 'vue-router'\n</script>\n\n<template>\n <RouterView />\n</template>\n
src
y crearemos una nueva carpeta llamada layouts
, dentro de esta carpeta crearemos otra que se llamara main-layout
(esto lo hacemos por si luego tenemos m\u00e1s de un layout que cada uno tenga su carpeta para tener sus propias cosas) y dentro de la carpeta main-layout
crearemos el archivo MainLayout.vue
, nos deber\u00eda de quedar asi:Una vez tenemos el archivo MainLayout.vue
creado lo abriremos y escribiremos el siguiente c\u00f3digo:
<script setup lang=\"ts\">\nconst helloWorld = 'Hola Mundo';\n</script>\n\n<template>\n <h1>{{ helloWorld }}</h1>\n</template>\n
Vamos a intentar explicar este c\u00f3digo un poco:
script
metemos todo el c\u00f3digo Javascript, en este caso como vamos a trabajar con Typescript le ponemos la etiqueta Lang=\u201dts\u201d
para que el compilador sepa que estamos trabajando con Typescript.setup
porque estamos trabajando con la composition api
, en VUE podemos trabajar con la options api
y con la composition api
, nosotros vamos a usar la composition api
que aunque al principio cuesta un poco m\u00e1s, luego nos va a hacer la vida much\u00edsimo m\u00e1s f\u00e1cil, sobre todo en aplicaciones \"reales\".template
va el HTML y como estamos usando el m\u00e9todo setup
no necesitamos retornar nada para poder acceder a ello desde la plantilla.Las llaves dobles permiten hacen un binding entre el c\u00f3digo del componente y la plantilla. Es decir, en este caso ir\u00e1 al c\u00f3digo TypeScript y buscar\u00e1 el valor de la variable helloWorld.
Consejo
El binding tambi\u00e9n nos sirve para ejecutar los m\u00e9todos de TypeScript desde el c\u00f3digo HTML. Adem\u00e1s, si el valor que contiene la variable se modificar\u00e1 durante la ejecuci\u00f3n de alg\u00fan m\u00e9todo, autom\u00e1ticamente el c\u00f3digo HTML refrescar\u00eda el nuevo valor de la variable helloWorld
.
Ponemos en marcha la aplicaci\u00f3n con npm run dev
.
Si abrimos el navegador y accedemos a http://localhost:5173/
podremos ver el resultado del c\u00f3digo.
Lo primero que vamos a hacer es escoger un tema y una paleta de componentes para trabajar. VUE no tiene una librer\u00eda de componentes oficial al igual que, por ejemplo, Angular tiene Material, por lo que podremos elegir entre las diferentes opciones y ver la que m\u00e1s se ajusta a las necesidades del proyecto o crearnos la nuestra propia, si entramos en proyectos ya comenzados, seguramente este paso ya habr\u00e1 sido abordado y ya sabr\u00e1s con qu\u00e9 librer\u00eda de componentes trabajar, para este proyecto vamos a optar por PrimeVue, no tenemos ning\u00fan motivo especial para decidir esa en especial, pero la hemos usado en un curso anterior y optamos por seguir con la misma librer\u00eda.
Para instalarla bastar\u00e1 con seguir los pasos de su documentaci\u00f3n.
Vamos a hacerlo y la instalamos en nuestro proyecto:
npm install primevue\n
Despu\u00e9s instalaremos PrimeVue con la funci\u00f3n use
en el main.ts
que es donde tenemos nuestra configuraci\u00f3n, quedando asi nuestro main.ts
:
main.ts
:base.css
cambiaremos la fuente del proyecto por la que trae el tema de PrimeVue, cambiando en el body
la l\u00ednea:...\nfont-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,\nCantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\n...\n
Por:
base.css...\nfont-family: (--font-family);\n...\n
Recuerda
Al a\u00f1adir una nueva librer\u00eda tenemos que parar el servidor y volver a arrancarlo para que compile y pre-cargue las nuevas dependencias.
Una vez a\u00f1adida la dependencia, lo que queremos es crear una primera estructura inicial a la p\u00e1gina. Si te acuerdas cual era la estructura (y si no te acuerdas, vuelve a la secci\u00f3n Contexto de la aplicaci\u00f3n y lo revisas), ten\u00edamos una cabecera superior con un logo y t\u00edtulo y unas opciones de men\u00fa.
Antes de empezar a crear y programar vamos a instalar unas extensiones en Visual Studio Code que nos har\u00e1n la vida mucho mas f\u00e1cil, en cada una de ellas podeis ver una descripci\u00f3n de que hacen y para que sirven, tu ya dices si la quieres instalar o no, nosotros vamos a trabajar con ellas y por eso te las recomendamos:
Para poder seguir trabajando con comodidad vamos a a\u00f1adir una fuente de iconos para todos los iconitos que usemos en la aplicaci\u00f3n, nosotros vamos a usar Material porque es la que estamos acostumbrados, para a\u00f1adirla tenemos una gu\u00eda.
Lo haremos paso a paso:
index.html
la fuente a trav\u00e9s de Google fonts, hay muchas otras maneras de hacerlo, como bajarla y servirla desde local, pero para este tutorial vamos a usar esta por ser la m\u00e1s f\u00e1cil, para a\u00f1adirla pegaremos en el index.html
esta l\u00ednea:<link href=\"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\" rel=\"stylesheet\" />\n
Quedando de esta manera:
index.html<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <link rel=\"icon\" href=\"/favicon.ico\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <link\n href=\"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined\"\n rel=\"stylesheet\"\n />\n <title>Vite App</title>\n </head>\n <body>\n <div id=\"app\"></div>\n <script type=\"module\" src=\"/src/main.ts\"></script>\n </body>\n</html>\n
Para que no nos salga el error de comments, a\u00f1adiremos al eslintrc.js
estas l\u00edneas:
...\nrules: {\n'vue/comment-directive': 'off'\n}\n...\n
Despu\u00e9s nos iremos al fichero base.css
y a\u00f1adiremos esto al final del archivo:
...\n.material-symbols-outlined {\nfont-family: \"Material Symbols Outlined\", sans-serif;\nfont-weight: normal;\nfont-style: normal;\nfont-size: 24px; /* Preferred icon size */\ndisplay: inline-block;\nline-height: 1;\ntext-transform: none;\nletter-spacing: normal;\nword-wrap: normal;\nwhite-space: nowrap;\ndirection: ltr;\n}\n
Con esto ya tendremos a\u00f1adida la fuente material-symbols y podremos usar todos los iconos disponibles.
npm install primeicons\n
Una vez instalados, importaremos los iconos en el main.ts
poniendo este import debajo de todos los de css:
...\nimport 'primeicons/primeicons.css';\n...\n
Con esto ya lo tendr\u00edamos todo.
Pues vamos a ello, con las extensiones ya instaladas y la fuente para los iconos a\u00f1adida crearemos esa estructura com\u00fan para toda la aplicaci\u00f3n.
Lo primero crearemos el componente header, dentro de la carpeta components al ser un m\u00f3dulo de la aplicaci\u00f3n y no especifico de una vista o p\u00e1gina. Para eso crearemos una nueva carpeta dentro de components que llamaremos header, nos situaremos encima de la carpeta header y crearemos el archivo HeaderComponent.vue
, con el archivo vac\u00edo escribiremos, vbase-3-ts-setup
y conforme lo escribimos nos aparecer\u00e1 esto:
Consejo
Esto nos aparece gracias a las extensiones que hemos instalado, aseg\u00farate de instalarlas para que aparezca o si no las quieres instalar lo puedes crear a mano. Si no te aparece y has instalado las extensiones, cierra vscode y vu\u00e9lvelo a abrir.
Podemos seleccionar vbase-3-ts-setup
, esto es un snippet que lo que har\u00e1 es generarnos todo el c\u00f3digo de un componente vac\u00edo y lo dejara asi:
<template>\n <div>\n\n </div>\n</template>\n\n<script setup lang=\"ts\">\n\n</script>\n\n<style scoped>\n\n</style>\n
Con esto solo nos faltar\u00eda agregar a la etiqueta style que vamos a trabajar con Sass y la dejar\u00edamos asi:
HeaderComponent.vue...\n<style lang=\"scss\" scoped>\n\n</style>\n...\n
Si os dais cuenta hemos a\u00f1adido Lang=\u201dscss\u201d
y con esto ya estamos preparados para crear nuestro componente.
Para continuar cambiaremos el c\u00f3digo del HeaderComponent.vue
por este:
<template>\n <div class=\"card relative z-2\">\n <Menubar :model=\"items\">\n <template #start>\n <span class=\"material-symbols-outlined\">storefront</span>\n <span class=\"title\">LUDOTECA TAN</span>\n </template>\n <template #end>\n <Avatar icon=\"pi pi-user\" class=\"mr-2 avatar-image\" size=\"large\" shape=\"circle\" />\n <span class=\"sign-text\">Sign in</span>\n </template>\n </Menubar>\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from \"vue\";\nimport Menubar from \"primevue/menubar\";\nimport Avatar from \"primevue/avatar\";\n\nconst items = ref([\n{\nlabel: \"Cat\u00e1logo\",\n},\n{\nlabel: \"Categor\u00edas\",\n},\n{\nlabel: \"Autores\",\n},\n]);\n</script>\n\n<style lang=\"scss\" scoped>\n.p-menubar {\npadding: 0.5rem;\nbackground: var(--primary);\ncolor: var(--text-dark);\nborder: none;\nborder-radius: 0px;\n}\n\n.title {\nmargin-left: 1rem;\nfont-weight: 600;\n}\n\n.avatar-image {\nbackground-color: var(--secondary);\ncolor: var(--text-dark);\nborder: 1px solid var(--text-dark);\ncursor: pointer;\n}\n\n.sign-text {\ncolor: var(--text-dark);\nmargin-left: 1rem;\ncursor: pointer;\n}\n\n:deep(.p-menubar-start) {\ndisplay: flex;\nflex-direction: row;\nalign-items: center;\njustify-content: center;\nmargin-right: 1rem;\n}\n\n:deep(.p-menubar-end) {\ndisplay: flex;\nflex-direction: row;\nalign-items: center;\njustify-content: center;\n}\n\n:deep(.p-menuitem-text) {\ncolor: var(--text-dark) !important;\n}\n\n:deep(.p-menuitem-content:hover) {\nbackground: var(--secondary) !important;\n}\n\n.material-symbols-outlined {\nfont-size: 36px;\n}\n</style>\n
Intentaremos explicarlo un poco:
En el template estamos a\u00f1adiendo el Menubar
de la librer\u00eda de componentes que estamos utilizando, si queremos saber como se a\u00f1ade podemos verlo en este link.
Veremos que lo primero que hacemos es el import
dentro de las etiquetas <script>
para poder tener el componente disponible y poder usarlo.
...\nimport Menubar from \"primevue/menubar\";\n...\n
Luego, con el import
ya hecho, podemos copiar el HTML que nos dan y ponerlo en nuestro componente:
...\n<div class=\"card relative z-2\">\n <Menubar :model=\"items\">\n <template #start>\n <span class=\"material-symbols-outlined\">storefront</span>\n <span class=\"title\">LUDOTECA TAN</span>\n </template>\n <template #end>\n <Avatar icon=\"pi pi-user\" class=\"mr-2 avatar-image\" size=\"large\" shape=\"circle\" />\n <span class=\"sign-text\">Sign in</span>\n </template>\n </Menubar>\n</div>\n...\n
Si os dais cuenta es el c\u00f3digo que ellos nos dan retocado para cubrir nuestras necesidades, primero hemos metido un icono de material dentro del template #start
que es lo que se situara al principio pegado a la izquierda del Menubar
y tras el icono metemos el t\u00edtulo.
El template #end
se situar\u00e1 al final pegado a la derecha y alli estamos metiendo otro componente de la librer\u00eda de componentes, pod\u00e9is ver la info de como usarlo en este link.
Este simplemente lo pegamos como esta y le a\u00f1adimos detr\u00e1s la frase Sign in
.
En la parte del script metemos todo nuestro Javascript/Typescript:
HeaderComponent.vue...\n<script setup lang=\"ts\">\nimport { ref } from \"vue\";\nimport Menubar from \"primevue/menubar\";\nimport Avatar from \"primevue/avatar\";\n\nconst items = ref([\n{\nlabel: \"Cat\u00e1logo\",\n},\n{\nlabel: \"Categor\u00edas\",\n},\n{\nlabel: \"Autores\",\n},\n]);\n</script>\n...\n
Si os dais cuenta, lo \u00fanico que estamos haciendo son los imports necesarios para que todo funcione y creando una variable \u00edtems
que es la que luego estamos usando en el men\u00fa para pintar los diferentes menus. Si os dais cuenta envolvemos el valor de la variable dentro de ref()
. En Vue 3, la funci\u00f3n ref()
se utiliza para crear una referencia reactiva a un valor. Una referencia reactiva es un objeto que puede ser pasado como prop, utilizado en una plantilla, y observado para detectar cambios en su valor.
La funci\u00f3n ref()
toma un valor como argumento y devuelve un objeto con una propiedad value que contiene el valor proporcionado. Por ejemplo, si queremos crear una referencia a un n\u00famero entero, podemos hacer lo siguiente:
import { ref } from 'vue'\nconst myNumber = ref(42)\nconsole.log(myNumber.value) // 42\n
La referencia myNumber
es ahora un objeto con una propiedad value que contiene el valor 42
. Si cambiamos el valor de la propiedad value
, la referencia notificar\u00e1 a cualquier componente que est\u00e9 observando el valor que ha cambiado. Por ejemplo:
myNumber.value = 21\nconsole.log(myNumber.value) // 21\n
Cualquier componente que est\u00e9 utilizando myNumber se actualizar\u00e1 autom\u00e1ticamente para reflejar el nuevo valor. La funci\u00f3n ref()
es muy \u00fatil en Vue 3 para crear referencias reactivas a valores que pueden cambiar con el tiempo.
En los styles tenemos poco que explicar, simplemente estamos haciendo que se vea como nosotros queremos, que todos los colores y dem\u00e1s los traemos de las variables que hemos creado antes en el base.css
y adem\u00e1s me gustar\u00eda mencionar una cosa:
...\n:deep(.p-menubar-start) {\n display: flex;\n flex-direction: row;\n align-items: center;\n justify-content: center;\n margin-right: 1rem;\n}\n...\n
Si os dais cuenta algunos estilos llevan el :Deep delante, como seguro ya sabes, puedes utilizar el atributo scoped
dentro de la etiqueta <style>
para escribir CSS y as\u00ed impedir que tus estilos afecten a posibles sub-componentes. Pero, \u00bfqu\u00e9 ocurre si necesitas que al menos una regla s\u00ed afecte a tu componente hijo?. Para ello puedes usar la pseudo-clase :deep
de Vue 3.
En este ejemplo lo hemos creado asi para que sepas de su existencia y busques un poco de informaci\u00f3n sobre ella y las otras que existen, este CSS lo podr\u00edamos poner en el styles.scss
principal y no tendr\u00edamos que poner el :deep
que seria lo mas recomendado. Es importante tener en cuenta que la directiva :deep
puede tener un impacto en el rendimiento, ya que Vue necesita buscar en todo el \u00e1rbol de elementos para aplicar los estilos. Por lo tanto, se recomienda utilizar esta directiva con moderaci\u00f3n y solo en casos en los que sea necesario seleccionar elementos anidados de forma din\u00e1mica. Tenerlo en cuenta y solo usarla cuando de verdad sea necesario.
Ya por \u00faltimo nos iremos a nuestro MainLayout.vue
y a\u00f1adiremos el header que acabamos de crearnos:
<script setup lang=\"ts\">\nimport HeaderComponent from '@/components/header/HeaderComponent.vue';\n\nconst helloWorld = 'Hola Mundo';\n</script>\n\n<template>\n <HeaderComponent></HeaderComponent>\n <h1>{{ helloWorld }}</h1>\n</template>\n
Como antes, lo \u00fanico que hacemos es importar el componente en el script
y usarlo en el HTML.
Lo siguiente iremos a la carpeta router
, al archivo index.ts
y lo dejaremos asi:
import { createRouter, createWebHistory } from 'vue-router'\nimport MainLayout from '@/layouts/main-layout/MainLayout.vue'\n\nconst router = createRouter({\nhistory: createWebHistory(import.meta.env.BASE_URL),\nroutes: [\n{\npath: '/',\nname: 'home',\ncomponent: MainLayout\n}\n]\n})\n\nexport default router\n
Hemos cambiado la ruta principal para que apunte a nuestro layout
y nada m\u00e1s entrar en la aplicaci\u00f3n lo carguemos gracias al router de VUE.
Si guardamos todo y ponemos en marcha el proyecto ya veremos algo como esto:
"},{"location":"develop/basic/vuejsold/#creando-un-listado-basico","title":"Creando un listado b\u00e1sico","text":""},{"location":"develop/basic/vuejsold/#crear-componente_1","title":"Crear componente","text":"Ya tenemos la estructura principal, ahora vamos a crear nuestra primera pantalla. Vamos a empezar por la de Categor\u00edas que es la m\u00e1s sencilla, ya que se trata de un listado, que muestra datos sin filtrar ni paginar.
Como categor\u00edas es un dominio funcional de la aplicaci\u00f3n, vamos a crear una nueva carpeta dentro de la carpeta views
llamada categories, todas las pantallas, componentes y servicios que creemos, referidos a este dominio funcional, deber\u00e1n ir dentro del m\u00f3dulo categories. Dentro de esa carpeta crearemos un fichero que se llamara CategoriesView.vue
y dentro nos crearemos el esqueleto de la misma manera que hicimos anteriormente.
Escribiremos vbase-3-ts-setup
, le daremos al enter y nos generara toda la estructura a la que solo faltara agregar a la etiqueta <style> Lang=\u201dscss\u201d
para decirle que vamos a trabajar con SASS. Con esto tenemos nuestra vista preparada para empezar a trabajar.
Lo primero vamos a conectar nuestro componente al router
para que cuando hagamos click en el men\u00fa correspondiente podamos llegar hasta \u00e9l y tambi\u00e9n para poder ver lo que vamos trabajando. Para ello lo primero que vamos a hacer en el template de nuestro componente es a\u00f1adir cualquier cosa para saber que estamos donde toca, por ejemplo:
<template>\n <div>SOY CATEGORIAS</div>\n</template>\n
Con esto cuando entremos en la ruta de categor\u00edas deber\u00edamos ver SOY CATEGORIAS
.
Lo siguiente crearemos en el layout
un sitio para cargar todas nuestras rutas que van a ir dentro de ese layout, para ello iremos al archivo MainLayout.vue
y a\u00f1adiremos un <RouterView />
que ser\u00e1 el segundo de nuestra aplicaci\u00f3n, el primero lo tenemos en el App.vue
que servir\u00e1 para cargar nuestras rutas principales (diferentes layouts, pagina 404, etc) y el segundo es este que acabamos de crear, podemos tener tantos como queramos en una aplicaci\u00f3n y cada uno tendr\u00e1 su cometido. Este que acabamos de crear ser\u00e1 donde se cargaran todas las rutas que quieran estar dentro del layout
principal.
Para crearlo importaremos \u00e9l RouterView
dentro de los <script>
desde vue-router
:
import { RouterView } from 'vue-router';\n
Lo a\u00f1adiremos dentro de los <template>
exactamente donde queramos cargar las rutas y si puede ser con un div
padre que haga de contenedor asi podremos darle los estilos sin sufrir demasiado.
<div class=\"outlet-container\">\n <RouterView />\n</div>\n
Y luego dentro de <style>
le daremos estilo al contenedor padre de acuerdo a lo que necesitemos (grid, flex, etc\u2026) en este ejemplo para hacerlo f\u00e1cil lo haremos con flex, con todo esto quedar\u00eda asi:
<script setup lang=\"ts\">\nimport { RouterView } from 'vue-router';\nimport HeaderComponent from \"@/components/header/HeaderComponent.vue\";\n</script>\n\n<template>\n <HeaderComponent></HeaderComponent>\n <div class=\"outlet-container\">\n <RouterView />\n </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.outlet-container {\ndisplay: flex;\nflex-direction: column;\nflex-grow: 1;\nwidth: 100%;\nmin-height: calc(100vh - 65px);\npadding: 1rem;\n}\n</style>\n
Ahora vamos a a\u00f1adirlo a nuestras rutas, para ello nos vamos a la carpeta router
y dentro tendremos el index.ts
con nuestras rutas actuales, vamos a a\u00f1adir la nueva ruta como hija de layout
para que siempre se muestre dentro del layout
que hemos creado con \u00e9l header
:
import { createRouter, createWebHistory } from 'vue-router'\nimport MainLayout from '@/layouts/main-layout/MainLayout.vue'\n\nconst router = createRouter({\nhistory: createWebHistory(import.meta.env.BASE_URL),\nroutes: [\n{\npath: '/',\nname: 'home',\ncomponent: MainLayout,\nchildren: [\n{\npath: '/categories',\nname: 'categories',\ncomponent: () => import('../views/categories/CategoriesView.vue')\n}\n]\n}\n]\n})\n\nexport default router\n
Si os dais cuenta lo hemos a\u00f1adido como hijo de layout
y adem\u00e1s lo hemos hecho con lazy loading
, es decir, este componente solo se cargara cuando el usuario navegue a esa ruta, asi evitamos cargas much\u00edsimo m\u00e1s grandes al inicio de la aplicaci\u00f3n.
Posteriormente nos iremos al HeaderComponent.vue
y a\u00f1adiremos la ruta a los \u00edtems del men\u00fa de esta manera:
const items = ref([\n {\n label: \"Cat\u00e1logo\",\n },\n {\n label: \"Categor\u00edas\",\n to: { name: 'categories'}\n },\n {\n label: \"Autores\",\n },\n]);\n
Si nos fijamos hemos a\u00f1adido la navegaci\u00f3n por el nombre de ruta en el men\u00fa categor\u00edas para que sepa cuando apretemos ese men\u00fa donde nos tiene que llevar.
Con todo esto si ponemos en marcha nuestra aplicaci\u00f3n, ya podremos navegar haciendo click en el men\u00fa Categor\u00edas a esta nueva ruta que hemos creado y ya ver\u00edamos el SOY CATEGORIAS
pero tenemos un problemilla en los menus, cuando apretamos un men\u00fa se pone el fondo gris, lo cual no nos gusta y adem\u00e1s aunque estemos en categor\u00edas si apretamos en otro men\u00fa se pone el otro gris y se quita el categor\u00edas lo cual tampoco es lo deseado ya que queremos que se quede marcado el men\u00fa donde estamos actualmente para informaci\u00f3n del usuario. Para ello nos iremos al base.css
y a\u00f1adiremos al final estas l\u00edneas:
...\n.router-link-active {\nbackground: var(--secondary);\nborder-radius: 5px;\n}\n\n.p-menuitem.p-focus > .p-menuitem-content:not(:hover) {\nbackground: transparent !important;\n}\n
En Vue 3, la directiva router-link-active
se utiliza para establecer una clase CSS en un enlace de router activo, con esto ya tendremos resuelto el problema y todo estar\u00e1 funcionando como toca y poniendo en marcha la aplicaci\u00f3n y haciendo click en el men\u00fa Categor\u00edas ya deber\u00edamos ver esto:
Ahora vamos a construir la pantalla. Para manejar la informaci\u00f3n del listado, necesitamos tipar los datos para que Typescript no se queje. Para ello crearemos un fichero en categories\\models\\category-interface.ts
donde implementaremos la interface necesaria. Esta interface ser\u00e1 la que utilizaremos para tipar el c\u00f3digo de nuestro componente.
export interface Category {\nid: number\nname: string\n}\n
Tambi\u00e9n, escribiremos el c\u00f3digo de CategoriesView.vue
:
<template>\n <div class=\"card\">\n <DataTable\n v-model:editingRows=\"editingRows\"\n :value=\"categories\"\n tableStyle=\"min-width: 50rem\"\n editMode=\"row\"\n dataKey=\"id\"\n @row-edit-save=\"onRowEditSave\"\n >\n <Column field=\"id\" header=\"IDENTIFICADOR\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n </Column>\n <Column field=\"name\" header=\"NOMBRE CATEGOR\u00cdA\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n </Column>\n <Column\n :rowEditor=\"true\"\n style=\"width: 110px\"\n bodyStyle=\"text-align:center\"\n ></Column>\n <Column\n style=\"width: 30px; padding: 0px 2rem 0px 0px; color: red\"\n bodyStyle=\"text-align:center\"\n >\n <template #body=\"{ data }\">\n <i class=\"pi pi-times\" @click=\"onRowDelete(data)\"></i>\n </template>\n </Column>\n </DataTable>\n </div>\n <div class=\"actions\">\n <Button label=\"Nueva categor\u00eda\" />\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from \"vue\";\nimport DataTable, { type DataTableRowEditSaveEvent } from \"primevue/datatable\";\nimport Column from \"primevue/column\";\nimport InputText from \"primevue/inputtext\";\nimport Button from 'primevue/button';\nimport type { CategoryInterface } from \"./model/category.interface\";\nconst categories = ref([]);\nconst editingRows = ref([]);\nconst onRowEditSave = (event: DataTableRowEditSaveEvent) => {\nconsole.log(event);\n};\nconst onRowDelete = (data: CategoryInterface) => {\nconsole.log(data);\n}\n</script>\n\n<style lang=\"scss\" scoped>\n.actions {\ndisplay: flex;\nflex-direction: row;\nmargin-top: 1rem;\njustify-content: flex-end;\n}\n\n.p-button {\nbackground: var(--primary);\nborder: 1px solid var(--primary);\n\n&:enabled {\n&:hover {\nbackground: var(--secondary);\nborder-color: var(--secondary);\n}\n}\n}\n</style>\n
Intentaremos explicar un poco el c\u00f3digo:
Lo primero vamos a importar el componente DataTable desde la librer\u00eda de componentes que estamos usando, para ello podemos ver algunos ejemplos de como hacerlo en la documentaci\u00f3n oficial
Nosotros hemos puesto las importaciones que necesitamos en el <script>
:
import DataTable, { type DataTableRowEditSaveEvent } from \"primevue/datatable\";\nimport Column from \"primevue/column\";\n
Hemos creado nuestra tabla con las exigencias de la aplicaci\u00f3n, hemos puesto dos columnas, la columna identificador donde en \u00e9l header=\u201d\u201d
le ponemos que nombre se muestra en la cabecera y le hemos dicho que debe mostrar en ella el dato id poni\u00e9ndolo en \u00e9l field=\u201d\u201d
.
<Column field=\"id\" header=\"IDENTIFICADOR\"></Column>\n
Como a la tabla le hemos dicho que debe ser editable con:
CategoriesView.vueeditMode=\"row\"\n
Le decimos a esta columna que debe hacer cuando entremos en modo de edici\u00f3n, con el template le decimos que mostrara un InputText
que es otro componente de la librer\u00eda de componentes que viene a ser un input de toda la vida donde podemos escribir texto para editar el valor, quedando al final asi:
<Column field=\"id\" header=\"IDENTIFICADOR\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n</Column>\n
Luego hemos creado dos columnas, una que tiene el l\u00e1piz y activa el modo edici\u00f3n:
CategoriesView.vue<Column\n :rowEditor=\"true\"\n style=\"width: 110px\"\n bodyStyle=\"text-align:center\">\n</Column>\n
Y otra que tiene la X y lo que har\u00e1 ser\u00e1 borrar la fila:
CategoriesView.vue<Column\n style=\"width: 30px; padding: 0px 2rem 0px 0px; color: red\"\n bodyStyle=\"text-align:center\"\n>\n <template #body=\"{ data }\">\n <i class=\"pi pi-times\" @click=\"onRowDelete(data)\"></i>\n </template>\n</Column>\n
Al final a\u00f1adimos otro contenedor que vale para alojar los botones como en nuestro caso el de crear nueva categor\u00eda, el bot\u00f3n es tambi\u00e9n un componente de la librer\u00eda por lo que tendremos que hacer su import en la etiqueta <script>
:
<div class=\"actions\">\n <Button label=\"Nueva categor\u00eda\" />\n</div>\n
Si abrimos el navegador y accedemos a http://localhost:5173/
y pulsamos en el men\u00fa de Categor\u00edas obtendremos una pantalla con un listado vac\u00edo (solo con cabeceras) y un bot\u00f3n de crear Nueva Categor\u00eda
que a\u00fan no hace nada.
En este punto y para ver como responde el listado, vamos a a\u00f1adir datos. Si tuvi\u00e9ramos el backend implementado podr\u00edamos consultar los datos directamente de una operaci\u00f3n de negocio de backend, pero ahora mismo no lo tenemos implementado as\u00ed que para no bloquear el desarrollo vamos a mockear los datos.
En Vue para conectar a APIS externas solemos usar una librer\u00eda llamada Axios, lo primero que haremos ser\u00e1 descargarla e instalarla como indica en su documentaci\u00f3n oficial.
Para instalarla simplemente nos iremos a la terminal dentro de la carpeta donde tenemos el proyecto y pondremos:
npm install axios\n
Con esto ya podremos ver que se ha a\u00f1adido a nuestro package.json
, luego crearemos una carpeta api
dentro de la carpeta src
y dentro de la carpeta api
crearemos el archivo app-api.ts
. Dentro de este archivo vamos a inicializar nuestra config de la API y guardaremos todos los par\u00e1metros iniciales conforme nos vayan haciendo falta, de momento pondremos solo este c\u00f3digo:
import axios from 'axios';\n\nexport const appApi = axios.create({\nbaseURL: 'http://localhost:8080',\n});\n
Si os dais cuenta lo \u00fanico que hacemos es importar axios
que acabamos de instalarlo y definir nuestra url base del api para no tener que escribirla cada vez y para s\u00ed alg\u00fan d\u00eda cambia, tener que cambiarla solo en un sitio y no en todos los servicios que la usen.
Para mockear los datos con axios
usaremos una librer\u00eda que se llama axios-mock-adapter
y la pod\u00e9is encontrar en este link.
Para instalarla lo haremos con npm como siempre, pondremos esta orden en el terminal y enter:
npm install axios-mock-adapter --save-dev\n
Si nos vamos al package.json
veremos que ya la tenemos en las devDependencies
, la diferencia entre estas y las dependencias es que las dependencias las necesitamos en el proyecto y estar\u00e1n en nuestro bundle
final que serviremos a la gente, las devDependencies
se usan solo mientras programamos y no entraran en el bundle
final. Los mocks los usaremos solo en el desarrollo y hasta que podamos conectar con la API real por eso los metemos en las devDependencies.
Como hemos comentado anteriormente, el backend todav\u00eda no est\u00e1 implementado as\u00ed que vamos a mockear datos. Nos crearemos un fichero mock-categories.ts
dentro de views/categories/mocks
, con datos ficticios y crearemos una llamada a la API que nos devuelva estos datos. De esta forma, cuando tengamos implementada la operaci\u00f3n de negocio en backend, tan solo tenemos que sustituir el c\u00f3digo que devuelve datos est\u00e1ticos por una llamada HTTP.
Dentro de la carpeta mocks
crearemos el archivo mock-categories.ts
con el siguiente c\u00f3digo:
import type { Category } from \"@/views/categories/models/category-interface\";\n\nexport const CATEGORY_DATA_MOCK: Category[] = [\n{ id: 1, name: 'Dados' },\n{ id: 2, name: 'Fichas' },\n{ id: 3, name: 'Cartas' },\n{ id: 4, name: 'Rol' },\n{ id: 5, name: 'Tableros' },\n{ id: 6, name: 'Tem\u00e1ticos' },\n{ id: 7, name: 'Europeos' },\n{ id: 8, name: 'Guerra' },\n{ id: 9, name: 'Abstractos' },\n]\n
Despu\u00e9s nos crearemos un composable
que usaremos para llamar a la API y poder reutilizarlo en otros componentes si hiciera falta. Dentro de la carpeta categories
crearemos otra carpeta llamada composables
y dentro crearemos un archivo llamado categories-composable.ts
, en ese archivo escribiremos este c\u00f3digo:
import appApi from '@/api/app-api'\nimport MockAdapter from 'axios-mock-adapter';\nimport { CATEGORY_DATA_MOCK } from '@/views/categories/mocks/mock-categories'\n\nconst mock = new MockAdapter(appApi);\n\nconst useCategoriesApiComposable = () => {\nmock.onGet(\"/category\").reply(200, CATEGORY_DATA_MOCK);\n\nconst getCategories = async () => {\nconst categories = await appApi.get(\"/category\");\nreturn categories.data;\n};\n\nreturn {\ngetCategories\n}\n}\n\nexport default useCategoriesApiComposable\n
A\u00f1adiremos \u00e9l composable
a nuestro CategoriesView.vue
dentro de las etiquetas <script>
, lo primero en el import
que ya tenemos desde Vue a\u00f1adiremos el m\u00e9todo onMounted
dej\u00e1ndolo asi:
import { onMounted, ref } from 'vue'\n
El m\u00e9todo onMounted
es un ciclo de vida que se dispara nada m\u00e1s montarse el componente, despu\u00e9s a\u00f1adiremos al final el import
del composable
para poder usarlo:
import useCategoriesApiComposable from '@/views/categories/composables/categories-composable'\n
Nos traeremos el m\u00e9todo del composable
con la desestructuraci\u00f3n del objeto:
const { getCategories } = useCategoriesApiComposable()\n
Crearemos una funci\u00f3n as\u00edncrona para llamar al composable
y llamaremos al composable
en el onMounted
:
async function getInitCategories() {\n categories.value = await getCategories()\n}\n\nonMounted(() => {\n getInitCategories()\n})\n
El CategoriesView.vue
quedar\u00eda asi:
<template>\n <div class=\"card\">\n <DataTable\n v-model:editingRows=\"editingRows\"\n :value=\"categories\"\n tableStyle=\"min-width: 50rem\"\n editMode=\"row\"\n dataKey=\"id\"\n @row-edit-save=\"onRowEditSave\"\n >\n <Column field=\"id\" header=\"IDENTIFICADOR\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n </Column>\n <Column field=\"name\" header=\"NOMBRE CATEGOR\u00cdA\">\n <template #editor=\"{ data, field }\">\n <InputText v-model=\"data[field]\" />\n </template>\n </Column>\n <Column :rowEditor=\"true\" style=\"width: 110px\" bodyStyle=\"text-align:center\"></Column>\n <Column\n style=\"width: 30px; padding: 0px 2rem 0px 0px; color: red\"\n bodyStyle=\"text-align:center\"\n >\n <template #body=\"{ data }\">\n <i class=\"pi pi-times\" @click=\"onRowDelete(data)\"></i>\n </template>\n </Column>\n </DataTable>\n </div>\n <div class=\"actions\">\n <Button label=\"Nueva categor\u00eda\" />\n </div>\n</template>\n\n<script setup lang=\"ts\">\nimport { onMounted, ref } from 'vue'\nimport DataTable, { type DataTableRowEditSaveEvent } from 'primevue/datatable'\nimport Column from 'primevue/column'\nimport InputText from 'primevue/inputtext'\nimport Button from 'primevue/button'\nimport type { CategoryInterface } from './model/category.interface'\nimport useCategoriesApiComposable from '@/views/categories/composables/categories-composable'\n\nconst categories = ref([])\nconst editingRows = ref([])\n\nconst { getCategories } = useCategoriesApiComposable()\n\nconst onRowEditSave = (event: DataTableRowEditSaveEvent) => {\nconsole.log(event)\n}\n\nconst onRowDelete = (data: CategoryInterface) => {\nconsole.log(data)\n}\n\nasync function getInitCategories() {\ncategories.value = await getCategories()\n}\n\nonMounted(() => {\ngetInitCategories()\n})\n</script>\n\n<style lang=\"scss\" scoped>\n.actions {\ndisplay: flex;\nflex-direction: row;\nmargin-top: 1rem;\njustify-content: flex-end;\n}\n\n.p-button {\nbackground: var(--primary);\nborder: 1px solid var(--primary);\n\n&:enabled {\n&:hover {\nbackground: var(--secondary);\nborder-color: var(--secondary);\n}\n}\n}\n</style>\n
Si ahora refrescamos la p\u00e1gina web, veremos que el listado ya tiene datos con los que vamos a interactuar.
"},{"location":"develop/basic/vuejsold/#simulando-las-otras-peticiones","title":"Simulando las otras peticiones","text":"Para terminar, vamos a simular las otras dos peticiones, la de editar y la de borrar para cuando tengamos que utilizarlas. \u00c9l composable
debe quedar m\u00e1s o menos as\u00ed:
import appApi from '@/api/app-api'\nimport MockAdapter from 'axios-mock-adapter'\nimport type { Category } from '@/views/categories/models/category-interface'\nimport { CATEGORY_DATA_MOCK } from '@/views/categories/mocks/mock-categories'\n\nconst mock = new MockAdapter(appApi)\n\nconst useCategoriesApiComposable = () => {\nmock.onAny('/category').reply(200, CATEGORY_DATA_MOCK)\n\nconst getCategories = async () => {\nconst categories = await appApi.get('/category')\nreturn categories.data\n}\n\nconst saveCategory = async (category: Category) => {\nconst categoryEdit = await appApi.post('/category', category)\nreturn categoryEdit.data\n}\n\nconst editCategory = async (category: Category) => {\nconst categoryEdit = await appApi.put('/category', category)\nreturn categoryEdit.data\n}\n\nconst deleteCategory = async (categoryId: number) => {\nconst categoryEdit = await appApi.delete(`/category/${categoryId}`)\nreturn categoryEdit.data\n}\n\nreturn {\ngetCategories,\nsaveCategory,\neditCategory,\ndeleteCategory\n}\n}\n\nexport default useCategoriesApiComposable\n
"},{"location":"develop/filtered/angular/","title":"Listado filtrado - Angular","text":"En este punto ya tenemos dos listados, uno b\u00e1sico y otro paginado. Ahora vamos a implementar un listado un poco diferente, con filtros y con una presentaci\u00f3n un tanto distinta.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/filtered/angular/#crear-componentes","title":"Crear componentes","text":"Vamos a desarrollar el listado de Juegos
. Este listado es un tanto peculiar, porque no tiene una tabla como tal, sino que tiene una tabla con \"tiles\" para cada uno de los juegos. Necesitaremos un componente para el listado y otro componente para el detalle del juego. Tambi\u00e9n necesitaremos otro componente para el dialogo de edici\u00f3n / alta.
Manos a la obra:
ng generate module game\n\nng generate component game/game-list\nng generate component game/game-list/game-item\nng generate component game/game-edit\n\nng generate service game/game\n
Y a\u00f1adimos el nuevo m\u00f3dulo al app.module.ts
como hemos hecho con el resto de m\u00f3dulos.
import { NgModule } from '@angular/core';\nimport { BrowserModule } from '@angular/platform-browser';\n\nimport { AppRoutingModule } from './app-routing.module';\nimport { AppComponent } from './app.component';\nimport { BrowserAnimationsModule } from '@angular/platform-browser/animations';\nimport { CoreModule } from './core/core.module';\nimport { CategoryModule } from './category/category.module';\nimport { AuthorModule } from './author/author.module';\nimport { GameModule } from './game/game.module';\n\n@NgModule({\ndeclarations: [\nAppComponent\n],\nimports: [\nBrowserModule,\nAppRoutingModule,\nCoreModule,\nCategoryModule,\nAuthorModule,\nGameModule,\nBrowserAnimationsModule\n],\nproviders: [],\nbootstrap: [AppComponent]\n})\nexport class AppModule { }\n
"},{"location":"develop/filtered/angular/#crear-el-modelo","title":"Crear el modelo","text":"Lo primero que vamos a hacer es crear el modelo en game/model/Game.ts
con todas las propiedades necesarias para trabajar con un juego:
import { Category } from \"src/app/category/model/Category\";\nimport { Author } from \"src/app/author/model/Author\";\n\nexport class Game {\nid: number;\ntitle: string;\nage: number;\ncategory: Category;\nauthor: Author;\n}\n
Como ves, el juego tiene dos objetos para mapear categor\u00eda y autor.
"},{"location":"develop/filtered/angular/#anadir-el-punto-de-entrada","title":"A\u00f1adir el punto de entrada","text":"A\u00f1adimos la ruta al men\u00fa para que podamos navegar a esta pantalla:
app-routing.module.tsimport { NgModule } from '@angular/core';\nimport { Routes, RouterModule } from '@angular/router';\nimport { AuthorListComponent } from './author/author-list/author-list.component';\nimport { CategoryListComponent } from './category/category-list/category-list.component';\nimport { GameListComponent } from './game/game-list/game-list.component';\nconst routes: Routes = [\n{ path: '', redirectTo: '/games', pathMatch: 'full'},\n{ path: 'categories', component: CategoryListComponent },\n{ path: 'authors', component: AuthorListComponent },\n{ path: 'games', component: GameListComponent },\n];\n\n@NgModule({\nimports: [RouterModule.forRoot(routes)],\nexports: [RouterModule]\n})\nexport class AppRoutingModule { }\n
Adem\u00e1s, hemos a\u00f1adido una regla adicional con el path vac\u00edo para indicar que si no pone ruta, por defecto la p\u00e1gina inicial redirija al path /games
, que es nuevo path que hemos a\u00f1adido.
A continuaci\u00f3n implementamos el servicio y mockeamos datos de ejemplo:
mock-games.tsgame.service.tsimport { Game } from \"./Game\";\n\nexport const GAME_DATA: Game[] = [\n{ id: 1, title: 'Juego 1', age: 6, category: { id: 1, name: 'Categor\u00eda 1' }, author: { id: 1, name: 'Autor 1', nationality: 'Nacionalidad 1' } },\n{ id: 2, title: 'Juego 2', age: 8, category: { id: 1, name: 'Categor\u00eda 1' }, author: { id: 2, name: 'Autor 2', nationality: 'Nacionalidad 2' } },\n{ id: 3, title: 'Juego 3', age: 4, category: { id: 1, name: 'Categor\u00eda 1' }, author: { id: 3, name: 'Autor 3', nationality: 'Nacionalidad 3' } },\n{ id: 4, title: 'Juego 4', age: 10, category: { id: 2, name: 'Categor\u00eda 2' }, author: { id: 1, name: 'Autor 1', nationality: 'Nacionalidad 1' } },\n{ id: 5, title: 'Juego 5', age: 16, category: { id: 2, name: 'Categor\u00eda 2' }, author: { id: 2, name: 'Autor 2', nationality: 'Nacionalidad 2' } },\n{ id: 6, title: 'Juego 6', age: 16, category: { id: 2, name: 'Categor\u00eda 2' }, author: { id: 3, name: 'Autor 3', nationality: 'Nacionalidad 3' } },\n{ id: 7, title: 'Juego 7', age: 12, category: { id: 3, name: 'Categor\u00eda 3' }, author: { id: 1, name: 'Autor 1', nationality: 'Nacionalidad 1' } },\n{ id: 8, title: 'Juego 8', age: 14, category: { id: 3, name: 'Categor\u00eda 3' }, author: { id: 2, name: 'Autor 2', nationality: 'Nacionalidad 2' } },\n]\n
import { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Game } from './model/Game';\nimport { GAME_DATA } from './model/mock-games';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class GameService {\n\nconstructor() { }\n\ngetGames(title?: String, categoryId?: number): Observable<Game[]> {\nreturn of(GAME_DATA);\n}\n\nsaveGame(game: Game): Observable<void> {\nreturn of(null);\n}\n\n}\n
"},{"location":"develop/filtered/angular/#implementar-listado","title":"Implementar listado","text":"Ya tenemos las operaciones del servicio con datoos, as\u00ed que ahora vamos a por el listado filtrado.
game-list.component.htmlgame-list.component.scssgame-list.component.ts<div class=\"container\">\n <h1>Cat\u00e1logo de juegos</h1>\n\n <div class=\"filters\">\n <form>\n <mat-form-field>\n <mat-label>T\u00edtulo del juego</mat-label>\n <input type=\"text\" matInput placeholder=\"T\u00edtulo del juego\" [(ngModel)]=\"filterTitle\" name=\"title\">\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Categor\u00eda del juego</mat-label>\n <mat-select disableRipple [(ngModel)]=\"filterCategory\" name=\"category\">\n <mat-option *ngFor=\"let category of categories\" [value]=\"category\">{{category.name}}</mat-option>\n </mat-select>\n </mat-form-field> \n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onCleanFilter()\">Limpiar</button> \n <button mat-stroked-button (click)=\"onSearch()\">Filtrar</button> \n </div> \n </div> \n\n <div class=\"game-list\">\n <app-game-item *ngFor=\"let game of games; let i = index;\" (click)=\"editGame(game)\">\n </app-game-item>\n </div>\n\n <div class=\"buttons\">\n <button mat-flat-button color=\"primary\" (click)=\"createGame()\">Nuevo juego</button> \n </div> \n</div>\n
.container {\nmargin: 20px;\n\n.filters {\ndisplay: flex;\n\nmat-form-field {\nwidth: 300px;\nmargin-right: 20px;\n}\n\n.buttons {\nflex: auto;\nalign-self: center;\n\nbutton {\nmargin-left: 15px;\n}\n}\n}\n\n.game-list { margin-top: 20px;\nmargin-bottom: 20px;\n\ndisplay: flex;\nflex-flow: wrap;\noverflow: auto; }\n\n.buttons {\ntext-align: right;\n}\n}\n\nbutton {\nwidth: 125px;\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatDialog } from '@angular/material/dialog';\nimport { CategoryService } from 'src/app/category/category.service';\nimport { Category } from 'src/app/category/model/Category';\nimport { GameEditComponent } from '../game-edit/game-edit.component';\nimport { GameService } from '../game.service';\nimport { Game } from '../model/Game';\n\n@Component({\nselector: 'app-game-list',\ntemplateUrl: './game-list.component.html',\nstyleUrls: ['./game-list.component.scss']\n})\nexport class GameListComponent implements OnInit {\n\ncategories : Category[];\ngames: Game[];\nfilterCategory: Category;\nfilterTitle: string;\n\nconstructor(\nprivate gameService: GameService,\nprivate categoryService: CategoryService,\npublic dialog: MatDialog,\n) { }\n\nngOnInit(): void {\n\nthis.gameService.getGames().subscribe(\ngames => this.games = games\n);\n\nthis.categoryService.getCategories().subscribe(\ncategories => this.categories = categories\n);\n}\n\nonCleanFilter(): void {\nthis.filterTitle = null;\nthis.filterCategory = null;\n\nthis.onSearch();\n}\n\nonSearch(): void {\n\nlet title = this.filterTitle;\nlet categoryId = this.filterCategory != null ? this.filterCategory.id : null;\n\nthis.gameService.getGames(title, categoryId).subscribe(\ngames => this.games = games\n);\n}\n\ncreateGame() { const dialogRef = this.dialog.open(GameEditComponent, {\ndata: {}\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); } editGame(game: Game) {\nconst dialogRef = this.dialog.open(GameEditComponent, {\ndata: { game: game }\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.onSearch();\n});\n}\n}\n
Recuerda, de nuevo, que todos los componentes de Angular que utilicemos hay que importarlos en el m\u00f3dulo padre correspondiente para que se puedan precargar correctamente.
game.module.tsimport { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { GameListComponent } from './game-list/game-list.component';\nimport { GameEditComponent } from './game-edit/game-edit.component';\nimport { GameItemComponent } from './game-list/game-item/game-item.component';\nimport { FormsModule, ReactiveFormsModule } from '@angular/forms';\nimport { MatButtonModule } from '@angular/material/button';\nimport { MatOptionModule } from '@angular/material/core';\nimport { MatDialogModule } from '@angular/material/dialog';\nimport { MatFormFieldModule } from '@angular/material/form-field';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatInputModule } from '@angular/material/input';\nimport { MatPaginatorModule } from '@angular/material/paginator';\nimport { MatSelectModule } from '@angular/material/select';\nimport { MatTableModule } from '@angular/material/table';\nimport { MatCardModule } from '@angular/material/card';\n\n\n@NgModule({\ndeclarations: [\nGameListComponent,\nGameEditComponent,\nGameItemComponent\n],\nimports: [\nCommonModule,\nMatTableModule,\nMatIconModule, MatButtonModule,\nMatDialogModule,\nMatFormFieldModule,\nMatInputModule,\nFormsModule,\nReactiveFormsModule,\nMatPaginatorModule,\nMatOptionModule,\nMatSelectModule,\nMatCardModule,\n]\n})\nexport class GameModule { }\n
Con todos estos cambios y si refrescamos el navegador, deber\u00eda verse una pantalla similar a esta:
Tenemos una pantalla con una secci\u00f3n de filtros en la parte superior, donde podemos introducir un texto o seleccionar una categor\u00eda de un dropdown, un listado que de momento tiene todos los componentes b\u00e1sicos en una fila uno detr\u00e1s del otro, y un bot\u00f3n para crear juegos nuevos.
Dropdown
El componente Dropdown
es uno de los componentes m\u00e1s utilizados en las pantallas y formularios de Angular. Ves familiariz\u00e1ndote con \u00e9l porque lo vas a usar mucho. Es bastante potente y medianamente sencillo de utilizar. Los datos del listado pueden ser din\u00e1micos (desde servidor) o est\u00e1ticos (si los valores ya los tienes prefijados).
Ahora vamos a implementar el detalle de cada uno de los items que forman el listado. Para ello lo primero que haremos ser\u00e1 pasarle la informaci\u00f3n del juego a cada componente como un dato de entrada Input
hacia el componente.
<div class=\"container\">\n <h1>Cat\u00e1logo de juegos</h1>\n\n <div class=\"filters\">\n <form>\n <mat-form-field>\n <mat-label>T\u00edtulo del juego</mat-label>\n <input type=\"text\" matInput placeholder=\"T\u00edtulo del juego\" [(ngModel)]=\"filterTitle\" name=\"title\">\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Categor\u00eda del juego</mat-label>\n <mat-select disableRipple [(ngModel)]=\"filterCategory\" name=\"category\">\n <mat-option *ngFor=\"let category of categories\" [value]=\"category\">{{category.name}}</mat-option>\n </mat-select>\n </mat-form-field> \n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onCleanFilter()\">Limpiar</button> \n <button mat-stroked-button (click)=\"onSearch()\">Filtrar</button> \n </div> \n </div> \n\n <div class=\"game-list\">\n<app-game-item *ngFor=\"let game of games; let i = index;\" (click)=\"editGame(game)\" [game]=\"game\">\n</app-game-item>\n </div>\n\n <div class=\"buttons\">\n <button mat-flat-button color=\"primary\" (click)=\"createGame()\">Nuevo juego</button> \n </div> \n</div>\n
Tambi\u00e9n vamos a necesitar una foto de ejemplo para poner dentro de la tarjeta detalle de los juegos. Vamos a utilizar esta imagen:
Desc\u00e1rgala y d\u00e9jala dentro del proyecto en assets/foto.png
. Y ya para terminar, implementamos el componente de detalle:
<div class=\"container\">\n <mat-card>\n <div class=\"photo\">\n <img src=\"./assets/foto.png\">\n </div>\n <div class=\"detail\">\n <div class=\"title\">{{game.title}}</div>\n <div class=\"properties\">\n <div><i>Edad recomendada: </i>+{{game.age}}</div>\n <div><i>Categor\u00eda: </i>{{game.category.name}}</div>\n <div><i>Autor: </i>{{game.author.name}}</div>\n <div><i>Nacionalidad: </i>{{game.author.nationality}}</div>\n </div>\n </div>\n </mat-card>\n</div>\n
.container {\ndisplay: flex;\nwidth: 325px;\n\nmat-card {\nwidth: 100%;\nmargin: 10px;\ndisplay: flex;\n\n.photo {\nmargin-right: 10px;\n\nimg {\nwidth: 80px;\nheight: 80px;\n}\n}\n\n.detail {\n.title {\nfont-size: 14px;\nfont-weight: bold;\n}\n\n.properties {\nfont-size: 11px;\n\ndiv {\nheight: 15px;\n} }\n}\n}\n}
import { Component, OnInit, Input } from '@angular/core';\nimport { Game } from '../../model/Game';\n\n@Component({\nselector: 'app-game-item',\ntemplateUrl: './game-item.component.html',\nstyleUrls: ['./game-item.component.scss']\n})\nexport class GameItemComponent implements OnInit {\n\n@Input() game: Game;\nconstructor() { }\n\nngOnInit(): void {\n}\n\n}\n
Ahora si que deber\u00eda quedar algo similar a esta pantalla:
"},{"location":"develop/filtered/angular/#implementar-dialogo-de-edicion","title":"Implementar dialogo de edici\u00f3n","text":"Ya solo nos falta el \u00faltimo paso, implementar el cuadro de edici\u00f3n / alta de un nuevo juego. Pero tenemos un peque\u00f1o problema, y es que al crear o editar un juego debemos seleccionar una Categor\u00eda
y un Autor
.
Para la Categor\u00eda
no tenemos ning\u00fan problema, pero para el Autor
no tenemos un servicio que nos devuelva todos los autores, solo tenemos un servicio que nos devuelve una Page
de autores.
As\u00ed que lo primero que haremos ser\u00e1 implementar una operaci\u00f3n getAllAuthors
para poder recuperar una lista.
import { Author } from \"./Author\";\n\nexport const AUTHOR_DATA_LIST : Author[] = [\n{ id: 1, name: 'Klaus Teuber', nationality: 'Alemania' },\n{ id: 2, name: 'Matt Leacock', nationality: 'Estados Unidos' },\n{ id: 3, name: 'Keng Leong Yeo', nationality: 'Singapur' },\n{ id: 4, name: 'Gil Hova', nationality: 'Estados Unidos'},\n{ id: 5, name: 'Kelly Adams', nationality: 'Estados Unidos' },\n]
import { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Pageable } from '../core/model/page/Pageable';\nimport { Author } from './model/Author';\nimport { AuthorPage } from './model/AuthorPage';\nimport { AUTHOR_DATA_LIST } from './model/mock-authors-list';\n@Injectable({\nprovidedIn: 'root'\n})\nexport class AuthorService {\n\nconstructor(\nprivate http: HttpClient\n) { }\n\ngetAuthors(pageable: Pageable): Observable<AuthorPage> {\nreturn this.http.post<AuthorPage>('http://localhost:8080/author', {pageable:pageable});\n}\n\nsaveAuthor(author: Author): Observable<void> {\n\nlet url = 'http://localhost:8080/author';\nif (author.id != null) url += '/'+author.id;\n\nreturn this.http.put<void>(url, author);\n}\n\ndeleteAuthor(idAuthor : number): Observable<void> {\nreturn this.http.delete<void>('http://localhost:8080/author/'+idAuthor);\n} getAllAuthors(): Observable<Author[]> {\nreturn of(AUTHOR_DATA_LIST);\n}\n}\n
Ahora s\u00ed que tenemos todo listo para implementar el cuadro de dialogo para dar de alta o editar juegos.
game-edit.component.htmlgame-edit.component.scssgame-edit.component.ts<div class=\"container\">\n <h1 *ngIf=\"game.id == null\">Crear juego</h1>\n <h1 *ngIf=\"game.id != null\">Modificar juego</h1>\n\n <form>\n <mat-form-field>\n <mat-label>Identificador</mat-label>\n <input type=\"text\" matInput placeholder=\"Identificador\" [(ngModel)]=\"game.id\" name=\"id\" disabled>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>T\u00edtulo</mat-label>\n <input type=\"text\" matInput placeholder=\"T\u00edtulo del juego\" [(ngModel)]=\"game.title\" name=\"title\" required>\n <mat-error>El t\u00edtulo no puede estar vac\u00edo</mat-error>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Edad recomendada</mat-label>\n <input type=\"number\" matInput placeholder=\"Edad recomendada\" [(ngModel)]=\"game.age\" name=\"age\" required>\n <mat-error>La edad no puede estar vac\u00eda</mat-error>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Categor\u00eda</mat-label>\n <mat-select disableRipple [(ngModel)]=\"game.category\" name=\"category\" required>\n <mat-option *ngFor=\"let category of categories\" [value]=\"category\">{{category.name}}</mat-option>\n </mat-select>\n <mat-error>La categor\u00eda no puede estar vac\u00eda</mat-error>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Autor</mat-label>\n <mat-select disableRipple [(ngModel)]=\"game.author\" name=\"author\" required>\n <mat-option *ngFor=\"let author of authors\" [value]=\"author\">{{author.name}}</mat-option>\n </mat-select>\n <mat-error>El autor no puede estar vac\u00edo</mat-error>\n </mat-form-field>\n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onClose()\">Cerrar</button>\n <button mat-flat-button color=\"primary\" (click)=\"onSave()\">Guardar</button>\n </div>\n</div>\n
.container {\nmin-width: 350px;\nmax-width: 500px;\npadding: 20px;\n\nform {\ndisplay: flex;\nflex-direction: column;\nmargin-bottom:20px;\n}\n\n.buttons {\ntext-align: right;\n\nbutton {\nmargin-left: 10px;\n}\n}\n}\n
import { Component, Inject, OnInit } from '@angular/core';\nimport { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';\nimport { AuthorService } from 'src/app/author/author.service';\nimport { Author } from 'src/app/author/model/Author';\nimport { CategoryService } from 'src/app/category/category.service';\nimport { Category } from 'src/app/category/model/Category';\nimport { GameService } from '../game.service';\nimport { Game } from '../model/Game';\n\n@Component({\nselector: 'app-game-edit',\ntemplateUrl: './game-edit.component.html',\nstyleUrls: ['./game-edit.component.scss']\n})\nexport class GameEditComponent implements OnInit {\n\ngame: Game; authors: Author[];\ncategories: Category[];\n\nconstructor(\npublic dialogRef: MatDialogRef<GameEditComponent>,\n@Inject(MAT_DIALOG_DATA) public data: any,\nprivate gameService: GameService,\nprivate categoryService: CategoryService,\nprivate authorService: AuthorService,\n) { }\n\nngOnInit(): void {\nif (this.data.game != null) {\nthis.game = Object.assign({}, this.data.game);\n}\nelse {\nthis.game = new Game();\n}\n\nthis.categoryService.getCategories().subscribe(\ncategories => {\nthis.categories = categories;\n\nif (this.game.category != null) {\nlet categoryFilter: Category[] = categories.filter(category => category.id == this.data.game.category.id);\nif (categoryFilter != null) {\nthis.game.category = categoryFilter[0];\n}\n}\n}\n);\n\nthis.authorService.getAllAuthors().subscribe(\nauthors => {\nthis.authors = authors\n\nif (this.game.author != null) {\nlet authorFilter: Author[] = authors.filter(author => author.id == this.data.game.author.id);\nif (authorFilter != null) {\nthis.game.author = authorFilter[0];\n}\n}\n}\n);\n}\n\nonSave() {\nthis.gameService.saveGame(this.game).subscribe(result => {\nthis.dialogRef.close();\n}); } onClose() {\nthis.dialogRef.close();\n}\n\n}\n
Como puedes ver, para rellenar los componentes seleccionables de dropdown, hemos realizado una consulta al servicio para recuperar todos los autores y categorias, y en la respuesta de cada uno de ellos, hemos buscado en los resultados cual es el que coincide con el ID enviado desde el listado, y ese es el que hemos fijado en el objeto Game
.
De esta forma, no estamos cogiendo directamente los datos del listado, sino que no estamos asegurando que los datos de autor y de categor\u00eda son los que vienen del servicio, siempre filtrando por su ID.
"},{"location":"develop/filtered/angular/#conectar-con-backend","title":"Conectar con Backend","text":"Antes de seguir
Antes de seguir con este punto, debes implementar el c\u00f3digo de backend en la tecnolog\u00eda que quieras (Springboot o Nodejs). Si has empezado este cap\u00edtulo implementando el frontend, por favor accede a la secci\u00f3n correspondiente de backend para poder continuar con el tutorial. Una vez tengas implementadas todas las operaciones para este listado, puedes volver a este punto y continuar con Angular.
Una vez implementado front y back, lo que nos queda es modificar el servicio del front para que conecte directamente con las operaciones ofrecidas por el back.
author-service.tsgame-service.tsimport { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Pageable } from '../core/model/page/Pageable';\nimport { Author } from './model/Author';\nimport { AuthorPage } from './model/AuthorPage';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class AuthorService {\n\nconstructor(\nprivate http: HttpClient\n) { }\n\ngetAuthors(pageable: Pageable): Observable<AuthorPage> {\nreturn this.http.post<AuthorPage>('http://localhost:8080/author', {pageable:pageable});\n}\n\nsaveAuthor(author: Author): Observable<void> {\n\nlet url = 'http://localhost:8080/author';\nif (author.id != null) url += '/'+author.id;\n\nreturn this.http.put<void>(url, author);\n}\n\ndeleteAuthor(idAuthor : number): Observable<void> {\nreturn this.http.delete<void>('http://localhost:8080/author/'+idAuthor);\n} getAllAuthors(): Observable<Author[]> {\nreturn this.http.get<Author[]>('http://localhost:8080/author');\n}\n}\n
import { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Game } from './model/Game';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class GameService {\n\nconstructor(\nprivate http: HttpClient\n) { }\n\ngetGames(title?: String, categoryId?: number): Observable<Game[]> { return this.http.get<Game[]>(this.composeFindUrl(title, categoryId));\n}\n\nsaveGame(game: Game): Observable<void> {\nlet url = 'http://localhost:8080/game';\nif (game.id != null) {\nurl += '/'+game.id;\n}\nreturn this.http.put<void>(url, game);\n}\n\nprivate composeFindUrl(title?: String, categoryId?: number) : string {\nlet params = '';\nif (title != null) {\nparams += 'title='+title;\n}\nif (categoryId != null) {\nif (params != '') params += \"&\";\nparams += \"idCategory=\"+categoryId;\n}\nlet url = 'http://localhost:8080/game'\nif (params == '') return url;\nelse return url + '?'+params;\n}\n}\n
Y ahora si, podemos navegar por la web y ver el resultado completo.
"},{"location":"develop/filtered/nodejs/","title":"Listado simple - Nodejs","text":"En este punto ya tenemos dos listados, uno b\u00e1sico y otro paginado. Ahora vamos a implementar un listado un poco diferente, este listado va a tener filtros de b\u00fasqueda.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/filtered/nodejs/#crear-modelos","title":"Crear Modelos","text":"Lo primero que vamos a hacer es crear el modelo de author para trabajar con BBDD. En la carpeta schemas creamos el archivo game.schema.js
:
import mongoose from \"mongoose\";\nconst { Schema, model } = mongoose;\nimport normalize from 'normalize-mongoose';\n\nconst gameSchema = new Schema({\ntitle: {\ntype: String,\nrequire: true\n},\nage: {\ntype: Number,\nrequire: true,\nmax: 99,\nmin: 0\n},\ncategory: {\ntype: Schema.Types.ObjectId,\nref: 'Category',\nrequired: true\n},\nauthor: {\ntype: Schema.Types.ObjectId,\nref: 'Author',\nrequired: true\n}\n});\n\ngameSchema.plugin(normalize);\nconst GameModel = model('Game', gameSchema);\n\nexport default GameModel;\n
Lo m\u00e1s novedoso aqu\u00ed es que ahora cada juego va a tener una categor\u00eda y un autor asociados. Para ello simplemente en el tipo del dato Category
y Author
tenemos que hacer referencia al id del esquema deseado.
Creamos el service correspondiente game.service.js
:
import GameModel from '../schemas/game.schema.js';\n\nexport const getGames = async (title, category) => {\ntry {\nconst regexTitle = new RegExp(title, 'i');\nconst find = category? { $and: [{ title: regexTitle }, { category: category }] } : { title: regexTitle };\nreturn await GameModel.find(find).sort('id').populate('category').populate('author');\n} catch(e) {\nthrow Error('Error fetching games');\n}\n}\n\nexport const createGame = async (data) => {\ntry {\nconst game = new GameModel({\n...data,\ncategory: data.category.id,\nauthor: data.author.id,\n});\nreturn await game.save();\n} catch (e) {\nthrow Error('Error creating game');\n}\n}\n\nexport const updateGame = async (id, data) => {\ntry {\nconst game = await GameModel.findById(id);\nif (!game) {\nthrow Error('There is no game with that Id');\n} const gameToUpdate = {\n...data,\ncategory: data.category.id,\nauthor: data.author.id,\n};\nreturn await GameModel.findByIdAndUpdate(id, gameToUpdate, { new: false });\n} catch (e) {\nthrow Error(e);\n}\n}\n
En este caso recibimos en el m\u00e9todo para recuperar juegos dos par\u00e1metros, el titulo del juego y la categor\u00eda. Aqu\u00ed vamos a utilizar una expresi\u00f3n regular para que podamos encontrar cualquier juego que contenga el titulo que pasemos en su nombre. Con la categor\u00eda tiene que ser el valor exacto de su id. El m\u00e9todo populate lo que hace es traernos toda la informaci\u00f3n de la categor\u00eda y del autor. Sino lo us\u00e1semos solo nos recuperar\u00eda el id.
"},{"location":"develop/filtered/nodejs/#implementar-el-controller","title":"Implementar el Controller","text":"Creamos el controlador game.controller.js
:
import * as GameService from '../services/game.service.js';\n\nexport const getGames = async (req, res) => {\ntry {\nconst titleToFind = req.query?.title || '';\nconst categoryToFind = req.query?.idCategory || null;\nconst games = await GameService.getGames(titleToFind, categoryToFind);\nres.status(200).json(games);\n} catch(err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const createGame = async (req, res) => {\ntry {\nconst game = await GameService.createGame(req.body);\nres.status(200).json({\ngame\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const updateGame = async (req, res) => {\nconst gameId = req.params.id;\ntry {\nawait GameService.updateGame(gameId, req.body);\nres.status(200).json(1);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Los m\u00e9todos son muy parecidos al resto de los controllers. En este caso para recuperar los datos del filtro tendremos que hacerlo con req.query
para leer los datos que nos lleguen como query params en la url. Por ejemplo: http://localhost:8080/game?title=trivial&category=1
Y por \u00faltimo creamos nuestro archivo de rutas game.routes.js
:
import { Router } from 'express';\nimport { check } from 'express-validator';\nimport validateFields from '../middlewares/validateFields.js';\nimport { createGame, getGames, updateGame } from '../controllers/game.controller.js';\nconst gameRouter = Router();\n\ngameRouter.put('/:id', [\ncheck('title').not().isEmpty(),\ncheck('age').not().isEmpty(),\ncheck('age').isNumeric(),\ncheck('category.id').not().isEmpty(),\ncheck('author.id').not().isEmpty(),\nvalidateFields\n], updateGame);\n\ngameRouter.put('/', [\ncheck('title').not().isEmpty(),\ncheck('age').not().isEmpty(),\ncheck('age').isNumeric(),\ncheck('category.id').not().isEmpty(),\ncheck('author.id').not().isEmpty(),\nvalidateFields\n], createGame);\n\ngameRouter.get('/', getGames);\ngameRouter.get('/:query', getGames);\n\nexport default gameRouter;\n
En este caso hemos tenido que meter dos rutas para get
, una para cuando se informen los filtros y otra para cuando no vayan informados. Si lo hici\u00e9ramos con una \u00fanica ruta nos fallar\u00eda en el otro caso.
Finalmente en nuestro archivo index.js
vamos a a\u00f1adir el nuevo router:
...\n\nimport gameRouter from './src/routes/game.routes.js';\n\n...\n\napp.use('/game', gameRouter);\n\n...\n
"},{"location":"develop/filtered/nodejs/#probar-las-operaciones","title":"Probar las operaciones","text":"Y ahora que tenemos todo creado, ya podemos probarlo con Postman:
Por un lado creamos juegos con:
** PUT /game **
** PUT /game/{id} **
{\n\"title\": \"Nuevo juego\",\n\"age\": \"18\",\n\"category\": {\n\"id\": \"63e8b795f7dae4b980b63202\"\n},\n\"author\": {\n\"id\": \"63e8bda064c208e065667bfa\"\n}\n}\n
Tambi\u00e9n podemos filtrar y recuperar informaci\u00f3n:
** GET /game **
** GET /game?title=xxx **
** GET /game?idCategory=xxx **
"},{"location":"develop/filtered/nodejs/#implementar-validaciones","title":"Implementar validaciones","text":"Ahora que ya tenemos todos nuestros CRUDs creados vamos a introducir unas peque\u00f1as validaciones.
"},{"location":"develop/filtered/nodejs/#validacion-en-borrado","title":"Validaci\u00f3n en borrado","text":"La primera validaci\u00f3n sera para que no podamos borrar categor\u00edas ni autores que tengan un juego asociado. Para ello primero tendremos que crear un m\u00e9todo en el servicio de juegos para buscar los juegos que correspondan con un campo dado. En game.service.js
a\u00f1adimos:
...\nexport const getGame = async (field) => {\ntry {\nreturn await GameModel.find(field);\n} catch (e) {\nthrow Error('Error fetching games');\n}\n}\n...\n
Y ahora en category.service.js
importamos el m\u00e9todo creado y modificamos el m\u00e9todo para borrar categor\u00edas:
...\nimport { getGame } from './game.service.js';\n...\n\n...\nexport const deleteCategory = async (id) => {\ntry {\nconst category = await CategoryModel.findById(id);\nif (!category) {\nthrow 'There is no category with that Id';\n}\nconst games = await getGame({category});\nif(games.length > 0) {\nthrow 'There are games related to this category';\n}\nreturn await CategoryModel.findByIdAndDelete(id);\n} catch (e) {\nthrow Error(e);\n}\n}\n...\n
De este modo si encontramos alg\u00fan juego con esta categor\u00eda no nos dejar\u00e1 borrarla.
Por \u00faltimo, hacemos lo mismo en author.service.js
:
...\nimport { getGame } from './game.service.js';\n...\n\n...\nexport const deleteAuthor = async (id) => {\ntry {\nconst author = await AuthorModel.findById(id);\nif (!author) {\nthrow 'There is no author with that Id';\n}\nconst games = await getGame({author});\nif(games.length > 0) {\nthrow 'There are games related to this author';\n}\nreturn await AuthorModel.findByIdAndDelete(id);\n} catch (e) {\nthrow Error(e);\n}\n}\n...\n
"},{"location":"develop/filtered/nodejs/#validacion-en-creacion","title":"Validaci\u00f3n en creaci\u00f3n","text":"En las creaciones es conveniente validad la existencia de las entidades relacionadas para garantizar la integridad de la BBDD.
Para esto vamos a introducir una validaci\u00f3n en la creaci\u00f3n y edici\u00f3n de los juegos para garantizar que la categor\u00eda y el autor proporcionados existen.
En primer lugar vamos a crear los servicios de consulta de categor\u00eda y autor:
category.service.js...\nexport const getCategory = async (id) => {\ntry {\nreturn await CategoryModel.findById(id);\n} catch (e) {\nthrow Error('There is no category with that Id');\n}\n}\n...\n
author.service.js ...\nexport const getAuthor = async (id) => {\ntry {\nreturn await AuthorModel.findById(id);\n} catch (e) {\nthrow Error('There is no author with that Id');\n}\n}\n...\n
Teniendo los servicios ya disponibles, vamos a a\u00f1adir las validaciones a los servicios de creaci\u00f3n y edici\u00f3n:
game.service.js...\nimport { getCategory } from './category.service.js';\nimport { getAuthor } from './author.service.js';\n...\n\n...\nexport const createGame = async (data) => {\ntry {\nconst category = await getCategory(data.category.id);\nif (!category) {\nthrow Error('There is no category with that Id');\n}\n\nconst author = await getAuthor(data.author.id);\nif (!author) {\nthrow Error('There is no author with that Id');\n}\n\nconst game = new GameModel({\n...data,\ncategory: data.category.id,\nauthor: data.author.id,\n});\nreturn await game.save();\n} catch (e) {\nthrow Error(e);\n}\n}\n...\n\n...\nexport const updateGame = async (id, data) => {\ntry {\nconst game = await GameModel.findById(id);\nif (!game) {\nthrow Error('There is no game with that Id');\n}\n\nconst category = await getCategory(data.category.id);\nif (!category) {\nthrow Error('There is no category with that Id');\n}\n\nconst author = await getAuthor(data.author.id);\nif (!author) {\nthrow Error('There is no author with that Id');\n}\n\nconst gameToUpdate = {\n...data,\ncategory: data.category.id,\nauthor: data.author.id,\n};\nreturn await GameModel.findByIdAndUpdate(id, gameToUpdate, { new: false });\n} catch (e) {\nthrow Error(e);\n}\n}\n...\n
Con esto ya tendr\u00edamos acabado nuestro CRUD.
"},{"location":"develop/filtered/react/","title":"Listado filtrado - Angular","text":"En este punto ya tenemos dos listados, uno b\u00e1sico y otro paginado. Ahora vamos a implementar un listado un poco diferente, con filtros y con una presentaci\u00f3n un tanto distinta.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
Vamos a desarrollar el listado de Juegos
. Este listado es un tanto peculiar, porque no tiene una tabla como tal, sino que vamos a mostrar los juegos como cards. Ya tenemos creado nuestro componentes pagina pero vamos a necesitar un componente para mostrar cada uno de los juegos y otro para crear y editar los juegos.
Manos a la obra:
Creamos el fichero Game.ts
dentro de la carpeta types
:
import { Category } from \"./Category\";\nimport { Author } from \"./Author\";\n\nexport interface Game {\nid: string;\ntitle: string;\nage: number;\ncategory?: Category;\nauthor?: Author;\n}\n
Modificamos nuestra api de Toolkit
para a\u00f1adir los endpoints
de juegos y aparte creamos un endpoint
para recuperar los autores que necesitaremos para crear un nuevo juego, el fichero completo quedar\u00eda de esta manera:
import { createApi, fetchBaseQuery } from \"@reduxjs/toolkit/query/react\";\nimport { Game } from \"../../types/Game\";\nimport { Category } from \"../../types/Category\";\nimport { Author, AuthorResponse } from \"../../types/Author\";\n\nexport const ludotecaAPI = createApi({\nreducerPath: \"ludotecaApi\",\nbaseQuery: fetchBaseQuery({\nbaseUrl: \"http://localhost:8080\",\n}),\ntagTypes: [\"Category\", \"Author\", \"Game\"],\nendpoints: (builder) => ({\ngetCategories: builder.query<Category[], null>({\nquery: () => \"category\",\nprovidesTags: [\"Category\"],\n}),\ncreateCategory: builder.mutation({\nquery: (payload) => ({\nurl: \"/category\",\nmethod: \"PUT\",\nbody: payload,\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Category\"],\n}),\ndeleteCategory: builder.mutation({\nquery: (id: string) => ({\nurl: `/category/${id}`,\nmethod: \"DELETE\",\n}),\ninvalidatesTags: [\"Category\"],\n}),\nupdateCategory: builder.mutation({\nquery: (payload: Category) => ({\nurl: `category/${payload.id}`,\nmethod: \"PUT\",\nbody: payload,\n}),\ninvalidatesTags: [\"Category\"],\n}),\ngetAllAuthors: builder.query<Author[], null>({\nquery: () => \"author\",\nprovidesTags: [\"Author\"],\n}),\ngetAuthors: builder.query<\nAuthorResponse,\n{ pageNumber: number; pageSize: number }\n>({\nquery: ({ pageNumber, pageSize }) => {\nreturn {\nurl: \"author/\",\nmethod: \"POST\",\nbody: {\npageable: {\npageNumber,\npageSize,\n},\n},\n};\n},\nprovidesTags: [\"Author\"],\n}),\ncreateAuthor: builder.mutation({\nquery: (payload) => ({\nurl: \"/author\",\nmethod: \"PUT\",\nbody: payload,\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Author\"],\n}),\ndeleteAuthor: builder.mutation({\nquery: (id: string) => ({\nurl: `/author/${id}`,\nmethod: \"DELETE\",\n}),\ninvalidatesTags: [\"Author\"],\n}),\nupdateAuthor: builder.mutation({\nquery: (payload: Author) => ({\nurl: `author/${payload.id}`,\nmethod: \"PUT\",\nbody: payload,\n}),\ninvalidatesTags: [\"Author\", \"Game\"],\n}),\ngetGames: builder.query<Game[], { title: string; idCategory: string }>({\nquery: ({ title, idCategory }) => {\nreturn {\nurl: \"game/\",\nparams: { title, idCategory },\n};\n},\nprovidesTags: [\"Game\"],\n}),\ncreateGame: builder.mutation({\nquery: (payload: Game) => ({\nurl: \"/game\",\nmethod: \"PUT\",\nbody: { ...payload },\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Game\"],\n}),\nupdateGame: builder.mutation({\nquery: (payload: Game) => ({\nurl: `game/${payload.id}`,\nmethod: \"PUT\",\nbody: { ...payload },\n}),\ninvalidatesTags: [\"Game\"],\n}),\n\n}),\n});\n\nexport const {\nuseGetCategoriesQuery,\nuseCreateCategoryMutation,\nuseDeleteCategoryMutation,\nuseUpdateCategoryMutation,\nuseCreateAuthorMutation,\nuseDeleteAuthorMutation,\nuseGetAllAuthorsQuery,\nuseGetAuthorsQuery,\nuseUpdateAuthorMutation,\nuseCreateGameMutation,\nuseGetGamesQuery,\nuseUpdateGameMutation\n} = ludotecaAPI;\n
Creamos una nueva carpeta components
dentro de src/pages/Game
y dentro creamos un archivo llamado CreateGame.tsx
con el siguiente contenido:
import { ChangeEvent, useContext, useEffect, useState } from \"react\";\nimport Button from \"@mui/material/Button\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport TextField from \"@mui/material/TextField\";\nimport Dialog from \"@mui/material/Dialog\";\nimport DialogActions from \"@mui/material/DialogActions\";\nimport DialogContent from \"@mui/material/DialogContent\";\nimport DialogTitle from \"@mui/material/DialogTitle\";\nimport {\nuseGetAllAuthorsQuery,\nuseGetCategoriesQuery,\n} from \"../../../redux/services/ludotecaApi\";\nimport { LoaderContext } from \"../../../context/LoaderProvider\";\nimport { Game } from \"../../../types/Game\";\nimport { Category } from \"../../../types/Category\";\nimport { Author } from \"../../../types/Author\";\n\ninterface Props {\ngame: Game | null;\ncloseModal: () => void;\ncreate: (game: Game) => void;\n}\n\nconst initialState = {\nid: \"\",\ntitle: \"\",\nage: 0,\ncategory: undefined,\nauthor: undefined,\n};\n\nexport default function CreateGame(props: Props) {\nconst [form, setForm] = useState<Game>(initialState);\nconst loader = useContext(LoaderContext);\nconst { data: categories, isLoading: isLoadingCategories } =\nuseGetCategoriesQuery(null);\nconst { data: authors, isLoading: isLoadingAuthors } =\nuseGetAllAuthorsQuery(null);\n\nuseEffect(() => {\nsetForm({\nid: props.game?.id || \"\",\ntitle: props.game?.title || \"\",\nage: props.game?.age || 0,\ncategory: props.game?.category,\nauthor: props.game?.author,\n});\n}, [props?.game]);\n\nuseEffect(() => {\nloader.showLoading(isLoadingCategories || isLoadingAuthors);\n}, [isLoadingCategories, isLoadingAuthors]);\n\nconst handleChangeForm = (\nevent: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>\n) => {\nsetForm({\n...form,\n[event.target.id]: event.target.value,\n});\n};\n\nconst handleChangeSelect = (\nevent: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>\n) => {\nconst values = event.target.name === \"category\" ? categories : authors;\nsetForm({\n...form,\n[event.target.name]: values?.find((val) => val.id === event.target.value),\n});\n};\n\nreturn (\n<div>\n<Dialog open={true} onClose={props.closeModal}>\n<DialogTitle>\n{props.game ? \"Actualizar Juego\" : \"Crear Juego\"}\n</DialogTitle>\n<DialogContent>\n{props.game && (\n<TextField\nmargin=\"dense\"\ndisabled\nid=\"id\"\nlabel=\"Id\"\nfullWidth\nvalue={props.game.id}\nvariant=\"standard\"\n/>\n)}\n<TextField\nmargin=\"dense\"\nid=\"title\"\nlabel=\"Titulo\"\nfullWidth\nvariant=\"standard\"\nonChange={handleChangeForm}\nvalue={form.title}\n/>\n<TextField\nmargin=\"dense\"\nid=\"age\"\nlabel=\"Edad Recomendada\"\nfullWidth\ntype=\"number\"\nvariant=\"standard\"\nonChange={handleChangeForm}\nvalue={form.age}\n/>\n<TextField\nid=\"category\"\nselect\nlabel=\"Categor\u00eda\"\ndefaultValue=\"''\"\nfullWidth\nvariant=\"standard\"\nname=\"category\"\nvalue={form.category ? form.category.id : \"\"}\nonChange={handleChangeSelect}\n>\n{categories &&\ncategories.map((option: Category) => (\n<MenuItem key={option.id} value={option.id}>\n{option.name}\n</MenuItem>\n))}\n</TextField>\n<TextField\nid=\"author\"\nselect\nlabel=\"Autor\"\ndefaultValue=\"''\"\nfullWidth\nvariant=\"standard\"\nname=\"author\"\nvalue={form.author ? form.author.id : \"\"}\nonChange={handleChangeSelect}\n>\n{authors &&\nauthors.map((option: Author) => (\n<MenuItem key={option.id} value={option.id}>\n{option.name}\n</MenuItem>\n))}\n</TextField>\n</DialogContent>\n<DialogActions>\n<Button onClick={props.closeModal}>Cancelar</Button>\n<Button\nonClick={() =>\nprops.create({\nid: \"\",\ntitle: form.title,\nage: form.age,\ncategory: form.category,\nauthor: form.author,\n})\n}\ndisabled={\n!form.title || !form.age || !form.category || !form.author\n}\n>\n{props.game ? \"Actualizar\" : \"Crear\"}\n</Button>\n</DialogActions>\n</Dialog>\n</div>\n);\n}\n
Ahora en esa misma carpeta crearemos el componente GameCard.tsx
para mostrar nuestros juegos con un dise\u00f1o de carta:
import Card from \"@mui/material/Card\";\nimport CardContent from \"@mui/material/CardContent\";\nimport CardMedia from \"@mui/material/CardMedia\";\nimport CardHeader from \"@mui/material/CardHeader\";\nimport List from \"@mui/material/List\";\nimport ListItem from \"@mui/material/ListItem\";\nimport ListItemAvatar from \"@mui/material/ListItemAvatar\";\nimport ListItemText from \"@mui/material/ListItemText\";\nimport Avatar from \"@mui/material/Avatar\";\nimport PersonIcon from \"@mui/icons-material/Person\";\nimport LanguageIcon from \"@mui/icons-material/Language\";\nimport CardActionArea from \"@mui/material/CardActionArea\";\nimport red from \"@mui/material/colors/red\";\nimport imageGame from \"./../../../assets/foto.png\";\nimport { Game } from \"../../../types/Game\";\n\ninterface GameCardProps {\ngame: Game;\n}\n\nexport default function GameCard(props: GameCardProps) {\nconst { title, age, category, author } = props.game;\nreturn (\n<Card sx={{ maxWidth: 265 }}>\n<CardHeader\nsx={{\n\".MuiCardHeader-title\": {\nfontSize: \"20px\",\n},\n}}\navatar={\n<Avatar sx={{ bgcolor: red[500] }} aria-label=\"age\">\n+{age}\n</Avatar>\n}\ntitle={title}\nsubheader={category?.name}\n/>\n<CardActionArea>\n<CardMedia\ncomponent=\"img\"\nheight=\"140\"\nimage={imageGame}\nalt=\"game image\"\n/>\n<CardContent>\n<List dense={true}>\n<ListItem>\n<ListItemAvatar>\n<Avatar>\n<PersonIcon />\n</Avatar>\n</ListItemAvatar>\n<ListItemText primary={`Autor: ${author?.name}`} />\n</ListItem>\n<ListItem>\n<ListItemAvatar>\n<Avatar>\n<LanguageIcon />\n</Avatar>\n</ListItemAvatar>\n<ListItemText primary={`Nacionalidad: ${author?.nationality}`} />\n</ListItem>\n</List>\n</CardContent>\n</CardActionArea>\n</Card>\n);\n}\n
En la carpeta src/pages/game
vamos a crear un fichero para los estilos llamado Game.module.css
:
.filter {\ndisplay: flex;\nalign-items: center;\n}\n\n.cards {\ndisplay: flex;\ngap: 20px;\npadding: 10px;\nflex-wrap: wrap;\n}\n\n.card {\ncursor: pointer;\n}\n\n@media (max-width: 800px) {\n.cards {\ndisplay: flex;\nflex-direction: column;\nalign-items: center;\n}\n\n.filter {\ndisplay: flex;\nflex-direction: column;\n}\n}\n
Y por \u00faltimo modificamos nuestro componente p\u00e1gina Game
y lo dejamos de esta manera:
import { useState, useContext, useEffect } from \"react\";\nimport MenuItem from \"@mui/material/MenuItem\";\nimport FormControl from \"@mui/material/FormControl\";\nimport TextField from \"@mui/material/TextField\";\nimport Button from \"@mui/material/Button\";\nimport GameCard from \"./components/GameCard\";\nimport styles from \"./Game.module.css\";\nimport {\nuseCreateGameMutation,\nuseGetCategoriesQuery,\nuseGetGamesQuery,\nuseUpdateGameMutation,\n} from \"../../redux/services/ludotecaApi\";\nimport CreateGame from \"./components/CreateGame\";\nimport { LoaderContext } from \"../../context/LoaderProvider\";\nimport { useAppDispatch } from \"../../redux/hooks\";\nimport { setMessage } from \"../../redux/features/messagesSlice\";\nimport { Game as GameModel } from \"../../types/Game\";\nimport { Category } from \"../../types/Category\";\n\nexport const Game = () => {\nconst [openCreate, setOpenCreate] = useState(false);\nconst [filterTitle, setFilterTitle] = useState(\"\");\nconst [filterCategory, setFilterCategory] = useState(\"\");\nconst [gameToUpdate, setGameToUpdate] = useState<GameModel | null>(null);\nconst loader = useContext(LoaderContext);\nconst dispatch = useAppDispatch();\n\nconst { data, error, isLoading, isFetching } = useGetGamesQuery({\ntitle: filterTitle,\nidCategory: filterCategory,\n});\n\nconst [updateGameApi, { isLoading: isLoadingUpdate, error: errorUpdate }] =\nuseUpdateGameMutation();\n\nconst { data: categories } = useGetCategoriesQuery(null);\n\nconst [createGameApi, { isLoading: isLoadingCreate, error: errorCreate }] =\nuseCreateGameMutation();\n\nuseEffect(() => {\nloader.showLoading(\nisLoadingCreate || isLoadingUpdate || isLoading || isFetching\n);\n}, [isLoadingCreate, isLoadingUpdate, isLoading, isFetching]);\n\nuseEffect(() => {\nif (errorCreate || errorUpdate) {\nsetMessage({\ntext: \"Se ha producido un error al realizar la operaci\u00f3n\",\ntype: \"error\",\n});\n}\n}, [errorUpdate, errorCreate]);\n\nif (error) return <p>Error cargando!!!</p>;\n\nconst createGame = (game: GameModel) => {\nsetOpenCreate(false);\nif (gameToUpdate) {\nupdateGameApi({\n...game,\nid: gameToUpdate.id,\n})\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Juego actualizado correctamente\",\ntype: \"ok\",\n})\n);\nsetGameToUpdate(null);\n})\n.catch((err) => console.log(err));\n} else {\ncreateGameApi(game)\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Juego creado correctamente\",\ntype: \"ok\",\n})\n);\nsetGameToUpdate(null);\n})\n.catch((err) => console.log(err));\n}\n};\n\nreturn (\n<div className=\"container\">\n<h1>Cat\u00e1logo de juegos</h1>\n<div className={styles.filter}>\n<FormControl variant=\"standard\" sx={{ m: 1, minWidth: 220 }}>\n<TextField\nmargin=\"dense\"\nid=\"title\"\nlabel=\"Titulo\"\nfullWidth\nvalue={filterTitle}\nvariant=\"standard\"\nonChange={(event) => setFilterTitle(event.target.value)}\n/>\n</FormControl>\n<FormControl variant=\"standard\" sx={{ m: 1, minWidth: 220 }}>\n<TextField\nid=\"category\"\nselect\nlabel=\"Categor\u00eda\"\ndefaultValue=\"''\"\nfullWidth\nvariant=\"standard\"\nname=\"author\"\nvalue={filterCategory}\nonChange={(event) => setFilterCategory(event.target.value)}\n>\n{categories &&\ncategories.map((option: Category) => (\n<MenuItem key={option.id} value={option.id}>\n{option.name}\n</MenuItem>\n))}\n</TextField>\n</FormControl>\n<Button\nvariant=\"outlined\"\nonClick={() => {\nsetFilterCategory(\"\");\nsetFilterTitle(\"\");\n}}\n>\nLimpiar\n</Button>\n</div>\n<div className={styles.cards}>\n{data?.map((card) => (\n<div\nkey={card.id}\nclassName={styles.card}\nonClick={() => {\nsetGameToUpdate(card);\nsetOpenCreate(true);\n}}\n>\n<GameCard game={card} />\n</div>\n))}\n</div>\n<div className=\"newButton\">\n<Button variant=\"contained\" onClick={() => setOpenCreate(true)}>\nNuevo juego\n</Button>\n</div>\n{openCreate && (\n<CreateGame\ncreate={createGame}\ngame={gameToUpdate}\ncloseModal={() => {\nsetGameToUpdate(null);\nsetOpenCreate(false);\n}}\n/>\n)}\n</div>\n);\n};\n
Y por \u00faltimo descargamos la siguiente imagen y la guardamos en la carpeta src/assets
.
En este listado realizamos el filtro de manera din\u00e1mica, en el momento en que cambiamos el valor de la categor\u00eda o el t\u00edtulo a filtrar, como estas variables est\u00e1n asociadas al estado de nuestro componente, se vuelve a renderizar y por lo tanto se actualiza el valor de \"data\" modificando as\u00ed los resultados.
El resto es muy parecido a lo que ya hemos realizado antes. Aqu\u00ed no tenemos una tabla, sino que mostramos nuestros juegos como Cards y si pulsamos sobre cualquier Card se mostrar\u00e1 el formulario de edici\u00f3n del juego.
Si ahora arrancamos el proyecto y nos vamos a la pagina de juegos podremos crear y ver nuestros juegos.
"},{"location":"develop/filtered/springboot/","title":"Listado filtrado - Spring Boot","text":"En este punto ya tenemos dos listados, uno b\u00e1sico y otro paginado. Ahora vamos a implementar un listado un poco diferente, este listado va a tener filtros de b\u00fasqueda.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/filtered/springboot/#crear-modelos","title":"Crear Modelos","text":"Lo primero que vamos a hacer es crear los modelos para trabajar con BBDD y con peticiones hacia el front. Adem\u00e1s, tambi\u00e9n tenemos que a\u00f1adir datos al script de inicializaci\u00f3n de BBDD.
Game.javaGameDto.javadata.sqlpackage com.ccsw.tutorial.game.model;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.category.model.Category;\n\nimport jakarta.persistence.*;\n\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"game\")\npublic class Game {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"title\", nullable = false)\nprivate String title;\n\n@Column(name = \"age\", nullable = false)\nprivate String age;\n\n@ManyToOne\n@JoinColumn(name = \"category_id\", nullable = false)\nprivate Category category;\n\n@ManyToOne\n@JoinColumn(name = \"author_id\", nullable = false)\nprivate Author author;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return category\n */\npublic Category getCategory() {\n\nreturn this.category;\n}\n\n/**\n * @param category new value of {@link #getCategory}.\n */\npublic void setCategory(Category category) {\n\nthis.category = category;\n}\n\n/**\n * @return author\n */\npublic Author getAuthor() {\n\nreturn this.author;\n}\n\n/**\n * @param author new value of {@link #getAuthor}.\n */\npublic void setAuthor(Author author) {\n\nthis.author = author;\n}\n\n}\n
package com.ccsw.tutorial.game.model;\n\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\n/**\n * @author ccsw\n *\n */\npublic class GameDto {\n\nprivate Long id;\n\nprivate String title;\n\nprivate String age;\n\nprivate CategoryDto category;\n\nprivate AuthorDto author;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return title\n */\npublic String getTitle() {\n\nreturn this.title;\n}\n\n/**\n * @param title new value of {@link #getTitle}.\n */\npublic void setTitle(String title) {\n\nthis.title = title;\n}\n\n/**\n * @return age\n */\npublic String getAge() {\n\nreturn this.age;\n}\n\n/**\n * @param age new value of {@link #getAge}.\n */\npublic void setAge(String age) {\n\nthis.age = age;\n}\n\n/**\n * @return category\n */\npublic CategoryDto getCategory() {\n\nreturn this.category;\n}\n\n/**\n * @param category new value of {@link #getCategory}.\n */\npublic void setCategory(CategoryDto category) {\n\nthis.category = category;\n}\n\n/**\n * @return author\n */\npublic AuthorDto getAuthor() {\n\nreturn this.author;\n}\n\n/**\n * @param author new value of {@link #getAuthor}.\n */\npublic void setAuthor(AuthorDto author) {\n\nthis.author = author;\n}\n\n}\n
INSERT INTO category(name) VALUES ('Eurogames');\nINSERT INTO category(name) VALUES ('Ameritrash');\nINSERT INTO category(name) VALUES ('Familiar');\n\nINSERT INTO author(name, nationality) VALUES ('Alan R. Moon', 'US');\nINSERT INTO author(name, nationality) VALUES ('Vital Lacerda', 'PT');\nINSERT INTO author(name, nationality) VALUES ('Simone Luciani', 'IT');\nINSERT INTO author(name, nationality) VALUES ('Perepau Llistosella', 'ES');\nINSERT INTO author(name, nationality) VALUES ('Michael Kiesling', 'DE');\nINSERT INTO author(name, nationality) VALUES ('Phil Walker-Harding', 'US');\n\nINSERT INTO game(title, age, category_id, author_id) VALUES ('On Mars', '14', 1, 2);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Aventureros al tren', '8', 3, 1);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('1920: Wall Street', '12', 1, 4);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Barrage', '14', 1, 3);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Los viajes de Marco Polo', '12', 1, 3);\nINSERT INTO game(title, age, category_id, author_id) VALUES ('Azul', '8', 3, 5);\n
Relaciones anidadas
F\u00edjate que tanto la Entity
como el Dto
tienen relaciones con Author
y Category
. Gracias a Spring JPA se pueden resolver de esta forma y tener toda la informaci\u00f3n de las relaciones hijas dentro del objeto padre. Muy importante recordar que en el mundo entity las relaciones ser\u00e1n con objetos Entity
mientras que en el mundo dto las relaciones deben ser siempre con objetos Dto
. La utilidad beanMapper ya har\u00e1 las conversiones necesarias, siempre que tengan el mismo nombre de propiedades.
Para desarrollar todas las operaciones, empezaremos primero dise\u00f1ando las pruebas y luego implementando el c\u00f3digo necesario que haga funcionar correctamente esas pruebas. Para ir m\u00e1s r\u00e1pido vamos a poner todas las pruebas de golpe, pero realmente se deber\u00edan crear una a una e ir implementando el c\u00f3digo necesario para esa prueba. Para evitar tantas iteraciones en el tutorial las haremos todas de golpe.
Vamos a pararnos a pensar un poco que necesitamos en la pantalla. En este caso solo tenemos dos operaciones:
De nuevo tendremos que desglosar esto en varios casos de prueba:
Tambi\u00e9n crearemos una clase GameController
dentro del package de com.ccsw.tutorial.game
con la implementaci\u00f3n de los m\u00e9todos vac\u00edos, para que no falle la compilaci\u00f3n.
\u00a1Vamos a implementar test!
GameController.javaGameIT.javapackage com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Game\", description = \"API of Game\")\n@RequestMapping(value = \"/game\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class GameController {\n\n/**\n * M\u00e9todo para recuperar una lista de {@link Game}\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link GameDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a filtered list of Games\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<GameDto> find(@RequestParam(value = \"title\", required = false) String title,\n@RequestParam(value = \"idCategory\", required = false) Long idCategory) {\n\nreturn null;\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Game}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Game\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody GameDto dto) {\n\n}\n\n}\n
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.web.client.TestRestTemplate;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.http.HttpEntity;\nimport org.springframework.http.HttpMethod;\nimport org.springframework.http.HttpStatus;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.test.annotation.DirtiesContext;\nimport org.springframework.web.util.UriComponentsBuilder;\n\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)\n@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)\npublic class GameIT {\n\npublic static final String LOCALHOST = \"http://localhost:\";\npublic static final String SERVICE_PATH = \"/game\";\n\npublic static final Long EXISTS_GAME_ID = 1L;\npublic static final Long NOT_EXISTS_GAME_ID = 0L;\nprivate static final String NOT_EXISTS_TITLE = \"NotExists\";\nprivate static final String EXISTS_TITLE = \"Aventureros\";\nprivate static final String NEW_TITLE = \"Nuevo juego\";\nprivate static final Long NOT_EXISTS_CATEGORY = 0L;\nprivate static final Long EXISTS_CATEGORY = 3L;\n\nprivate static final String TITLE_PARAM = \"title\";\nprivate static final String CATEGORY_ID_PARAM = \"idCategory\";\n\n@LocalServerPort\nprivate int port;\n\n@Autowired\nprivate TestRestTemplate restTemplate;\n\nParameterizedTypeReference<List<GameDto>> responseType = new ParameterizedTypeReference<List<GameDto>>(){};\n\nprivate String getUrlWithParams(){\nreturn UriComponentsBuilder.fromHttpUrl(LOCALHOST + port + SERVICE_PATH)\n.queryParam(TITLE_PARAM, \"{\" + TITLE_PARAM +\"}\")\n.queryParam(CATEGORY_ID_PARAM, \"{\" + CATEGORY_ID_PARAM +\"}\")\n.encode()\n.toUriString();\n}\n\n@Test\npublic void findWithoutFiltersShouldReturnAllGamesInDB() {\n\nint GAMES_WITH_FILTER = 6;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, null);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findExistsTitleShouldReturnGames() {\n\nint GAMES_WITH_FILTER = 1;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findExistsCategoryShouldReturnGames() {\n\nint GAMES_WITH_FILTER = 2;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, null);\nparams.put(CATEGORY_ID_PARAM, EXISTS_CATEGORY);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findExistsTitleAndCategoryShouldReturnGames() {\n\nint GAMES_WITH_FILTER = 1;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, EXISTS_CATEGORY);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findNotExistsTitleShouldReturnEmpty() {\n\nint GAMES_WITH_FILTER = 0;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, NOT_EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findNotExistsCategoryShouldReturnEmpty() {\n\nint GAMES_WITH_FILTER = 0;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, null);\nparams.put(CATEGORY_ID_PARAM, NOT_EXISTS_CATEGORY);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void findNotExistsTitleOrCategoryShouldReturnEmpty() {\n\nint GAMES_WITH_FILTER = 0;\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, NOT_EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, NOT_EXISTS_CATEGORY);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n\nparams.put(TITLE_PARAM, NOT_EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, EXISTS_CATEGORY);\n\nresponse = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n\nparams.put(TITLE_PARAM, EXISTS_TITLE);\nparams.put(CATEGORY_ID_PARAM, NOT_EXISTS_CATEGORY);\n\nresponse = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\nassertNotNull(response);\nassertEquals(GAMES_WITH_FILTER, response.getBody().size());\n}\n\n@Test\npublic void saveWithoutIdShouldCreateNewGame() {\n\nGameDto dto = new GameDto();\nAuthorDto authorDto = new AuthorDto();\nauthorDto.setId(1L);\n\nCategoryDto categoryDto = new CategoryDto();\ncategoryDto.setId(1L);\n\ndto.setTitle(NEW_TITLE);\ndto.setAge(\"18\");\ndto.setAuthor(authorDto);\ndto.setCategory(categoryDto);\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, NEW_TITLE);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(0, response.getBody().size());\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nresponse = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(1, response.getBody().size());\n}\n\n@Test\npublic void modifyWithExistIdShouldModifyGame() {\n\nGameDto dto = new GameDto();\nAuthorDto authorDto = new AuthorDto();\nauthorDto.setId(1L);\n\nCategoryDto categoryDto = new CategoryDto();\ncategoryDto.setId(1L);\n\ndto.setTitle(NEW_TITLE);\ndto.setAge(\"18\");\ndto.setAuthor(authorDto);\ndto.setCategory(categoryDto);\n\nMap<String, Object> params = new HashMap<>();\nparams.put(TITLE_PARAM, NEW_TITLE);\nparams.put(CATEGORY_ID_PARAM, null);\n\nResponseEntity<List<GameDto>> response = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(0, response.getBody().size());\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + EXISTS_GAME_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nresponse = restTemplate.exchange(getUrlWithParams(), HttpMethod.GET, null, responseType, params);\n\nassertNotNull(response);\nassertEquals(1, response.getBody().size());\nassertEquals(EXISTS_GAME_ID, response.getBody().get(0).getId());\n}\n\n@Test\npublic void modifyWithNotExistIdShouldThrowException() {\n\nGameDto dto = new GameDto();\ndto.setTitle(NEW_TITLE);\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + NOT_EXISTS_GAME_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n\n}\n
B\u00fasquedas en BBDD
Siempre deber\u00edamos buscar a los hijos por primary keys, nunca hay que hacerlo por una descripci\u00f3n libre, ya que el usuario podr\u00eda teclear el mismo nombre de diferentes formas y no habr\u00eda manera de buscar correctamente el resultado. As\u00ed que siempre que haya un dropdown, se debe filtrar por su ID.
Si ahora ejecutas los jUnits, ver\u00e1s que en este caso hemos construido 10 pruebas, para cubrir los casos b\u00e1sicos del Controller
, y todas ellas fallan la ejecuci\u00f3n. Vamos a seguir implementando el resto de capas para hacer que los test funcionen.
De nuevo para poder compilar esta capa, nos hace falta delegar sus operaciones de l\u00f3gica de negocio en un Service
as\u00ed que lo crearemos al mismo tiempo que lo vamos necesitando.
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameService {\n\n/**\n * Recupera los juegos filtrando opcionalmente por t\u00edtulo y/o categor\u00eda\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link Game}\n */\nList<Game> find(String title, Long idCategory);\n\n/**\n * Guarda o modifica un juego, dependiendo de si el identificador est\u00e1 o no informado\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, GameDto dto);\n\n}\n
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Game\", description = \"API of Game\")\n@RequestMapping(value = \"/game\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class GameController {\n\n@Autowired\nGameService gameService;\n@Autowired\nModelMapper mapper;\n/**\n * M\u00e9todo para recuperar una lista de {@link Game}\n *\n * @param title t\u00edtulo del juego\n * @param idCategory PK de la categor\u00eda\n * @return {@link List} de {@link GameDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a filtered list of Games\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<GameDto> find(@RequestParam(value = \"title\", required = false) String title,\n@RequestParam(value = \"idCategory\", required = false) Long idCategory) {\n\nList<Game> games = gameService.find(title, idCategory);\nreturn games.stream().map(e -> mapper.map(e, GameDto.class)).collect(Collectors.toList());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Game}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Game\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody GameDto dto) {\n\ngameService.save(id, dto);\n}\n\n}\n
En esta ocasi\u00f3n, para el m\u00e9todo de b\u00fasqueda hemos decidido utilizar par\u00e1metros en la URL de tal forma que nos quedar\u00e1 algo as\u00ed http://localhost:8080/game/?title=xxx&idCategoria=yyy
. Queremos recuperar el recurso Game
que es el raiz de la ruta, pero filtrado por cero o varios par\u00e1metros.
Siguiente paso, la capa de l\u00f3gica de negocio, es decir el Service
, que por tanto har\u00e1 uso de un Repository
.
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class GameServiceImpl implements GameService {\n\n@Autowired\nGameRepository gameRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Game> find(String title, Long idCategory) {\n\nreturn (List<Game>) this.gameRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, GameDto dto) {\n\nGame game;\n\nif (id == null) {\ngame = new Game();\n} else {\ngame = this.gameRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(dto, game, \"id\", \"author\", \"category\");\nthis.gameRepository.save(game);\n}\n\n}\n
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameRepository extends CrudRepository<Game, Long> {\n\n}\n
Este servicio tiene dos peculiaridades, remarcadas en amarillo en la clase anterior. Por un lado tenemos la consulta, que no es un listado completo ni un listado paginado, sino que es un listado con filtros. Luego veremos como se hace eso, de momento lo dejaremos como un m\u00e9todo que recibe los dos filtros.
La segunda peculiaridad es que de cliente nos est\u00e1 llegando un GameDto
, que internamente tiene un AuthorDto
y un CategoryDto
, pero nosotros lo tenemos que traducir a entidades de BBDD. No sirve con copiar las propiedades tal cual, ya que entonces Spring lo que har\u00e1 ser\u00e1 crear un objeto nuevo y persistir ese objeto nuevo de Author
y de Category
. Adem\u00e1s, de cliente generalmente tan solo nos llega el ID de esos objetos hijo, y no el resto de informaci\u00f3n de la entidad. Por esos motivos lo hemos ignorado del copyProperties.
Pero de alguna forma tendremos que asignarle esos valores a la entidad Game
. Si conocemos sus ID que es lo que generalmente llega, podemos recuperar esos objetos de BBDD y asignarlos en el objeto Game
. Si recuerdas las reglas b\u00e1sicas, un Repository
debe pertenecer a un solo Service
, por lo que en lugar de llamar a m\u00e9todos de los AuthorRepository
y CategoryRepository
desde nuestro GameServiceImpl
, debemos llamar a m\u00e9todos expuestos en AuthorService
y CategoryService
, que son los que gestionan sus repositorios. Para ello necesitaremos crear esos m\u00e9todos get en los otros Services
.
Y ya sabes, para implementar nuevos m\u00e9todos, antes se deben hacer las pruebas jUnit, que en este caso, por variar, cubriremos con pruebas unitarias. Recuerda que los test van en src/test/java
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport org.junit.jupiter.api.Test;\nimport org.junit.jupiter.api.extension.ExtendWith;\nimport org.mockito.InjectMocks;\nimport org.mockito.Mock;\nimport org.mockito.junit.jupiter.MockitoExtension;\n\nimport java.util.Optional;\n\nimport static org.junit.jupiter.api.Assertions.*;\nimport static org.mockito.Mockito.mock;\nimport static org.mockito.Mockito.when;\n\n@ExtendWith(MockitoExtension.class)\npublic class AuthorTest {\n\npublic static final Long EXISTS_AUTHOR_ID = 1L;\npublic static final Long NOT_EXISTS_AUTHOR_ID = 0L;\n\n@Mock\nprivate AuthorRepository authorRepository;\n\n@InjectMocks\nprivate AuthorServiceImpl authorService;\n\n@Test\npublic void getExistsAuthorIdShouldReturnAuthor() {\n\nAuthor author = mock(Author.class);\nwhen(author.getId()).thenReturn(EXISTS_AUTHOR_ID);\nwhen(authorRepository.findById(EXISTS_AUTHOR_ID)).thenReturn(Optional.of(author));\n\nAuthor authorResponse = authorService.get(EXISTS_AUTHOR_ID);\n\nassertNotNull(authorResponse);\n\nassertEquals(EXISTS_AUTHOR_ID, authorResponse.getId());\n}\n\n@Test\npublic void getNotExistsAuthorIdShouldReturnNull() {\n\nwhen(authorRepository.findById(NOT_EXISTS_AUTHOR_ID)).thenReturn(Optional.empty());\n\nAuthor author = authorService.get(NOT_EXISTS_AUTHOR_ID);\n\nassertNull(author);\n}\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport org.springframework.data.domain.Page;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorService {\n\n/**\n * Recupera un {@link Author} a trav\u00e9s de su ID\n *\n * @param id PK de la entidad\n * @return {@link Author}\n */\nAuthor get(Long id);\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findPage(AuthorSearchDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, AuthorDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class AuthorServiceImpl implements AuthorService {\n\n@Autowired\nAuthorRepository authorRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic Author get(Long id) {\nreturn this.authorRepository.findById(id).orElse(null);\n}\n/**\n * {@inheritDoc}\n */\n@Override\npublic Page<Author> findPage(AuthorSearchDto dto) {\n\nreturn this.authorRepository.findAll(dto.getPageable().getPageable());\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, AuthorDto data) {\n\nAuthor author;\n\nif (id == null) {\nauthor = new Author();\n} else {\nauthor = this.get(id);\n}\n\nBeanUtils.copyProperties(data, author, \"id\");\n\nthis.authorRepository.save(author);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.get(id) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.authorRepository.deleteById(id);\n}\n\n}\n
Y lo mismo para categor\u00edas.
CategoryTest.javaCategoryService.javaCategoryServiceImpl.javapublic static final Long NOT_EXISTS_CATEGORY_ID = 0L;\n\n@Test\npublic void getExistsCategoryIdShouldReturnCategory() {\n\nCategory category = mock(Category.class);\nwhen(category.getId()).thenReturn(EXISTS_CATEGORY_ID);\nwhen(categoryRepository.findById(EXISTS_CATEGORY_ID)).thenReturn(Optional.of(category));\n\nCategory categoryResponse = categoryService.get(EXISTS_CATEGORY_ID);\n\nassertNotNull(categoryResponse);\nassertEquals(EXISTS_CATEGORY_ID, category.getId());\n}\n\n@Test\npublic void getNotExistsCategoryIdShouldReturnNull() {\n\nwhen(categoryRepository.findById(NOT_EXISTS_CATEGORY_ID)).thenReturn(Optional.empty());\n\nCategory category = categoryService.get(NOT_EXISTS_CATEGORY_ID);\n\nassertNull(category);\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface CategoryService {\n\n/**\n * Recupera una {@link Category} a partir de su ID\n *\n * @param id PK de la entidad\n * @return {@link Category}\n */\nCategory get(Long id);\n/**\n * M\u00e9todo para recuperar todas las {@link Category}\n *\n * @return {@link List} de {@link Category}\n */\nList<Category> findAll();\n\n/**\n * M\u00e9todo para crear o actualizar una {@link Category}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, CategoryDto dto);\n\n/**\n * M\u00e9todo para borrar una {@link Category}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
package com.ccsw.tutorial.category;\n\nimport com.ccsw.tutorial.category.model.Category;\nimport com.ccsw.tutorial.category.model.CategoryDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class CategoryServiceImpl implements CategoryService {\n\n@Autowired\nCategoryRepository categoryRepository;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic Category get(Long id) {\nreturn this.categoryRepository.findById(id).orElse(null);\n}\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Category> findAll() {\n\nreturn (List<Category>) this.categoryRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, CategoryDto dto) {\n\nCategory category;\n\nif (id == null) {\ncategory = new Category();\n} else {\ncategory = this.get(id);\n}\n\ncategory.setName(dto.getName());\n\nthis.categoryRepository.save(category);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.get(id) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.categoryRepository.deleteById(id);\n}\n\n}\n
Clean Code
A la hora de implementar m\u00e9todos nuevos, ten siempre presente el Clean Code
. \u00a1No dupliques c\u00f3digo!, es muy importante de cara al futuro mantenimiento. Si en nuestro m\u00e9todo save
hac\u00edamos uso de una operaci\u00f3n findById
y ahora hemos creado una nueva operaci\u00f3n get
, hagamos uso de esta nueva operaci\u00f3n y no repitamos el c\u00f3digo.
Y ahora que ya tenemos los m\u00e9todos necesarios, ya podemos implementar correctamente nuestro GameServiceImpl
.
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.author.AuthorService;\nimport com.ccsw.tutorial.category.CategoryService;\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.stereotype.Service;\n\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class GameServiceImpl implements GameService {\n\n@Autowired\nGameRepository gameRepository;\n\n@Autowired\nAuthorService authorService;\n@Autowired\nCategoryService categoryService;\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Game> find(String title, Long idCategory) {\n\nreturn this.gameRepository.findAll();\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, GameDto dto) {\n\nGame game;\n\nif (id == null) {\ngame = new Game();\n} else {\ngame = this.gameRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(dto, game, \"id\", \"author\", \"category\");\n\ngame.setAuthor(authorService.get(dto.getAuthor().getId()));\ngame.setCategory(categoryService.get(dto.getCategory().getId()));\nthis.gameRepository.save(game);\n}\n\n}\n
Ahora si que tenemos la capa de l\u00f3gica de negocio terminada, podemos pasar a la siguiente capa.
"},{"location":"develop/filtered/springboot/#repository","title":"Repository","text":"Y llegamos a la \u00faltima capa donde, si recordamos, ten\u00edamos un m\u00e9todo que recibe dos par\u00e1metros. Necesitamos traducir esto en una consulta a la BBDD.
Vamos a necesitar un listado filtrado por t\u00edtulo o por categor\u00eda, as\u00ed que necesitaremos pasarle esos datos y filtrar la query. Para el t\u00edtulo vamos a buscar por una cadena contenida, as\u00ed que el par\u00e1metro ser\u00e1 de tipo String
, mientras que para la categor\u00eda vamos a buscar por su primary key, as\u00ed que el par\u00e1metro ser\u00e1 de tipo Long
.
Existen varias estrategias para abordar esta implementaci\u00f3n. Podr\u00edamos utilizar los QueryMethods para que Spring JPA haga su magia, pero en esta ocasi\u00f3n ser\u00eda bastante complicado encontrar un predicado correcto.
Tambi\u00e9n podr\u00edamos hacer una implementaci\u00f3n de la interface y hacer la consulta directamente con Criteria.
Por otro lado se podr\u00eda hacer uso de la anotaci\u00f3n @Query. Esta anotaci\u00f3n nos permite definir una consulta en SQL nativo o en JPQL (Java Persistence Query Language) y Spring JPA se encargar\u00e1 de realizar todo el mapeo y conversi\u00f3n de los datos de entrada y salida. Pero esta opci\u00f3n no es la m\u00e1s recomendable.
"},{"location":"develop/filtered/springboot/#specifications","title":"Specifications","text":"En este caso vamos a hacer uso de las Specifications que es la opci\u00f3n m\u00e1s robusta y no presenta acoplamientos con el tipo de BBDD.
Haciendo un resumen muy r\u00e1pido y con poco detalle, las Specifications
sirven para generar de forma robusta las clausulas where
de una consulta SQL. Estas clausulas se generar\u00e1n mediante Predicate
(predicados) que realizar\u00e1n operaciones de comparaci\u00f3n entre un campo y un valor.
En el siguiente ejemplo podemos verlo m\u00e1s claro: en la sentencia select * from
Table
where
name = 'b\u00fasqueda'
tenemos un solo predicado que es name = 'b\u00fasqueda'
. En ese predicado diferenciamos tres etiquetas:
name
\u2192 es el campo sobre el que hacemos el predicado=
\u2192 es la operaci\u00f3n que realizamos'b\u00fasqueda'
\u2192 es el valor con el que realizamos la operaci\u00f3nLo que trata de hacer Specifications
es agregar varios predicados con AND
o con OR
de forma tipada en c\u00f3digo. Y \u00bfqu\u00e9 intentamos conseguir con esta forma de programar?, pues f\u00e1cil, intentamos hacer que si cambiamos alg\u00fan tipo o el nombre de alguna propiedad involucrada en la query, nos salte un fallo en tiempo de compilaci\u00f3n y nos demos cuenta de donde est\u00e1 el error. Si utiliz\u00e1ramos queries construidas directamente con String
, al cambiar alg\u00fan tipo o el nombre de alguna propiedad involucrada, no nos dar\u00edamos cuenta hasta que saltara un fallo en tiempo de ejecuci\u00f3n.
Por este motivo hay que programar con Specifications
, porque son robustas ante cambios de c\u00f3digo y tenemos que tratar de evitar las construcciones a trav\u00e9s de cadenas de texto.
Dicho esto, \u00a1vamos a implementar!
Lo primero que necesitaremos ser\u00e1 una clase que nos permita guardar la informaci\u00f3n de un Predicate
para luego generar facilmente la construcci\u00f3n. Para ello vamos a crear una clase que guarde informaci\u00f3n de los criterios de filtrado (campo, operaci\u00f3n y valor), por suerte esta clase ser\u00e1 gen\u00e9rica y la podremos usar en toda la aplicaci\u00f3n, as\u00ed que la vamos a crear en el paquete com.ccsw.tutorial.common.criteria
package com.ccsw.tutorial.common.criteria;\n\npublic class SearchCriteria {\n\nprivate String key;\nprivate String operation;\nprivate Object value;\n\npublic SearchCriteria(String key, String operation, Object value) {\n\nthis.key = key;\nthis.operation = operation;\nthis.value = value;\n}\n\npublic String getKey() {\nreturn key;\n}\n\npublic void setKey(String key) {\nthis.key = key;\n}\n\npublic String getOperation() {\nreturn operation;\n}\n\npublic void setOperation(String operation) {\nthis.operation = operation;\n}\n\npublic Object getValue() {\nreturn value;\n}\n\npublic void setValue(Object value) {\nthis.value = value;\n}\n\n}\n
Hecho esto pasamos a definir el Specification
de nuestra clase la cual contendr\u00e1 la construcci\u00f3n de la consulta en funci\u00f3n de los criterios que se le proporcionan. No queremos construir los predicados directamente en nuestro Service
ya que duplicariamos mucho c\u00f3digo, mucho mejor si hacemos una clase para centralizar la construcci\u00f3n de predicados.
De esta forma vamos a crear una clase Specification
por cada una de las Entity
que queramos consultar. En nuestro caso solo vamos a generar queries
para Game
, as\u00ed que solo crearemos un GameSpecification
donde construirmos los predicados.
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.common.criteria.SearchCriteria;\nimport com.ccsw.tutorial.game.model.Game;\nimport jakarta.persistence.criteria.*;\nimport org.springframework.data.jpa.domain.Specification;\n\n\npublic class GameSpecification implements Specification<Game> {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate final SearchCriteria criteria;\npublic GameSpecification(SearchCriteria criteria) {\n\nthis.criteria = criteria;\n}\n\n@Override\npublic Predicate toPredicate(Root<Game> root, CriteriaQuery<?> query, CriteriaBuilder builder) {\nif (criteria.getOperation().equalsIgnoreCase(\":\") && criteria.getValue() != null) {\nPath<String> path = getPath(root);\nif (path.getJavaType() == String.class) {\nreturn builder.like(path, \"%\" + criteria.getValue() + \"%\");\n} else {\nreturn builder.equal(path, criteria.getValue());\n}\n}\nreturn null;\n}\n\nprivate Path<String> getPath(Root<Game> root) {\nString key = criteria.getKey();\nString[] split = key.split(\"[.]\", 0);\n\nPath<String> expression = root.get(split[0]);\nfor (int i = 1; i < split.length; i++) {\nexpression = expression.get(split[i]);\n}\n\nreturn expression;\n}\n\n}\n
Voy a tratar de explicar con calma cada una de las l\u00edneas marcadas, ya que son conceptos dificiles de entender hasta que no se utilizan.
Las dos primeras l\u00edneas marcadas hacen referencia a que cuando se crea un Specification
, esta debe generar un predicado, con lo que necesita unos criterios de filtrado para poder generarlo. En el constructor le estamos pasando esos criterios de filtrado que luego utilizaremos.
La tercera l\u00ednea marcada est\u00e1 seleccionando el tipo de operaci\u00f3n. En nuestro caso solo vamos a utilizar operaciones de comparaci\u00f3n. Por convenio las operaciones de comparaci\u00f3n se marcan como \":\" ya que el s\u00edmbolo = est\u00e1 reservado. Aqu\u00ed es donde podr\u00edamos a\u00f1adir otro tipo de operaciones como \">\" o \"<>\" o cualquiera que queramos implementar. Gu\u00e1rdate esa informaci\u00f3n que te servir\u00e1 en el ejercicio final .
Las dos siguientes l\u00edneas, las de return
est\u00e1n construyendo un Predicate
al ser de tipo comparaci\u00f3n, si es un texto har\u00e1 un like
y si no es texto (que es un n\u00famero o fecha) har\u00e1 un equals
.
Por \u00faltimo, tenemos un m\u00e9todo getPath
que invocamos dentro la generaci\u00f3n del predicado y que implementamos m\u00e1s abajo. Esta funci\u00f3n nos permite explorar las sub-entidades para realizar consultas sobre los atributos de estas. Por ejemplo, si queremos navegar hasta game.author.name
, lo que har\u00e1 la exploraci\u00f3n ser\u00e1 recuperar el atributo name
del objeto author
de la entidad game
.
Una vez implementada nuestra clase de Specification
, que lo \u00fanico que hace es recoger un criterio de filtrado y construir un predicado, y que en principio solo permite generar comparaciones de igualdad, vamos a utilizarlo dentro de nuestro Service
:
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.author.AuthorService;\nimport com.ccsw.tutorial.category.CategoryService;\nimport com.ccsw.tutorial.common.criteria.SearchCriteria;\nimport com.ccsw.tutorial.game.model.Game;\nimport com.ccsw.tutorial.game.model.GameDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.jpa.domain.Specification;\nimport org.springframework.stereotype.Service;\n\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class GameServiceImpl implements GameService {\n\n@Autowired\nGameRepository gameRepository;\n\n@Autowired\nAuthorService authorService;\n\n@Autowired\nCategoryService categoryService;\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Game> find(String title, Long idCategory) {\n\nGameSpecification titleSpec = new GameSpecification(new SearchCriteria(\"title\", \":\", title));\nGameSpecification categorySpec = new GameSpecification(new SearchCriteria(\"category.id\", \":\", idCategory));\nSpecification<Game> spec = Specification.where(titleSpec).and(categorySpec);\nreturn this.gameRepository.findAll(spec);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, GameDto dto) {\n\nGame game;\n\nif (id == null) {\ngame = new Game();\n} else {\ngame = this.gameRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(dto, game, \"id\", \"author\", \"category\");\n\ngame.setAuthor(authorService.get(dto.getAuthor().getId()));\ngame.setCategory(categoryService.get(dto.getCategory().getId()));\n\nthis.gameRepository.save(game);\n}\n\n}\n
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport org.springframework.data.jpa.domain.Specification;\nimport org.springframework.data.jpa.repository.EntityGraph;\nimport org.springframework.data.jpa.repository.JpaSpecificationExecutor;\nimport org.springframework.data.repository.CrudRepository;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameRepository extends CrudRepository<Game, Long>, JpaSpecificationExecutor<Game> {\n}\n
Lo que hemos hecho es crear los dos criterios de filtrado que necesit\u00e1bamos. En nuestro caso eran title
, que es un atributo de la entidad Game
y por otro lado el identificador de categor\u00eda, que en este caso, ya no es un atributo directo de la entidad, si no, de la categor\u00eda asociada, por lo que debemos navegar hasta el atributo id
a trav\u00e9s del atributo category
(para esto utilizamos el getPath
que hemos visto anteriormente).
A partir de estos dos predicados, podemos generar el Specification
global para la consulta, uniendo los dos predicados mediante el operador AND
.
Una vez construido el Specification
ya podemos usar el m\u00e9todo por defecto que nos proporciona Spring Data para dicho fin, tan solo tenemos que decirle a nuestro GameRepository
que adem\u00e1s extender de CrudRepository
debe extender de JpaSpecificationExecutor
, para que pueda ejecutarlas.
Finalmente, de cara a mejorar el rendimiento de nuestros servicios vamos a hacer foco en la generaci\u00f3n de transacciones con la base de datos. Si ejecut\u00e1ramos esta petici\u00f3n tal cual lo tenemos implementado ahora mismo, en la consola ver\u00edamos lo siguiente:
Hibernate: select g1_0.id,g1_0.age,g1_0.author_id,g1_0.category_id,g1_0.title from game g1_0\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\nHibernate: select c1_0.id,c1_0.name from category c1_0 where c1_0.id=?\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\nHibernate: select c1_0.id,c1_0.name from category c1_0 where c1_0.id=?\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\nHibernate: select a1_0.id,a1_0.name,a1_0.nationality from author a1_0 where a1_0.id=?\n
Esto es debido a que no le hemos dado indicaciones a Spring Data de como queremos que construya las consultas con relaciones y por defecto est\u00e1 configurado para generar sub-consultas cuando tenemos tablas relacionadas.
En nuestro caso la tabla Game
est\u00e1 relacionada con Author
y Category
. Al realizar la consulta a Game
realiza las sub-consultas por cada uno de los registros relacionados con los resultados Game
.
Para evitar tantas consultas contra la BBDD y realizar esto de una forma mucho m\u00e1s \u00f3ptima, podemos decirle a Spring Data el comportamiento que queremos, que en nuestro caso ser\u00e1 que haga una \u00fanica consulta y haga las sub-consultas mediante los join
correspondientes.
Para ello a\u00f1adimos una sobre-escritura del m\u00e9todo findAll
, que ya ten\u00edamos implementado en JpaSpecificationExecutor
y que utlizamos de forma heredada, pero en este caso le a\u00f1adimos la anotaci\u00f3n @EntityGraph
con los atributos que queremos que se incluyan dentro de la consulta principal mediante join
:
package com.ccsw.tutorial.game;\n\nimport com.ccsw.tutorial.game.model.Game;\nimport org.springframework.data.jpa.domain.Specification;\nimport org.springframework.data.jpa.repository.EntityGraph;\nimport org.springframework.data.jpa.repository.JpaSpecificationExecutor;\nimport org.springframework.data.repository.CrudRepository;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface GameRepository extends CrudRepository<Game, Long>, JpaSpecificationExecutor<Game> {\n\n@Override\n@EntityGraph(attributePaths = {\"category\", \"author\"})\nList<Game> findAll(Specification<Game> spec);\n}\n
Tras realizar este cambio, podemos observar que la nueva consulta generada es la siguiente:
Hibernate: select g1_0.id,g1_0.age,a1_0.id,a1_0.name,a1_0.nationality,c1_0.id,c1_0.name,g1_0.title from game g1_0 join author a1_0 on a1_0.id=g1_0.author_id join category c1_0 on c1_0.id=g1_0.category_id\n
Como podemos observar, ahora se realiza una \u00fanica consulta con la correspondiente transacci\u00f3n con la BBDD, y se trae todos los datos necesarios de Game
, Author
y Category
sin lanzar m\u00faltiples queries.
Si ahora ejecutamos de nuevo los jUnits, vemos que todos los que hemos desarrollado en GameIT
ya funcionan correctamente, e incluso el resto de test de la aplicaci\u00f3n tambi\u00e9n funcionan correctamente.
Pruebas jUnit
Cada vez que desarrollemos un caso de uso nuevo, debemos relanzar todas las pruebas autom\u00e1ticas que tenga la aplicaci\u00f3n. Es muy com\u00fan que al implementar alg\u00fan desarrollo nuevo, interfiramos de alguna forma en el funcionamiento de otra funcionalidad. Si lanzamos toda la bater\u00eda de pruebas, nos daremos cuenta si algo ha dejado de funcionar y podremos solucionarlo antes de llevar ese error a Producci\u00f3n. Las pruebas jUnit son nuestra red de seguridad.
Adem\u00e1s de las pruebas autom\u00e1ticas, podemos ver como se comporta la aplicaci\u00f3n y que respuesta nos ofrece, lanzando peticiones Rest con Postman, como hemos hecho en los casos anteriores. As\u00ed que podemos levantar la aplicaci\u00f3n y lanzar las operaciones:
** GET http://localhost:8080/game **
** GET http://localhost:8080/game?title=xxx **
** GET http://localhost:8080/game?idCategory=xxx **
Nos devuelve un listado filtrado de Game
. F\u00edjate bien en la petici\u00f3n donde enviamos los filtros y la respuesta que tiene los objetos Category
y Author
inclu\u00eddos.
** PUT http://localhost:8080/game ** ** PUT http://localhost:8080/game/{id} **
{\n \"title\": \"Nuevo juego\",\n \"age\": \"18\",\n \"category\": {\n \"id\": 3\n },\n \"author\": {\n \"id\": 1\n }\n}\n
Nos sirve para insertar un Game
nuevo (si no tienen el id informado) o para actualizar un Game
(si tienen el id informado). F\u00edjate que para enlazar Category
y Author
tan solo hace falta el id de cada no de ellos, ya que en el m\u00e9todo save
se hace una consulta get
para recuperarlos por su id. Adem\u00e1s que no tendr\u00eda sentido enviar toda la informaci\u00f3n de esas entidades ya que no est\u00e1s dando de alta una Category
ni un Author
.
Rendimiento en las consultas JPA
En este punto te recomiendo que visites el Anexo. Funcionamiento JPA para conocer un poco m\u00e1s como funciona por dentro JPA y alg\u00fan peque\u00f1o truco que puede mejorar el rendimiento.
"},{"location":"develop/filtered/springboot/#implementar-listado-autores","title":"Implementar listado Autores","text":"Antes de poder conectar front con back, si recuerdas, en la edici\u00f3n de un Game
, nos hac\u00eda falta un listado de Author
y un listado de Category
. El segundo ya lo tenemos ya que lo reutilizaremos del listado de categor\u00edas que implementamos. Pero el primero no lo tenemos, porque en la pantalla que hicimos, se mostraban de forma paginada.
As\u00ed que necesitamos implementar esa funcionalidad, y como siempre vamos de la capa de testing hacia las siguientes capas. Deber\u00edamos a\u00f1adir los siguientes m\u00e9todos:
AuthorIT.javaAuthorController.javaAuthorService.javaAuthorServiceImpl.java...\n\nParameterizedTypeReference<List<AuthorDto>> responseTypeList = new ParameterizedTypeReference<List<AuthorDto>>(){};\n\n@Test\npublic void findAllShouldReturnAllAuthor() {\n\nResponseEntity<List<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.GET, null, responseTypeList);\n\nassertNotNull(response);\nassertEquals(TOTAL_AUTHORS, response.getBody().size());\n}\n\n...\n
...\n\n/**\n * Recupera un listado de autores {@link Author}\n *\n * @return {@link List} de {@link AuthorDto}\n */\n@Operation(summary = \"Find\", description = \"Method that return a list of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.GET)\npublic List<AuthorDto> findAll() {\n\nList<Author> authors = this.authorService.findAll();\n\nreturn authors.stream().map(e -> mapper.map(e, AuthorDto.class)).collect(Collectors.toList());\n}\n\n...\n
...\n\n/**\n * Recupera un listado de autores {@link Author}\n *\n * @return {@link List} de {@link Author}\n */\nList<Author> findAll();\n\n...\n
...\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic List<Author> findAll() {\n\nreturn (List<Author>) this.authorRepository.findAll();\n}\n\n\n...\n
"},{"location":"develop/filtered/vuejs/","title":"Listado filtrado - VUE","text":"Aqu\u00ed vamos a volver a la pantalla de cat\u00e1logo para realizar un filtrado en la propia tabla.
Empezaremos por modificar el template de la tabla que modificamos para a\u00f1adir el bot\u00f3n de a\u00f1adir nueva fila para a\u00f1adir tambi\u00e9n tres inputs: uno de texto para el nombre del juego y dos seleccionables para la categor\u00eda y el autor (les tendremos que asignar las opciones que haya en ese momento).
Tambi\u00e9n a\u00f1adiremos un bot\u00f3n para que no se lance la petici\u00f3n cada vez que el usuario introduce una letra en el input de texto. Esto quedar\u00eda as\u00ed:
<template v-slot:top>\n <div class=\"q-table__title\">Cat\u00e1logo</div>\n <q-btn flat round color=\"primary\" icon=\"add\" @click=\"showAdd = true\" />\n <q-space />\n <q-input dense v-model=\"filter.title\" placeholder=\"T\u00edtulo\">\n <template v-slot:append>\n <q-icon name=\"search\" />\n </template>\n </q-input>\n <q-separator inset />\n <div style=\"width: 10%\">\n <q-select\n dense\n name=\"category\"\n v-model=\"filter.category\"\n :options=\"categories\"\n emit-value\n map-options\n option-value=\"id\"\n option-label=\"name\"\n label=\"Categor\u00eda\"\n />\n </div>\n <q-separator inset />\n <div style=\"width: 10%\">\n <q-select\n dense\n name=\"author\"\n v-model=\"filter.author\"\n :options=\"authors\"\n emit-value\n map-options\n option-value=\"id\"\n option-label=\"name\"\n label=\"Autor\"\n />\n </div>\n <q-separator inset />\n <q-btn flat round color=\"primary\" icon=\"filter_alt\" @click=\"getGames\" />\n </template>\n
Adem\u00e1s, tambi\u00e9n vamos a a\u00f1adir un estado para todos los filtros juntos:
const filter = ref({ title: '', category: '', author: '' });\n
Por \u00faltimo, para no estar haciendo las tres peticiones (juegos, categor\u00edas y autores) las hemos extra\u00eddo en funciones diferentes de la siguiente manera:
const getGames = () => {\n const { data } = useFetch(url.value).get().json();\n whenever(data, () => (catalogData.value = data.value));\n};\n\nconst getCategories = () => {\n const { data: categoriesData } = useFetch('http://localhost:8080/category')\n .get()\n .json();\n whenever(categoriesData, () => (categories.value = categoriesData.value));\n};\n\nconst getAuthors = () => {\n const { data: authorsData } = useFetch('http://localhost:8080/author')\n .get()\n .json();\n whenever(authorsData, () => (authors.value = authorsData.value));\n};\n\nconst firstLoad = () => {\n getGames();\n getCategories();\n getAuthors();\n};\nfirstLoad();\n
Y como podemos ver, ahora la petici\u00f3n de juegos no tiene la url. Esto es porque hemos hecho que sea una variable computada para a\u00f1adirle los par\u00e1metros de filtrado y ha quedado as\u00ed:
const url = computed(() => {\n const _url = new URL('http://localhost:8080/game');\n _url.search = new URLSearchParams({\n title: filter.value.title,\n idCategory: filter.value.category ?? '',\n idAuthor: filter.value.author ?? '',\n });\n return _url.toString();\n});\n
"},{"location":"develop/paginated/angular/","title":"Listado paginado - Angular","text":"Ya tienes tu primer CRUD desarrollado. \u00bfHa sido sencillo, verdad?.
Ahora vamos a implementar un CRUD un poco m\u00e1s complejo, este tiene datos paginados en servidor, esto quiere decir que no nos sirve un array de datos como en el anterior ejemplo. Para que un listado paginado en servidor funcione, el cliente debe enviar en cada petici\u00f3n que p\u00e1gina est\u00e1 solicitando y cual es el tama\u00f1o de la p\u00e1gina, para que el servidor devuelva solamente un subconjunto de datos, en lugar de devolver el listado completo.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/paginated/angular/#crear-modulo-y-componentes","title":"Crear modulo y componentes","text":"Vamos a desarrollar el listado de Autores
as\u00ed que, debemos crear los componentes:
ng generate module author\nng generate component author/author-list\nng generate component author/author-edit\n\nng generate service author/author\n
Este m\u00f3dulo lo vamos a a\u00f1adir a la aplicaci\u00f3n para que se cargue en el arranque. Abrimos el fichero app.module.ts
y a\u00f1adimos el m\u00f3dulo:
import { NgModule } from '@angular/core';\nimport { BrowserModule } from '@angular/platform-browser';\n\nimport { AppRoutingModule } from './app-routing.module';\nimport { AppComponent } from './app.component';\nimport { BrowserAnimationsModule } from '@angular/platform-browser/animations';\nimport { CoreModule } from './core/core.module';\nimport { CategoryModule } from './category/category.module';\nimport { AuthorModule } from './author/author.module';\n@NgModule({\ndeclarations: [\nAppComponent\n],\nimports: [\nBrowserModule,\nAppRoutingModule,\nCoreModule,\nCategoryModule,\nAuthorModule,\nBrowserAnimationsModule\n],\nproviders: [],\nbootstrap: [AppComponent]\n})\nexport class AppModule { }\n
"},{"location":"develop/paginated/angular/#crear-el-modelo","title":"Crear el modelo","text":"Creamos el modelo en author/model/Author.ts
con las propiedades necesarias para trabajar con la informaci\u00f3n de un autor:
export class Author {\nid: number;\nname: string;\nnationality: string;\n}\n
"},{"location":"develop/paginated/angular/#anadir-el-punto-de-entrada","title":"A\u00f1adir el punto de entrada","text":"A\u00f1adimos la ruta al men\u00fa para que podamos acceder a la pantalla:
app-routing.module.tsimport { NgModule } from '@angular/core';\nimport { Routes, RouterModule } from '@angular/router';\nimport { CategoryListComponent } from './category/category-list/category-list.component';\nimport { AuthorListComponent } from './author/author-list/author-list.component';\nconst routes: Routes = [\n{ path: 'categories', component: CategoriesComponent },\n{ path: 'authors', component: AuthorListComponent },\n];\n\n@NgModule({\nimports: [RouterModule.forRoot(routes)],\nexports: [RouterModule]\n})\nexport class AppRoutingModule { }\n
"},{"location":"develop/paginated/angular/#implementar-servicio","title":"Implementar servicio","text":"Y realizamos las diferentes implementaciones. Empezaremos por el servicio. En este caso, hay un cambio sustancial con el anterior ejemplo. Al tratarse de un listado paginado, la operaci\u00f3n getAuthors
necesita informaci\u00f3n extra acerca de que p\u00e1gina de datos debe mostrar, adem\u00e1s de que el resultado ya no ser\u00e1 un listado sino una p\u00e1gina.
Por defecto el esquema de datos de Spring para la paginaci\u00f3n es como el siguiente:
Esquema de datos de paginaci\u00f3n{\n\"content\": [ ... <listado con los resultados paginados> ... ],\n\"pageable\": {\n\"pageNumber\": <n\u00famero de p\u00e1gina empezando por 0>,\n\"pageSize\": <tama\u00f1o de p\u00e1gina>,\n\"sort\": [\n{ \"property\": <nombre de la propiedad a ordenar>, \"direction\": <direcci\u00f3n de la ordenaci\u00f3n ASC / DESC> }\n]\n},\n\"totalElements\": <numero total de elementos en la tabla>\n}\n
As\u00ed que necesitamos poder enviar y recuperar esa informaci\u00f3n desde Angular, nos hace falta crear esos objetos. Los objetos de paginaci\u00f3n al ser comunes a toda la aplicaci\u00f3n, vamos a crearlos en core/model/page
, mientras que la paginaci\u00f3n de AuthorPage.ts
la crear\u00e9 en su propio model dentro de author/model
.
export class SortPage {\nproperty: String;\ndirection: String;\n}\n
import { SortPage } from './SortPage';\n\nexport class Pageable {\npageNumber: number;\npageSize: number;\nsort: SortPage[];\n}\n
import { Pageable } from \"src/app/core/model/page/Pageable\";\nimport { Author } from \"./Author\";\n\nexport class AuthorPage {\ncontent: Author[];\npageable: Pageable;\ntotalElements: number;\n}\n
Con estos objetos creados ya podemos implementar el servicio y sus datos mockeados.
mock-authors.tsauthor.service.tsimport { AuthorPage } from \"./AuthorPage\";\n\nexport const AUTHOR_DATA: AuthorPage = {\ncontent: [\n{ id: 1, name: 'Klaus Teuber', nationality: 'Alemania' },\n{ id: 2, name: 'Matt Leacock', nationality: 'Estados Unidos' },\n{ id: 3, name: 'Keng Leong Yeo', nationality: 'Singapur' },\n{ id: 4, name: 'Gil Hova', nationality: 'Estados Unidos'},\n{ id: 5, name: 'Kelly Adams', nationality: 'Estados Unidos' },\n{ id: 6, name: 'J. Alex Kavern', nationality: 'Estados Unidos' },\n{ id: 7, name: 'Corey Young', nationality: 'Estados Unidos' },\n], pageable : {\npageSize: 5,\npageNumber: 0,\nsort: [\n{property: \"id\", direction: \"ASC\"}\n]\n},\ntotalElements: 7\n}\n
import { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Pageable } from '../core/model/page/Pageable';\nimport { Author } from './model/Author';\nimport { AuthorPage } from './model/AuthorPage';\nimport { AUTHOR_DATA } from './model/mock-authors';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class AuthorService {\n\nconstructor() { }\n\ngetAuthors(pageable: Pageable): Observable<AuthorPage> {\nreturn of(AUTHOR_DATA);\n}\n\nsaveAuthor(author: Author): Observable<void> {\nreturn of(null);\n}\n\ndeleteAuthor(idAuthor : number): Observable<void> {\nreturn of(null);\n} }\n
"},{"location":"develop/paginated/angular/#implementar-listado","title":"Implementar listado","text":"Ya tenemos el servicio con los datos, ahora vamos a por el listado paginado.
author-list.component.htmlauthor-list.component.scssauthor-list.component.ts<div class=\"container\">\n <h1>Listado de Autores</h1>\n\n <mat-table [dataSource]=\"dataSource\"> \n <ng-container matColumnDef=\"id\">\n <mat-header-cell *matHeaderCellDef> Identificador </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.id}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"name\">\n <mat-header-cell *matHeaderCellDef> Nombre autor </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.name}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"nationality\">\n <mat-header-cell *matHeaderCellDef> Nacionalidad </mat-header-cell>\n <mat-cell *matCellDef=\"let element\"> {{element.nationality}} </mat-cell>\n </ng-container>\n\n <ng-container matColumnDef=\"action\">\n <mat-header-cell *matHeaderCellDef></mat-header-cell>\n <mat-cell *matCellDef=\"let element\">\n <button mat-icon-button color=\"primary\" (click)=\"editAuthor(element)\">\n <mat-icon>edit</mat-icon>\n </button>\n <button mat-icon-button color=\"accent\" (click)=\"deleteAuthor(element)\">\n <mat-icon>clear</mat-icon>\n </button>\n </mat-cell>\n </ng-container>\n\n <mat-header-row *matHeaderRowDef=\"displayedColumns; sticky: true\"></mat-header-row>\n <mat-row *matRowDef=\"let row; columns: displayedColumns;\"></mat-row>\n </mat-table> \n\n<mat-paginator (page)=\"loadPage($event)\" [pageSizeOptions]=\"[5, 10, 20]\" [pageIndex]=\"pageNumber\" [pageSize]=\"pageSize\" [length]=\"totalElements\" showFirstLastButtons></mat-paginator>\n<div class=\"buttons\">\n <button mat-flat-button color=\"primary\" (click)=\"createAuthor()\">Nuevo autor</button> \n </div> \n</div>\n
.container {\nmargin: 20px;\n\nmat-table {\nmargin-top: 10px;\nmargin-bottom: 20px;\n\n.mat-header-row {\nbackground-color:#f5f5f5;\n\n.mat-header-cell {\ntext-transform: uppercase;\nfont-weight: bold;\ncolor: #838383;\n} }\n\n.mat-column-id {\nflex: 0 0 20%;\njustify-content: center;\n}\n\n.mat-column-action {\nflex: 0 0 10%;\njustify-content: center;\n}\n}\n\n.buttons {\ntext-align: right;\n}\n}\n
import { Component, OnInit } from '@angular/core';\nimport { MatDialog } from '@angular/material/dialog';\nimport { PageEvent } from '@angular/material/paginator';\nimport { MatTableDataSource } from '@angular/material/table';\nimport { DialogConfirmationComponent } from 'src/app/core/dialog-confirmation/dialog-confirmation.component';\nimport { Pageable } from 'src/app/core/model/page/Pageable';\nimport { AuthorEditComponent } from '../author-edit/author-edit.component';\nimport { AuthorService } from '../author.service';\nimport { Author } from '../model/Author';\n\n@Component({\nselector: 'app-author-list',\ntemplateUrl: './author-list.component.html',\nstyleUrls: ['./author-list.component.scss']\n})\nexport class AuthorListComponent implements OnInit {\n\npageNumber: number = 0;\npageSize: number = 5;\ntotalElements: number = 0;\ndataSource = new MatTableDataSource<Author>();\ndisplayedColumns: string[] = ['id', 'name', 'nationality', 'action'];\n\nconstructor(\nprivate authorService: AuthorService,\npublic dialog: MatDialog,\n) { }\n\nngOnInit(): void {\nthis.loadPage();\n}\n\nloadPage(event?: PageEvent) {\nlet pageable : Pageable = {\npageNumber: this.pageNumber,\npageSize: this.pageSize,\nsort: [{\nproperty: 'id',\ndirection: 'ASC'\n}]\n}\nif (event != null) {\npageable.pageSize = event.pageSize\npageable.pageNumber = event.pageIndex;\n}\nthis.authorService.getAuthors(pageable).subscribe(data => {\nthis.dataSource.data = data.content;\nthis.pageNumber = data.pageable.pageNumber;\nthis.pageSize = data.pageable.pageSize;\nthis.totalElements = data.totalElements;\n});\n} createAuthor() { const dialogRef = this.dialog.open(AuthorEditComponent, {\ndata: {}\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); } editAuthor(author: Author) { const dialogRef = this.dialog.open(AuthorEditComponent, {\ndata: { author: author }\n});\n\ndialogRef.afterClosed().subscribe(result => {\nthis.ngOnInit();\n}); }\n\ndeleteAuthor(author: Author) { const dialogRef = this.dialog.open(DialogConfirmationComponent, {\ndata: { title: \"Eliminar autor\", description: \"Atenci\u00f3n si borra el autor se perder\u00e1n sus datos.<br> \u00bfDesea eliminar el autor?\" }\n});\n\ndialogRef.afterClosed().subscribe(result => {\nif (result) {\nthis.authorService.deleteAuthor(author.id).subscribe(result => {\nthis.ngOnInit();\n}); }\n});\n} }\n
F\u00edjate como hemos a\u00f1adido la paginaci\u00f3n.
mat-paginator
, lo que nos va a obligar a a\u00f1adirlo al m\u00f3dulo tambi\u00e9n como dependencia. Ese componente le hemos definido un m\u00e9todo page
que se ejecuta cada vez que la p\u00e1gina cambia, y unas propiedades con las que calcular\u00e1 la p\u00e1gina, el tama\u00f1o y el n\u00famero total de p\u00e1ginas.pageable
con los valores actuales del componente paginador y lanza la petici\u00f3n con esos datos en el body. Obviamente al ser un mock no funcionar\u00e1 el cambio de p\u00e1gina y dem\u00e1s.Como siempre, a\u00f1adimos las dependencias al m\u00f3dulo, vamos a intentar a\u00f1adir todas las que vamos a necesitar a futuro.
author.module.tsimport { NgModule } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { AuthorListComponent } from './author-list/author-list.component';\nimport { AuthorEditComponent } from './author-edit/author-edit.component';\nimport { MatTableModule } from '@angular/material/table';\nimport { FormsModule, ReactiveFormsModule } from '@angular/forms';\nimport { MatButtonModule } from '@angular/material/button';\nimport { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog';\nimport { MatFormFieldModule } from '@angular/material/form-field';\nimport { MatIconModule } from '@angular/material/icon';\nimport { MatInputModule } from '@angular/material/input';\nimport { MatPaginatorModule } from '@angular/material/paginator';\n\n\n\n@NgModule({\ndeclarations: [\nAuthorListComponent,\nAuthorEditComponent\n],\nimports: [\nCommonModule,\nMatTableModule,\nMatIconModule, MatButtonModule,\nMatDialogModule,\nMatFormFieldModule,\nMatInputModule,\nFormsModule,\nReactiveFormsModule,\nMatPaginatorModule,\n],\nproviders: [\n{\nprovide: MAT_DIALOG_DATA,\nuseValue: {},\n},\n]\n})\nexport class AuthorModule { }\n
Deber\u00eda verse algo similar a esto:
"},{"location":"develop/paginated/angular/#implementar-dialogo-edicion","title":"Implementar dialogo edici\u00f3n","text":"El \u00faltimo paso, es definir la pantalla de dialogo que realizar\u00e1 el alta y modificado de los datos de un Autor
.
<div class=\"container\">\n <h1 *ngIf=\"author.id == null\">Crear autor</h1>\n <h1 *ngIf=\"author.id != null\">Modificar autor</h1>\n\n <form>\n <mat-form-field>\n <mat-label>Identificador</mat-label>\n <input type=\"text\" matInput placeholder=\"Identificador\" [(ngModel)]=\"author.id\" name=\"id\" disabled>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Nombre</mat-label>\n <input type=\"text\" matInput placeholder=\"Nombre del autor\" [(ngModel)]=\"author.name\" name=\"name\" required>\n <mat-error>El nombre no puede estar vac\u00edo</mat-error>\n </mat-form-field>\n\n <mat-form-field>\n <mat-label>Nacionalidad</mat-label>\n <input type=\"text\" matInput placeholder=\"Nacionalidad del autor\" [(ngModel)]=\"author.nationality\" name=\"nationality\" required>\n <mat-error>La nacionalidad no puede estar vac\u00eda</mat-error>\n </mat-form-field>\n </form>\n\n <div class=\"buttons\">\n <button mat-stroked-button (click)=\"onClose()\">Cerrar</button>\n <button mat-flat-button color=\"primary\" (click)=\"onSave()\">Guardar</button>\n </div>\n</div>\n
.container {\nmin-width: 350px;\nmax-width: 500px;\npadding: 20px;\n\nform {\ndisplay: flex;\nflex-direction: column;\nmargin-bottom:20px;\n}\n\n.buttons {\ntext-align: right;\n\nbutton {\nmargin-left: 10px;\n}\n}\n}\n
import { Component, Inject, OnInit } from '@angular/core';\nimport { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';\nimport { AuthorService } from '../author.service';\nimport { Author } from '../model/Author';\n\n@Component({\nselector: 'app-author-edit',\ntemplateUrl: './author-edit.component.html',\nstyleUrls: ['./author-edit.component.scss']\n})\nexport class AuthorEditComponent implements OnInit {\n\nauthor : Author;\n\nconstructor(\npublic dialogRef: MatDialogRef<AuthorEditComponent>,\n@Inject(MAT_DIALOG_DATA) public data: any,\nprivate authorService: AuthorService\n) { }\n\nngOnInit(): void {\nif (this.data.author != null) {\nthis.author = Object.assign({}, this.data.author);\n}\nelse {\nthis.author = new Author();\n}\n}\n\nonSave() {\nthis.authorService.saveAuthor(this.author).subscribe(result => {\nthis.dialogRef.close();\n}); } onClose() {\nthis.dialogRef.close();\n}\n\n}\n
Que deber\u00eda quedar algo as\u00ed:
"},{"location":"develop/paginated/angular/#conectar-con-backend","title":"Conectar con Backend","text":"Antes de seguir
Antes de seguir con este punto, debes implementar el c\u00f3digo de backend en la tecnolog\u00eda que quieras (Springboot o Nodejs). Si has empezado este cap\u00edtulo implementando el frontend, por favor accede a la secci\u00f3n correspondiente de backend para poder continuar con el tutorial. Una vez tengas implementadas todas las operaciones para este listado, puedes volver a este punto y continuar con Angular.
Una vez implementado front y back, lo que nos queda es modificar el servicio del front para que conecte directamente con las operaciones ofrecidas por el back.
author.service.tsimport { HttpClient } from '@angular/common/http';\nimport { Injectable } from '@angular/core';\nimport { Observable, of } from 'rxjs';\nimport { Pageable } from '../core/model/page/Pageable';\nimport { Author } from './model/Author';\nimport { AuthorPage } from './model/AuthorPage';\n\n@Injectable({\nprovidedIn: 'root'\n})\nexport class AuthorService {\n\nconstructor(\nprivate http: HttpClient\n) { }\n\ngetAuthors(pageable: Pageable): Observable<AuthorPage> {\nreturn this.http.post<AuthorPage>('http://localhost:8080/author', {pageable:pageable});\n}\n\nsaveAuthor(author: Author): Observable<void> {\nlet url = 'http://localhost:8080/author';\nif (author.id != null) url += '/'+author.id;\nreturn this.http.put<void>(url, author);\n}\n\ndeleteAuthor(idAuthor : number): Observable<void> {\nreturn this.http.delete<void>('http://localhost:8080/author/'+idAuthor);\n} }\n
"},{"location":"develop/paginated/nodejs/","title":"Listado paginado - Nodejs","text":"Ahora vamos a implementar las operaciones necesarias para ayudar al front a cubrir la funcionalidad del CRUD paginado en servidor. Recuerda que para que un listado paginado en servidor funcione, el cliente debe enviar en cada petici\u00f3n que p\u00e1gina est\u00e1 solicitando y cual es el tama\u00f1o de la p\u00e1gina, para que el servidor devuelva solamente un subconjunto de datos, en lugar de devolver el listado completo.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/paginated/nodejs/#crear-modelos","title":"Crear modelos","text":"Lo primero que vamos a hacer es crear el modelo de author para trabajar con BBDD. En la carpeta schemas creamos el archivo author.schema.js
:
import mongoose from \"mongoose\";\nimport normalize from 'normalize-mongoose';\nimport mongoosePaginate from 'mongoose-paginate-v2';\nconst { Schema, model } = mongoose;\n\nconst authorSchema = new Schema({\nname: {\ntype: String,\nrequire: true\n},\nnationality: {\ntype: String,\nrequire: true\n}\n});\nauthorSchema.plugin(normalize);\nauthorSchema.plugin(mongoosePaginate);\n\nconst AuthorModel = model('Author', authorSchema);\n\nexport default AuthorModel;\n
"},{"location":"develop/paginated/nodejs/#implementar-el-service","title":"Implementar el Service","text":"Creamos el service correspondiente author.service.js
:
import AuthorModel from '../schemas/author.schema.js';\n\nexport const getAuthors = async () => {\ntry {\nreturn await AuthorModel.find().sort('id');\n} catch (e) {\nthrow Error('Error fetching authors');\n}\n}\n\nexport const createAuthor = async (data) => {\nconst { name, nationality } = data;\ntry {\nconst author = new AuthorModel({ name, nationality });\nreturn await author.save();\n} catch (e) {\nthrow Error('Error creating author');\n}\n}\n\nexport const updateAuthor = async (id, data) => {\ntry {\nconst author = await AuthorModel.findById(id);\nif (!author) {\nthrow Error('There is no author with that Id');\n} return await AuthorModel.findByIdAndUpdate(id, data);\n} catch (e) {\nthrow Error(e);\n}\n}\n\nexport const deleteAuthor = async (id) => {\ntry {\nconst author = await AuthorModel.findById(id);\nif (!author) {\nthrow Error('There is no author with that Id');\n}\nreturn await AuthorModel.findByIdAndDelete(id);\n} catch (e) {\nthrow Error(e);\n}\n}\n\nexport const getAuthorsPageable = async (page, limit, sort) => {\nconst sortObj = {\n[sort?.property || 'name']: sort?.direction === 'DESC' ? 'DESC' : 'ASC'\n};\ntry {\nconst options = {\npage: parseInt(page) + 1,\nlimit,\nsort: sortObj\n};\n\nreturn await AuthorModel.paginate({}, options);\n} catch (e) {\nthrow Error('Error fetching authors page');\n} }\n
Como podemos observar es muy parecido al servicio de categor\u00edas, pero hemos incluido un nuevo m\u00e9todo getAuthorsPageable
. Este m\u00e9todo tendr\u00e1 como par\u00e1metros de entrada la p\u00e1gina que queramos mostrar, el tama\u00f1o de esta y las propiedades de ordenaci\u00f3n. Moongose nos proporciona el m\u00e9todo paginate que es muy parecido a find salvo que adem\u00e1s podemos pasar las opciones de paginaci\u00f3n y el solo realizar\u00e1 todo el trabajo.
Creamos el controlador author.controller.js
:
import * as AuthorService from '../services/author.service.js';\n\nexport const getAuthors = async (req, res) => {\ntry {\nconst authors = await AuthorService.getAuthors();\nres.status(200).json(\nauthors\n);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const createAuthor = async (req, res) => {\ntry {\nconst author = await AuthorService.createAuthor(req.body);\nres.status(200).json({\nauthor\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const updateAuthor = async (req, res) => {\nconst authorId = req.params.id;\ntry {\nawait AuthorService.updateAuthor(authorId, req.body);\nres.status(200).json(1);\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const deleteAuthor = async (req, res) => {\nconst authorId = req.params.id;\ntry {\nconst deletedAuthor = await AuthorService.deleteAuthor(authorId);\nres.status(200).json({\nauthor: deletedAuthor\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n\nexport const getAuthorsPageable = async (req, res) => {\nconst page = req.body.pageable.pageNumber || 0;\nconst limit = req.body.pageable.pageSize || 5;\nconst sort = req.body.pageable.sort || null;\n\ntry {\nconst response = await AuthorService.getAuthorsPageable(page, limit, sort);\nres.status(200).json({\ncontent: response.docs,\npageable: {\npageNumber: response.page - 1,\npageSize: response.limit\n},\ntotalElements: response.totalDocs\n});\n} catch (err) {\nres.status(400).json({\nmsg: err.toString()\n});\n}\n}\n
Y vemos que el m\u00e9todo getAuthorsPageable lee los datos de la request, se los pasa al servicio y por \u00faltimo transforma la response con los datos obtenidos.
"},{"location":"develop/paginated/nodejs/#implementar-las-rutas","title":"Implementar las Rutas","text":"Creamos nuestro archivo de rutas author.routes.js
:
import { Router } from 'express';\nimport { check } from 'express-validator';\nimport validateFields from '../middlewares/validateFields.js';\nimport { createAuthor, deleteAuthor, getAuthors, updateAuthor, getAuthorsPageable } from '../controllers/author.controller.js';\nconst authorRouter = Router();\n\nauthorRouter.put('/:id', [\ncheck('name').not().isEmpty(),\ncheck('nationality').not().isEmpty(),\nvalidateFields\n], updateAuthor);\n\nauthorRouter.put('/', [\ncheck('name').not().isEmpty(),\ncheck('nationality').not().isEmpty(),\nvalidateFields\n], createAuthor);\n\nauthorRouter.get('/', getAuthors);\nauthorRouter.delete('/:id', deleteAuthor);\n\nauthorRouter.post('/', [\ncheck('pageable').not().isEmpty(),\ncheck('pageable.pageSize').not().isEmpty(),\ncheck('pageable.pageNumber').not().isEmpty(),\nvalidateFields\n], getAuthorsPageable)\n\nexport default authorRouter;\n
Podemos observar que si hacemos una petici\u00f3n con get a /author
nos devolver\u00e1 todos los autores. Pero si hacemos una petici\u00f3n post con el objeto pageable en el body realizaremos el listado paginado.
Finalmente en nuestro archivo index.js
vamos a a\u00f1adir el nuevo router:
...\n\nimport authorRouter from './src/routes/author.routes.js';\n\n...\n\napp.use('/author', authorRouter);\n\n...\n
"},{"location":"develop/paginated/nodejs/#probar-las-operaciones","title":"Probar las operaciones","text":"Y ahora que tenemos todo creado, ya podemos probarlo con Postman:
Por un lado creamos autores con:
** PUT /author **
** PUT /author/{id} **
{\n\"name\" : \"Nuevo autor\",\n\"nationality\" : \"Nueva nacionalidad\"\n}\n
Nos sirve para insertar Autores
nuevas (si no tienen el id informado) o para actualizar Autores
(si tienen el id informado en la URL). F\u00edjate que los datos que se env\u00edan est\u00e1n en el body como formato JSON (parte izquierda de la imagen). Si no te dar\u00e1 un error.
** DELETE /author/{id} ** nos sirve eliminar Autores
. F\u00edjate que el dato del ID que se env\u00eda est\u00e1 en el path.
Luego recuperamos los autores con el m\u00e9todo GET
(antes tienes que crear unos cuantos para poder ver un listado):
Y por \u00faltimo listamos los autores paginados:
** POST /author **
{\n\"pageable\": {\n\"pageSize\" : 4,\n\"pageNumber\" : 0,\n\"sort\" : [\n{\n\"property\": \"name\",\n\"direction\": \"ASC\"\n}\n]\n}\n}\n
"},{"location":"develop/paginated/react/","title":"Listado paginado - React","text":"Ya tienes tu primer CRUD desarrollado. \u00bfHa sido sencillo, verdad?.
Ahora vamos a implementar un CRUD un poco m\u00e1s complejo, este tiene datos paginados en servidor, esto quiere decir que no nos sirve un array de datos como en el anterior ejemplo. Para que un listado paginado en servidor funcione, el cliente debe enviar en cada petici\u00f3n que p\u00e1gina est\u00e1 solicitando y cual es el tama\u00f1o de la p\u00e1gina, para que el servidor devuelva solamente un subconjunto de datos, en lugar de devolver el listado completo.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/paginated/react/#crear-componente-author","title":"Crear componente author","text":"Lo primero que vamos a hacer es crear una carpeta llamada types dentro de src
. Aqu\u00ed crearemos los tipos de typescript. Creamos un nuevo fichero llamado Author.ts
cuyo contenido ser\u00e1 el siguiente:
export interface Author {\nid: string,\nname: string,\nnationality: string\n}\n\nexport interface AuthorResponse {\ncontent: Author[];\ntotalElements: number;\n}\n
Ahora vamos a crear un archivo de estilos que ser\u00e1 solo utilizado por la p\u00e1gina de author. Para ello dentro de la carpeta Author
creamos un archivo llamado Author.module.css
. Al llamar al archivo de esta manera React reconoce este archivo como un archivo \u00fanico para un componente y hace que sus reglas css sean m\u00e1s prioritarias, aunque por ejemplo exista una clase con el mismo nombre en el archivo index.css
.
El contenido de nuestro archivo css ser\u00e1 el siguiente:
index.css.tableActions {\nmargin-right: 20px;\ndisplay: flex;\njustify-content: flex-end;\nalign-content: flex-start;\ngap: 19px;\n}\n
Al igual que hicimos con categor\u00edas vamos a crear un nuevo componente para el formulario de alta y edici\u00f3n, para ello creamos una nueva carpeta llamada components en src/pages/Author
y dentro de esta carpeta crearemos un fichero llamado CreateAuthor.tsx
:
import { ChangeEvent, useEffect, useState } from \"react\";\nimport Button from \"@mui/material/Button\";\nimport TextField from \"@mui/material/TextField\";\nimport Dialog from \"@mui/material/Dialog\";\nimport DialogActions from \"@mui/material/DialogActions\";\nimport DialogContent from \"@mui/material/DialogContent\";\nimport DialogTitle from \"@mui/material/DialogTitle\";\nimport { Author } from \"../../../types/Author\";\n\ninterface Props {\nauthor: Author | null;\ncloseModal: () => void;\ncreate: (author: Author) => void;\n}\n\nconst initialState = {\nname: \"\",\nnationality: \"\",\n};\n\nexport default function CreateAuthor(props: Props) {\nconst [form, setForm] = useState(initialState);\n\nuseEffect(() => {\nsetForm(props?.author || initialState);\n}, [props?.author]);\n\nconst handleChangeForm = (\nevent: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>\n) => {\nsetForm({\n...form,\n[event.target.id]: event.target.value,\n});\n};\n\nreturn (\n<div>\n<Dialog open={true} onClose={props.closeModal}>\n<DialogTitle>\n{props.author ? \"Actualizar Autor\" : \"Crear Autor\"}\n</DialogTitle>\n<DialogContent>\n{props.author && (\n<TextField\nmargin=\"dense\"\ndisabled\nid=\"id\"\nlabel=\"Id\"\nfullWidth\nvalue={props.author.id}\nvariant=\"standard\"\n/>\n)}\n<TextField\nmargin=\"dense\"\nid=\"name\"\nlabel=\"Nombre\"\nfullWidth\nvariant=\"standard\"\nonChange={handleChangeForm}\nvalue={form.name}\n/>\n<TextField\nmargin=\"dense\"\nid=\"nationality\"\nlabel=\"Nacionalidad\"\nfullWidth\nvariant=\"standard\"\nonChange={handleChangeForm}\nvalue={form.nationality}\n/>\n</DialogContent>\n<DialogActions>\n<Button onClick={props.closeModal}>Cancelar</Button>\n<Button\nonClick={() =>\nprops.create({\nid: props.author ? props.author.id : \"\",\nname: form.name,\nnationality: form.nationality,\n})\n}\ndisabled={!form.name || !form.nationality}\n>\n{props.author ? \"Actualizar\" : \"Crear\"}\n</Button>\n</DialogActions>\n</Dialog>\n</div>\n);\n}\n
Como los autores tienen m\u00e1s campos hemos a\u00f1adido un poco de funcionalidad extra que no ten\u00edamos en el formulario de categor\u00edas, pero no es demasiado complicada.
Vamos a a\u00f1adir los m\u00e9todos necesarios para el crud de autores en el fichero src/redux/services/ludotecaApi.ts
:
getAllAuthors: builder.query<Author[], null>({\nquery: () => \"author\",\nprovidesTags: [\"Author\" ],\n}),\ngetAuthors: builder.query<\nAuthorResponse,\n{ pageNumber: number; pageSize: number }\n>({\nquery: ({ pageNumber, pageSize }) => {\nreturn {\nurl: \"author/\",\nmethod: \"POST\",\nbody: {\npageable: {\npageNumber,\npageSize,\n},\n},\n};\n},\nprovidesTags: [\"Author\"],\n}),\ncreateAuthor: builder.mutation({\nquery: (payload) => ({\nurl: \"/author\",\nmethod: \"PUT\",\nbody: payload,\nheaders: {\n\"Content-type\": \"application/json; charset=UTF-8\",\n},\n}),\ninvalidatesTags: [\"Author\"],\n}),\ndeleteAuthor: builder.mutation({\nquery: (id: string) => ({\nurl: `/author/${id}`,\nmethod: \"DELETE\",\n}),\ninvalidatesTags: [\"Author\"],\n}),\nupdateAuthor: builder.mutation({\nquery: (payload: Author) => ({\nurl: `author/${payload.id}`,\nmethod: \"PUT\",\nbody: payload,\n}),\ninvalidatesTags: [\"Author\", \"Game\"],\n}),\n
A\u00f1adimos tambi\u00e9n los imports, tags y exports necesarios y guardamos.
import { Author, AuthorResponse } from \"../../types/Author\";\n\ntagTypes: [\"Category\", \"Author\", \"Game\"],\n\nexport const {\nuseGetCategoriesQuery,\nuseCreateCategoryMutation,\nuseDeleteCategoryMutation,\nuseUpdateCategoryMutation,\nuseCreateAuthorMutation,\nuseDeleteAuthorMutation,\nuseGetAllAuthorsQuery,\nuseGetAuthorsQuery,\nuseUpdateAuthorMutation,\n} = ludotecaAPI;\n
Y por \u00faltimo el contenido de nuestro fichero Author.tsx
quedar\u00eda as\u00ed:
import { useEffect, useState, useContext } from \"react\";\nimport Button from \"@mui/material/Button\";\nimport TableHead from \"@mui/material/TableHead\";\nimport Table from \"@mui/material/Table\";\nimport TableBody from \"@mui/material/TableBody\";\nimport TableCell from \"@mui/material/TableCell\";\nimport TableContainer from \"@mui/material/TableContainer\";\nimport TableFooter from \"@mui/material/TableFooter\";\nimport TablePagination from \"@mui/material/TablePagination\";\nimport TableRow from \"@mui/material/TableRow\";\nimport Paper from \"@mui/material/Paper\";\nimport IconButton from \"@mui/material/IconButton\";\nimport EditIcon from \"@mui/icons-material/Edit\";\nimport ClearIcon from \"@mui/icons-material/Clear\";\nimport styles from \"./Author.module.css\";\nimport CreateAuthor from \"./components/CreateAuthor\";\nimport { ConfirmDialog } from \"../../components/ConfirmDialog\";\nimport { useAppDispatch } from \"../../redux/hooks\";\nimport { setMessage } from \"../../redux/features/messageSlice\";\nimport { BackError } from \"../../types/appTypes\";\nimport { Author as AuthorModel } from \"../../types/Author\";\nimport {\nuseDeleteAuthorMutation,\nuseGetAuthorsQuery,\nuseCreateAuthorMutation,\nuseUpdateAuthorMutation,\n} from \"../../redux/services/ludotecaApi\";\nimport { LoaderContext } from \"../../context/LoaderProvider\";\n\nexport const Author = () => {\nconst [pageNumber, setPageNumber] = useState(0);\nconst [pageSize, setPageSize] = useState(5);\nconst [total, setTotal] = useState(0);\nconst [authors, setAuthors] = useState<AuthorModel[]>([]);\nconst [openCreate, setOpenCreate] = useState(false);\nconst [idToDelete, setIdToDelete] = useState(\"\");\nconst [authorToUpdate, setAuthorToUpdate] = useState<AuthorModel | null>(\nnull\n);\n\nconst dispatch = useAppDispatch();\nconst loader = useContext(LoaderContext);\n\nconst handleChangePage = (\n_event: React.MouseEvent<HTMLButtonElement> | null,\nnewPage: number\n) => {\nsetPageNumber(newPage);\n};\n\nconst handleChangeRowsPerPage = (\nevent: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>\n) => {\nsetPageNumber(0);\nsetPageSize(parseInt(event.target.value, 10));\n};\n\nconst { data, error, isLoading } = useGetAuthorsQuery({\npageNumber,\npageSize,\n});\n\nconst [deleteAuthorApi, { isLoading: isLoadingDelete, error: errorDelete }] =\nuseDeleteAuthorMutation();\n\nconst [createAuthorApi, { isLoading: isLoadingCreate }] =\nuseCreateAuthorMutation();\n\nconst [updateAuthorApi, { isLoading: isLoadingUpdate }] =\nuseUpdateAuthorMutation();\n\nuseEffect(() => {\nloader.showLoading(\nisLoadingCreate || isLoading || isLoadingDelete || isLoadingUpdate\n);\n}, [isLoadingCreate, isLoading, isLoadingDelete, isLoadingUpdate]);\n\nuseEffect(() => {\nif (data) {\nsetAuthors(data.content);\nsetTotal(data.totalElements);\n}\n}, [data]);\n\nuseEffect(() => {\nif (errorDelete) {\nif (\"status\" in errorDelete) {\ndispatch(\nsetMessage({\ntext: (errorDelete?.data as BackError).msg,\ntype: \"error\",\n})\n);\n}\n}\n}, [errorDelete, dispatch]);\n\nuseEffect(() => {\nif (error) {\ndispatch(setMessage({ text: \"Se ha producido un error\", type: \"error\" }));\n}\n}, [error]);\n\nconst createAuthor = (author: AuthorModel) => {\nsetOpenCreate(false);\nif (author.id) {\nupdateAuthorApi(author)\n.then(() => {\ndispatch(\nsetMessage({\ntext: \"Autor actualizado correctamente\",\ntype: \"ok\",\n})\n);\nsetAuthorToUpdate(null);\n})\n.catch((err) => console.log(err));\n} else {\ncreateAuthorApi(author)\n.then(() => {\ndispatch(\nsetMessage({ text: \"Autor creado correctamente\", type: \"ok\" })\n);\nsetAuthorToUpdate(null);\n})\n.catch((err) => console.log(err));\n}\n};\n\nconst deleteAuthor = () => {\ndeleteAuthorApi(idToDelete)\n.then(() => {\nsetIdToDelete(\"\");\n})\n.catch((err) => console.log(err));\n};\n\nreturn (\n<div className=\"container\">\n<h1>Listado de Autores</h1>\n<TableContainer component={Paper}>\n<Table sx={{ minWidth: 500 }} aria-label=\"custom pagination table\">\n<TableHead\nsx={{\n\"& th\": {\nbackgroundColor: \"lightgrey\",\n},\n}}\n>\n<TableRow>\n<TableCell>Identificador</TableCell>\n<TableCell>Nombre Autor</TableCell>\n<TableCell>Nacionalidad</TableCell>\n<TableCell align=\"right\"></TableCell>\n</TableRow>\n</TableHead>\n<TableBody>\n{authors.map((author: AuthorModel) => (\n<TableRow key={author.id}>\n<TableCell component=\"th\" scope=\"row\">\n{author.id}\n</TableCell>\n<TableCell style={{ width: 160 }}>{author.name}</TableCell>\n<TableCell style={{ width: 160 }}>\n{author.nationality}\n</TableCell>\n<TableCell align=\"right\">\n<div className={styles.tableActions}>\n<IconButton\naria-label=\"update\"\ncolor=\"primary\"\nonClick={() => {\nsetAuthorToUpdate(author);\nsetOpenCreate(true);\n}}\n>\n<EditIcon />\n</IconButton>\n<IconButton\naria-label=\"delete\"\ncolor=\"error\"\nonClick={() => {\nsetIdToDelete(author.id);\n}}\n>\n<ClearIcon />\n</IconButton>\n</div>\n</TableCell>\n</TableRow>\n))}\n</TableBody>\n<TableFooter>\n<TableRow>\n<TablePagination\nrowsPerPageOptions={[5, 10, 25]}\ncolSpan={4}\ncount={total}\nrowsPerPage={pageSize}\npage={pageNumber}\nSelectProps={{\ninputProps: {\n\"aria-label\": \"rows per page\",\n},\nnative: true,\n}}\nonPageChange={handleChangePage}\nonRowsPerPageChange={handleChangeRowsPerPage}\n/>\n</TableRow>\n</TableFooter>\n</Table>\n</TableContainer>\n<div className=\"newButton\">\n<Button variant=\"contained\" onClick={() => setOpenCreate(true)}>\nNuevo autor\n</Button>\n</div>\n{openCreate && (\n<CreateAuthor\ncreate={createAuthor}\nauthor={authorToUpdate}\ncloseModal={() => {\nsetAuthorToUpdate(null);\nsetOpenCreate(false);\n}}\n/>\n)}\n{!!idToDelete && (\n<ConfirmDialog\ntitle=\"Eliminar Autor\"\ntext=\"Atenci\u00f3n si borra el autor se perder\u00e1n sus datos. \u00bfDesea eliminar el autor?\"\nconfirm={deleteAuthor}\ncloseModal={() => setIdToDelete(\"\")}\n/>\n)}\n</div>\n);\n};\n
Al tratarse de un listado paginado hemos creado dos nuevas variables en nuestro estado para almacenar la p\u00e1gina y el n\u00famero de registros a mostrar en la p\u00e1gina. Cuando cambiamos estos valores en el navegador como estas variables van como par\u00e1metro en nuestro hook
para recuperar datos autom\u00e1ticamente el listado se va a modificar.
El resto de funcionalidad es muy parecida a la de categor\u00edas.
"},{"location":"develop/paginated/springboot/","title":"Listado paginado - Spring Boot","text":"Ahora vamos a implementar las operaciones necesarias para ayudar al front a cubrir la funcionalidad del CRUD paginado en servidor. Recuerda que para que un listado paginado en servidor funcione, el cliente debe enviar en cada petici\u00f3n que p\u00e1gina est\u00e1 solicitando y cu\u00e1l es el tama\u00f1o de la p\u00e1gina, para que el servidor devuelva solamente un subconjunto de datos, en lugar de devolver el listado completo.
Como ya conocemos como se debe desarrollar, en este ejemplo vamos a ir m\u00e1s r\u00e1pidos y nos vamos a centrar \u00fanicamente en las novedades.
"},{"location":"develop/paginated/springboot/#crear-modelos","title":"Crear modelos","text":"Lo primero que vamos a hacer es crear los modelos para trabajar con BBDD y con peticiones hacia el front. Adem\u00e1s, tambi\u00e9n tenemos que a\u00f1adir datos al script de inicializaci\u00f3n de BBDD, siempre respetando la nomenclatura que le hemos dado a la tabla y columnas de BBDD.
Author.javaAuthorDto.javadata.sqlpackage com.ccsw.tutorial.author.model;\n\nimport jakarta.persistence.*;\n\n/**\n * @author ccsw\n *\n */\n@Entity\n@Table(name = \"author\")\npublic class Author {\n\n@Id\n@GeneratedValue(strategy = GenerationType.IDENTITY)\n@Column(name = \"id\", nullable = false)\nprivate Long id;\n\n@Column(name = \"name\", nullable = false)\nprivate String name;\n\n@Column(name = \"nationality\")\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
package com.ccsw.tutorial.author.model;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorDto {\n\nprivate Long id;\n\nprivate String name;\n\nprivate String nationality;\n\n/**\n * @return id\n */\npublic Long getId() {\n\nreturn this.id;\n}\n\n/**\n * @param id new value of {@link #getId}.\n */\npublic void setId(Long id) {\n\nthis.id = id;\n}\n\n/**\n * @return name\n */\npublic String getName() {\n\nreturn this.name;\n}\n\n/**\n * @param name new value of {@link #getName}.\n */\npublic void setName(String name) {\n\nthis.name = name;\n}\n\n/**\n * @return nationality\n */\npublic String getNationality() {\n\nreturn this.nationality;\n}\n\n/**\n * @param nationality new value of {@link #getNationality}.\n */\npublic void setNationality(String nationality) {\n\nthis.nationality = nationality;\n}\n\n}\n
INSERT INTO category(name) VALUES ('Eurogames');\nINSERT INTO category(name) VALUES ('Ameritrash');\nINSERT INTO category(name) VALUES ('Familiar');\n\nINSERT INTO author(name, nationality) VALUES ('Alan R. Moon', 'US');\nINSERT INTO author(name, nationality) VALUES ('Vital Lacerda', 'PT');\nINSERT INTO author(name, nationality) VALUES ('Simone Luciani', 'IT');\nINSERT INTO author(name, nationality) VALUES ('Perepau Llistosella', 'ES');\nINSERT INTO author(name, nationality) VALUES ('Michael Kiesling', 'DE');\nINSERT INTO author(name, nationality) VALUES ('Phil Walker-Harding', 'US');\n
"},{"location":"develop/paginated/springboot/#implementar-tdd-pruebas","title":"Implementar TDD - Pruebas","text":"Para desarrollar todas las operaciones, empezaremos primero dise\u00f1ando las pruebas y luego implementando el c\u00f3digo necesario que haga funcionar correctamente esas pruebas. Para ir m\u00e1s r\u00e1pido vamos a poner todas las pruebas de golpe, pero realmente se deber\u00edan crear una a una e ir implementando el c\u00f3digo necesario para esa prueba. Para evitar tantas iteraciones en el tutorial las haremos todas de golpe.
Vamos a pararnos a pensar un poco que necesitamos en la pantalla. Ahora mismo nos sirve con:
Para la primera prueba que hemos descrito (consulta paginada) se necesita un objeto que contenga los datos de la p\u00e1gina a consultar. As\u00ed que crearemos una clase AuthorSearchDto
para utilizarlo como 'paginador'.
Para ello, en primer lugar, deberemos a\u00f1adir una clase que vamos a utilizar como envoltorio para las peticiones de paginaci\u00f3n en el proyecto. Hacemos esto para desacoplar la interface de Spring Boot de nuestro contrato de entrada. Crearemos esta clase en el paquete com.ccsw.tutorial.common.pagination
.
package com.ccsw.tutorial.common.pagination;\n\nimport com.fasterxml.jackson.annotation.JsonIgnore;\nimport org.springframework.data.domain.*;\n\nimport java.io.Serializable;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.stream.Collectors;\n\npublic class PageableRequest implements Serializable {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate int pageNumber;\n\nprivate int pageSize;\n\nprivate List<SortRequest> sort;\n\npublic PageableRequest() {\n\nsort = new ArrayList<>();\n}\n\npublic PageableRequest(int pageNumber, int pageSize) {\n\nthis();\nthis.pageNumber = pageNumber;\nthis.pageSize = pageSize;\n}\n\npublic PageableRequest(int pageNumber, int pageSize, List<SortRequest> sort) {\n\nthis();\nthis.pageNumber = pageNumber;\nthis.pageSize = pageSize;\nthis.sort = sort;\n}\n\npublic int getPageNumber() {\nreturn pageNumber;\n}\n\npublic void setPageNumber(int pageNumber) {\nthis.pageNumber = pageNumber;\n}\n\npublic int getPageSize() {\nreturn pageSize;\n}\n\npublic void setPageSize(int pageSize) {\nthis.pageSize = pageSize;\n}\n\npublic List<SortRequest> getSort() {\nreturn sort;\n}\n\npublic void setSort(List<SortRequest> sort) {\nthis.sort = sort;\n}\n\n@JsonIgnore\npublic Pageable getPageable() {\n\nreturn PageRequest.of(this.pageNumber, this.pageSize, Sort.by(sort.stream().map(e -> new Sort.Order(e.getDirection(), e.getProperty())).collect(Collectors.toList())));\n}\n\npublic static class SortRequest implements Serializable {\n\nprivate static final long serialVersionUID = 1L;\n\nprivate String property;\n\nprivate Sort.Direction direction;\n\nprotected String getProperty() {\nreturn property;\n}\n\nprotected void setProperty(String property) {\nthis.property = property;\n}\n\nprotected Sort.Direction getDirection() {\nreturn direction;\n}\n\nprotected void setDirection(Sort.Direction direction) {\nthis.direction = direction;\n}\n}\n\n}\n
Adicionalmente necesitaremos una clase para deserializar las respuestas de Page recibidas en los test que vamos a implementar. Para ello creamos la clase necesaria dentro de la fuente de la carpeta de los test
en el paquete com.ccsw.tutorial.config
. Esto solo hace falta porque necesitamos leer la respuesta paginada en el test, si no hicieramos test, no har\u00eda falta.
package com.ccsw.tutorial.config;\n\nimport com.fasterxml.jackson.annotation.JsonCreator;\nimport com.fasterxml.jackson.annotation.JsonIgnoreProperties;\nimport com.fasterxml.jackson.annotation.JsonProperty;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.data.domain.PageRequest;\nimport org.springframework.data.domain.Pageable;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\n@JsonIgnoreProperties(ignoreUnknown = true)\npublic class ResponsePage<T> extends PageImpl<T> {\n\nprivate static final long serialVersionUID = 1L;\n\n@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)\npublic ResponsePage(@JsonProperty(\"content\") List<T> content,\n@JsonProperty(\"number\") int number,\n@JsonProperty(\"size\") int size,\n@JsonProperty(\"totalElements\") Long totalElements,\n@JsonProperty(\"pageable\") JsonNode pageable,\n@JsonProperty(\"last\") boolean last,\n@JsonProperty(\"totalPages\") int totalPages,\n@JsonProperty(\"sort\") JsonNode sort,\n@JsonProperty(\"first\") boolean first,\n@JsonProperty(\"numberOfElements\") int numberOfElements) {\n\nsuper(content, PageRequest.of(number, size), totalElements);\n}\n\npublic ResponsePage(List<T> content, Pageable pageable, long total) {\nsuper(content, pageable, total);\n}\n\npublic ResponsePage(List<T> content) {\nsuper(content);\n}\n\npublic ResponsePage() {\nsuper(new ArrayList<>());\n}\n\n}\n
Paginaci\u00f3n en Springframework
Cuando utilicemos paginaci\u00f3n en Springframework, debemos recordar que ya vienen implementados algunos objetos que podemos utilizar y que nos facilitan la vida. Es el caso de Pageable
y Page
.
Pageable
no es m\u00e1s que una interface que le permite a Spring JPA saber que p\u00e1gina se quiere buscar, cual es el tama\u00f1o de p\u00e1gina y cuales son las propiedades de ordenaci\u00f3n que se debe lanzar en la consulta.PageRequest
es una utilidad que permite crear objetos de tipo Pageable
de forma sencilla. Se utiliza mucho para codificaci\u00f3n de test.Page
no es m\u00e1s que un contenedor que engloba la informaci\u00f3n b\u00e1sica de la p\u00e1gina que se est\u00e1 consultando (n\u00famero de p\u00e1gina, tama\u00f1o de p\u00e1gina, n\u00famero total de resultados) y el conjunto de datos de la BBDD que contiene esa p\u00e1gina una vez han sido buscados y ordenados.Tambi\u00e9n crearemos una clase AuthorController
dentro del package de com.ccsw.tutorial.author
con la implementaci\u00f3n de los m\u00e9todos vac\u00edos, para que no falle la compilaci\u00f3n.
\u00a1Vamos a implementar test!
AuthorSearchDto.javaAuthorController.javaAuthorIT.javapackage com.ccsw.tutorial.author.model;\n\nimport com.ccsw.tutorial.common.pagination.PageableRequest;\n\n/**\n * @author ccsw\n *\n */\npublic class AuthorSearchDto {\n\nprivate PageableRequest pageable;\n\npublic PageableRequest getPageable() {\nreturn pageable;\n}\n\npublic void setPageable(PageableRequest pageable) {\nthis.pageable = pageable;\n}\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.springframework.data.domain.Page;\nimport org.springframework.web.bind.annotation.*;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Author\", description = \"API of Author\")\n@RequestMapping(value = \"/author\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class AuthorController {\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link AuthorDto}\n */\n@Operation(summary = \"Find Page\", description = \"Method that return a page of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.POST)\npublic Page<AuthorDto> findPage(@RequestBody AuthorSearchDto dto) {\n\nreturn null;\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Author\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody AuthorDto dto) {\n\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Author\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\n}\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport com.ccsw.tutorial.common.pagination.PageableRequest;\nimport com.ccsw.tutorial.config.ResponsePage;\nimport org.junit.jupiter.api.Test;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.boot.test.context.SpringBootTest;\nimport org.springframework.boot.test.web.client.TestRestTemplate;\nimport org.springframework.boot.test.web.server.LocalServerPort;\nimport org.springframework.core.ParameterizedTypeReference;\nimport org.springframework.http.*;\nimport org.springframework.test.annotation.DirtiesContext;\n\nimport java.util.List;\n\nimport static org.junit.jupiter.api.Assertions.assertEquals;\nimport static org.junit.jupiter.api.Assertions.assertNotNull;\n\n@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)\n@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)\npublic class AuthorIT {\n\npublic static final String LOCALHOST = \"http://localhost:\";\npublic static final String SERVICE_PATH = \"/author\";\n\npublic static final Long DELETE_AUTHOR_ID = 6L;\npublic static final Long MODIFY_AUTHOR_ID = 3L;\npublic static final String NEW_AUTHOR_NAME = \"Nuevo Autor\";\npublic static final String NEW_NATIONALITY = \"Nueva Nacionalidad\";\n\nprivate static final int TOTAL_AUTHORS = 6;\nprivate static final int PAGE_SIZE = 5;\n\n@LocalServerPort\nprivate int port;\n\n@Autowired\nprivate TestRestTemplate restTemplate;\n\nParameterizedTypeReference<ResponsePage<AuthorDto>> responseTypePage = new ParameterizedTypeReference<ResponsePage<AuthorDto>>(){};\n\n@Test\npublic void findFirstPageWithFiveSizeShouldReturnFirstFiveResults() {\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(0, PAGE_SIZE));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(TOTAL_AUTHORS, response.getBody().getTotalElements());\nassertEquals(PAGE_SIZE, response.getBody().getContent().size());\n}\n\n@Test\npublic void findSecondPageWithFiveSizeShouldReturnLastResult() {\n\nint elementsCount = TOTAL_AUTHORS - PAGE_SIZE;\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(1, PAGE_SIZE));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(TOTAL_AUTHORS, response.getBody().getTotalElements());\nassertEquals(elementsCount, response.getBody().getContent().size());\n}\n\n@Test\npublic void saveWithoutIdShouldCreateNewAuthor() {\n\nlong newAuthorId = TOTAL_AUTHORS + 1;\nlong newAuthorSize = TOTAL_AUTHORS + 1;\n\nAuthorDto dto = new AuthorDto();\ndto.setName(NEW_AUTHOR_NAME);\ndto.setNationality(NEW_NATIONALITY);\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(0, (int) newAuthorSize));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(newAuthorSize, response.getBody().getTotalElements());\n\nAuthorDto author = response.getBody().getContent().stream().filter(item -> item.getId().equals(newAuthorId)).findFirst().orElse(null);\nassertNotNull(author);\nassertEquals(NEW_AUTHOR_NAME, author.getName());\n}\n\n@Test\npublic void modifyWithExistIdShouldModifyAuthor() {\n\nAuthorDto dto = new AuthorDto();\ndto.setName(NEW_AUTHOR_NAME);\ndto.setNationality(NEW_NATIONALITY);\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + MODIFY_AUTHOR_ID, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(0, PAGE_SIZE));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(TOTAL_AUTHORS, response.getBody().getTotalElements());\n\nAuthorDto author = response.getBody().getContent().stream().filter(item -> item.getId().equals(MODIFY_AUTHOR_ID)).findFirst().orElse(null);\nassertNotNull(author);\nassertEquals(NEW_AUTHOR_NAME, author.getName());\nassertEquals(NEW_NATIONALITY, author.getNationality());\n}\n\n@Test\npublic void modifyWithNotExistIdShouldThrowException() {\n\nlong authorId = TOTAL_AUTHORS + 1;\n\nAuthorDto dto = new AuthorDto();\ndto.setName(NEW_AUTHOR_NAME);\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + authorId, HttpMethod.PUT, new HttpEntity<>(dto), Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n\n@Test\npublic void deleteWithExistsIdShouldDeleteCategory() {\n\nlong newAuthorsSize = TOTAL_AUTHORS - 1;\n\nrestTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + DELETE_AUTHOR_ID, HttpMethod.DELETE, null, Void.class);\n\nAuthorSearchDto searchDto = new AuthorSearchDto();\nsearchDto.setPageable(new PageableRequest(0, TOTAL_AUTHORS));\n\nResponseEntity<ResponsePage<AuthorDto>> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH, HttpMethod.POST, new HttpEntity<>(searchDto), responseTypePage);\n\nassertNotNull(response);\nassertEquals(newAuthorsSize, response.getBody().getTotalElements());\n}\n\n@Test\npublic void deleteWithNotExistsIdShouldThrowException() {\n\nlong deleteAuthorId = TOTAL_AUTHORS + 1;\n\nResponseEntity<?> response = restTemplate.exchange(LOCALHOST + port + SERVICE_PATH + \"/\" + deleteAuthorId, HttpMethod.DELETE, null, Void.class);\n\nassertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode());\n}\n\n}\n
Cuidado con las clases de Test
Recuerda que el c\u00f3digo de aplicaci\u00f3n debe ir en src/main/java
, mientras que las clases de test deben ir en src/test/java
para que no se mezclen unas con otras y se empaquete todo en el artefacto final. En este caso AuthorIT.java
va en el directorio de test src/test/java
.
Si ejecutamos los test, el resultado ser\u00e1 7 maravillosos test que fallan su ejecuci\u00f3n. Es normal, puesto que no hemos implementado nada de c\u00f3digo de aplicaci\u00f3n para corresponder esos test.
"},{"location":"develop/paginated/springboot/#implementar-controller","title":"Implementar Controller","text":"Si recuerdas, esta capa de Controller
es la que tiene los endpoints de entrada a la aplicaci\u00f3n. Nosotros ya tenemos definidas 3 operaciones, que hemos dise\u00f1ado directamente desde los tests. Ahora vamos a implementar esos m\u00e9todos con el c\u00f3digo necesario para que los test funcionen correctamente, y teniendo en mente que debemos apoyarnos en las capas inferiores Service
y Repository
para repartir l\u00f3gica de negocio y acceso a datos.
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport io.swagger.v3.oas.annotations.Operation;\nimport io.swagger.v3.oas.annotations.tags.Tag;\nimport org.modelmapper.ModelMapper;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.PageImpl;\nimport org.springframework.web.bind.annotation.*;\n\nimport java.util.List;\nimport java.util.stream.Collectors;\n\n/**\n * @author ccsw\n *\n */\n@Tag(name = \"Author\", description = \"API of Author\")\n@RequestMapping(value = \"/author\")\n@RestController\n@CrossOrigin(origins = \"*\")\npublic class AuthorController {\n\n@Autowired\nAuthorService authorService;\n@Autowired\nModelMapper mapper;\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link AuthorDto}\n */\n@Operation(summary = \"Find Page\", description = \"Method that return a page of Authors\")\n@RequestMapping(path = \"\", method = RequestMethod.POST)\npublic Page<AuthorDto> findPage(@RequestBody AuthorSearchDto dto) {\n\nPage<Author> page = this.authorService.findPage(dto);\n\nreturn new PageImpl<>(page.getContent().stream().map(e -> mapper.map(e, AuthorDto.class)).collect(Collectors.toList()), page.getPageable(), page.getTotalElements());\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\n@Operation(summary = \"Save or Update\", description = \"Method that saves or updates a Author\")\n@RequestMapping(path = { \"\", \"/{id}\" }, method = RequestMethod.PUT)\npublic void save(@PathVariable(name = \"id\", required = false) Long id, @RequestBody AuthorDto dto) {\n\nthis.authorService.save(id, dto);\n}\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\n@Operation(summary = \"Delete\", description = \"Method that deletes a Author\")\n@RequestMapping(path = \"/{id}\", method = RequestMethod.DELETE)\npublic void delete(@PathVariable(\"id\") Long id) throws Exception {\n\nthis.authorService.delete(id);\n}\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport org.springframework.data.domain.Page;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorService {\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param dto dto de b\u00fasqueda\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findPage(AuthorSearchDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n * @param dto datos de la entidad\n */\nvoid save(Long id, AuthorDto dto);\n\n/**\n * M\u00e9todo para crear o actualizar un {@link Author}\n *\n * @param id PK de la entidad\n */\nvoid delete(Long id) throws Exception;\n\n}\n
Si te fijas, hemos trasladado toda la l\u00f3gica a llamadas al AuthorService
que hemos inyectado, y para que no falle la compilaci\u00f3n hemos creado una interface con los m\u00e9todos necesarios.
En la clase AuthorController
es donde se hacen las conversiones de cara al cliente, pasaremos de un Page<Author>
(modelo entidad) a un Page<AuthorDto>
(modelo DTO) con la ayuda del beanMapper. Recuerda que al cliente no le deben llegar modelos entidades sino DTOs.
Adem\u00e1s, el m\u00e9todo de carga findPage
ya no es un m\u00e9todo de tipo GET
, ahora es de tipo POST
porque le tenemos que enviar los datos de la paginaci\u00f3n para que Spring JPA pueda hacer su magia.
Ahora debemos implementar la siguiente capa.
"},{"location":"develop/paginated/springboot/#implementar-service","title":"Implementar Service","text":"La siguiente capa que vamos a implementar es justamente la capa que contiene toda la l\u00f3gica de negocio, hace uso del Repository
para acceder a los datos, y recibe llamadas generalmente de los Controller
.
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport com.ccsw.tutorial.author.model.AuthorDto;\nimport com.ccsw.tutorial.author.model.AuthorSearchDto;\nimport jakarta.transaction.Transactional;\nimport org.springframework.beans.BeanUtils;\nimport org.springframework.beans.factory.annotation.Autowired;\nimport org.springframework.data.domain.Page;\nimport org.springframework.stereotype.Service;\n\nimport java.util.List;\n\n/**\n * @author ccsw\n *\n */\n@Service\n@Transactional\npublic class AuthorServiceImpl implements AuthorService {\n\n@Autowired\nAuthorRepository authorRepository;\n/**\n * {@inheritDoc}\n */\n@Override\npublic Page<Author> findPage(AuthorSearchDto dto) {\n\nreturn this.authorRepository.findAll(dto.getPageable().getPageable());\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void save(Long id, AuthorDto data) {\n\nAuthor author;\n\nif (id == null) {\nauthor = new Author();\n} else {\nauthor = this.authorRepository.findById(id).orElse(null);\n}\n\nBeanUtils.copyProperties(data, author, \"id\");\nthis.authorRepository.save(author);\n}\n\n/**\n * {@inheritDoc}\n */\n@Override\npublic void delete(Long id) throws Exception {\n\nif(this.authorRepository.findById(id).orElse(null) == null){\nthrow new Exception(\"Not exists\");\n}\n\nthis.authorRepository.deleteById(id);\n}\n\n}\n
package com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorRepository extends CrudRepository<Author, Long> {\n}\n
De nuevo pasa lo mismo que con la capa anterior, aqu\u00ed delegamos muchas operaciones de consulta y guardado de datos en AuthorRepository
. Hemos tenido que crearlo como interface para que no falle la compilaci\u00f3n. Recuerda que cuando creamos un Repository
es de gran ayuda hacerlo extender de CrudRepository<T, ID>
ya que tiene muchos m\u00e9todos implementados de base que nos pueden servir, como el delete
o el save
.
F\u00edjate tambi\u00e9n que cuando queremos copiar m\u00e1s de un dato de una clase a otra, tenemos una utilidad llamada BeanUtils
que nos permite realizar esa copia (siempre que las propiedades de ambas clases se llamen igual). Adem\u00e1s, en nuestro ejemplo hemos ignorado el 'id' para que no nos copie un null a la clase destino.
Y llegamos a la \u00faltima capa, la que est\u00e1 m\u00e1s cerca de los datos finales. Tenemos la siguiente interface:
AuthorRepository.javapackage com.ccsw.tutorial.author;\n\nimport com.ccsw.tutorial.author.model.Author;\nimport org.springframework.data.domain.Page;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.data.repository.CrudRepository;\n\n/**\n * @author ccsw\n *\n */\npublic interface AuthorRepository extends CrudRepository<Author, Long> {\n\n/**\n * M\u00e9todo para recuperar un listado paginado de {@link Author}\n *\n * @param pageable pageable\n * @return {@link Page} de {@link Author}\n */\nPage<Author> findAll(Pageable pageable);\n\n}\n
Si te fijas, este Repository
ya no est\u00e1 vac\u00edo como el anterior, no nos sirve con las operaciones b\u00e1sicas del CrudRepository
en este caso hemos tenido que a\u00f1adir un m\u00e9todo nuevo al que pasandole un objeto de tipo Pageable
nos devuelva una Page
.
Pues bien, resulta que la m\u00e1gina de Spring JPA en este caso har\u00e1 su trabajo y nosotros no necesitamos implementar ninguna query, Spring ya entiende que un findAll
significa que debe recuperar todos los datos de la tabla Author
(que es la tabla que tiene como generico
en CrudRepository
) y adem\u00e1s deben estar paginados ya que el m\u00e9todo devuelve un objeto tipo Page
. Nos ahorra tener que generar una sql para buscar una p\u00e1gina concreta de datos y hacer un count
de la tabla para obtener el total de resultados. Para ver otros ejemplos y m\u00e1s informaci\u00f3n, visita la p\u00e1gina de QueryMethods. Realmente se puede hacer much\u00edsimas cosas con solo escribir el nombre del m\u00e9todo, sin tener que pensar ni teclear ninguna sql.
Con esto ya lo tendr\u00edamos todo.
"},{"location":"develop/paginated/springboot/#probar-las-operaciones","title":"Probar las operaciones","text":"Si ahora ejecutamos los test jUnit, veremos que todos funcionan y est\u00e1n en verde. Hemos implementado todas nuestras pruebas y la aplicaci\u00f3n es correcta.
Aun as\u00ed, debemos realizar pruebas con el postman para ver los resultados que nos ofrece el back. Para ello, tienes que levantar la aplici\u00f3n y ejecutar las siguientes operaciones:
** POST /author **
{\n \"pageable\": {\n \"pageSize\" : 4,\n \"pageNumber\" : 0,\n \"sort\" : [\n {\n \"property\": \"name\",\n \"direction\": \"ASC\"\n }\n ]\n }\n}\n
Nos devuelve un listado paginado de Autores
. F\u00edjate que los datos que se env\u00edan est\u00e1n en el body como formato JSON (parte izquierda de la imagen). Si no env\u00edas datos con formato Pageable
, te dar\u00e1 un error. Tambi\u00e9n f\u00edjate que la respuesta es de tipo Page
. Prueba a jugar con los datos de paginaci\u00f3n e incluso de ordenaci\u00f3n. No hemos programado ninguna SQL pero Spring hace su magia. ** PUT /author **
** PUT /author/{id} **
{\n \"name\" : \"Nuevo autor\",\n \"nationality\" : \"Nueva nacionalidad\"\n}\n
Nos sirve para insertar Autores
nuevas (si no tienen el id informado) o para actualizar Autores
(si tienen el id informado en la URL). F\u00edjate que los datos que se env\u00edan est\u00e1n en el body como formato JSON (parte izquierda de la imagen). Si no te dar\u00e1 un error.
** DELETE /author/{id} ** nos sirve eliminar Autores
. F\u00edjate que el dato del ID que se env\u00eda est\u00e1 en el path.
Ahora nos ponemos con la pantalla de autores y vamos a realizar los cambios para poder realizar un paginado en la tabla de autores, adem\u00e1s de realizar los cambios oportunos para poder a\u00f1adir, editar y borrar autores.
"},{"location":"develop/paginated/vuejs/#acciones-posibles","title":"Acciones posibles","text":""},{"location":"develop/paginated/vuejs/#anadir-una-fila","title":"A\u00f1adir una fila","text":"Para poder a\u00f1adir una fila, vamos a tener que a\u00f1adir al componente de dialog de adici\u00f3n un nuevo campo que ser\u00e1 la nacionalidad habiendo quitado los que hab\u00edamos copiado del cat\u00e1logo dejando finalmente solo dos: el nombre y la nacionalidad.
Veremos el estado del c\u00f3digo en el apartado de borrado.
"},{"location":"develop/paginated/vuejs/#editar-una-fila","title":"Editar una fila","text":"A la hora de editar una fila, modificaremos la columna de \u201cedad\u201d para reutilizarla con la nacionalidad, reutilizaremos la columna de \u201cnombre\u201d tal cual est\u00e1 y borraremos las dem\u00e1s exceptuando la de opciones que ah\u00ed pondremos el bot\u00f3n para el borrado.
Veremos el estado del c\u00f3digo en el apartado de borrado.
"},{"location":"develop/paginated/vuejs/#borrar-una-fila","title":"Borrar una fila","text":"Y, por \u00faltimo, haremos lo mismo que hicimos en la pantalla de categor\u00edas, que es a\u00f1adir la funci\u00f3n delete despu\u00e9s del dialog de confirmaci\u00f3n.
El estado del c\u00f3digo ahora mismo quedar\u00eda as\u00ed:
<template>\n <q-page padding>\n <q-table\n hide-bottom\n :rows=\"authorsData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Cat\u00e1logo\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n >\n <template v-slot:top>\n <q-btn flat round color=\"primary\" icon=\"add\" @click=\"showAdd = true\" />\n </template>\n <template v-slot:body=\"props\">\n <q-tr :props=\"props\">\n <q-td key=\"id\" :props=\"props\">{{ props.row.id }}</q-td>\n <q-td key=\"name\" :props=\"props\">\n {{ props.row.name }}\n <q-popup-edit\n v-model=\"props.row.name\"\n title=\"Cambiar nombre\"\n v-slot=\"scope\"\n >\n <q-input\n v-model=\"scope.value\"\n dense\n autofocus\n counter\n @keyup.enter=\"editRow(props, scope, 'name')\"\n >\n <template v-slot:append>\n <q-icon name=\"edit\" />\n </template>\n </q-input>\n </q-popup-edit>\n </q-td>\n <q-td key=\"nationality\" :props=\"props\">\n {{ props.row.nationality }}\n <q-popup-edit\n v-model=\"props.row.nationality\"\n title=\"Cambiar nacionalidad\"\n v-slot=\"scope\"\n >\n <q-input\n v-model=\"scope.value\"\n dense\n autofocus\n counter\n @keyup.enter=\"editRow(props, scope, 'nationality')\"\n >\n <template v-slot:append>\n <q-icon name=\"edit\" />\n </template>\n </q-input>\n </q-popup-edit>\n </q-td>\n <q-td key=\"options\" :props=\"props\">\n <q-btn\n flat\n round\n color=\"negative\"\n icon=\"delete\"\n @click=\"showDeleteDialog(props.row)\"\n />\n </q-td>\n </q-tr>\n </template>\n </q-table>\n <q-dialog v-model=\"showDelete\" persistent>\n <q-card>\n <q-card-section class=\"row items-center\">\n <q-icon\n name=\"delete\"\n size=\"sm\"\n color=\"negative\"\n @click=\"showDelete = true\"\n />\n <span class=\"q-ml-sm\">\n \u00bfEst\u00e1s seguro de que quieres borrar este elemento?\n </span>\n </q-card-section>\n\n <q-card-actions align=\"right\">\n <q-btn flat label=\"Cancelar\" color=\"primary\" v-close-popup />\n <q-btn\n flat\n label=\"Confirmar\"\n color=\"primary\"\n v-close-popup\n @click=\"deleteAuthor\"\n />\n </q-card-actions>\n </q-card>\n </q-dialog>\n <q-dialog v-model=\"showAdd\">\n <q-card style=\"width: 300px\" class=\"q-px-sm q-pb-md\">\n <q-card-section>\n <div class=\"text-h6\">Nuevo autor</div>\n </q-card-section>\n\n <q-item-label header>Nombre</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"badge\" />\n </q-item-section>\n <q-item-section>\n <q-input dense v-model=\"authorToAdd.name\" autofocus />\n </q-item-section>\n </q-item>\n\n <q-item-label header>Nacionalidad</q-item-label>\n <q-item dense>\n <q-item-section avatar>\n <q-icon name=\"flag\" />\n </q-item-section>\n <q-item-section>\n <q-input\n dense\n v-model=\"authorToAdd.nationality\"\n autofocus\n @keyup.enter=\"addAuthor\"\n />\n </q-item-section>\n </q-item>\n\n <q-card-actions align=\"right\" class=\"text-primary\">\n <q-btn flat label=\"Cancelar\" v-close-popup />\n <q-btn flat label=\"A\u00f1adir autor\" v-close-popup @click=\"addAuthor\" />\n </q-card-actions>\n </q-card>\n </q-dialog>\n </q-page>\n</template>\n\n<script setup lang=\"ts\">\nimport { ref } from 'vue';\nimport { useFetch, whenever } from '@vueuse/core';\n\nconst columns = [\n { name: 'id', align: 'left', label: 'ID', field: 'id', sortable: true },\n {\n name: 'name',\n align: 'left',\n label: 'Nombre',\n field: 'name',\n sortable: true,\n },\n {\n name: 'nationality',\n align: 'left',\n label: 'Nacionalidad',\n field: 'nationality',\n sortable: true,\n },\n { name: 'options', align: 'left', label: 'Options', field: 'options' },\n];\nconst pagination = {\n page: 1,\n rowsPerPage: 0,\n};\nconst newAuthor = {\n name: '',\n nationality: '',\n};\n\nconst authorsData = ref([]);\nconst showDelete = ref(false);\nconst showAdd = ref(false);\nconst selectedRow = ref({});\nconst authorToAdd = ref({ ...newAuthor });\n\nconst getAuthors = () => {\n const { data } = useFetch('http://localhost:8080/author').get().json();\n whenever(data, () => (authorsData.value = data.value));\n};\ngetAuthors();\n\nconst showDeleteDialog = (item: any) => {\n selectedRow.value = item;\n showDelete.value = true;\n};\n\nconst addAuthor = async () => {\n await useFetch('http://localhost:8080/author', {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(authorToAdd.value),\n })\n .put()\n .json();\n\n getAuthors();\n authorToAdd.value = newAuthor;\n showAdd.value = false;\n};\n\nconst editRow = (props: any, scope: any, field: any) => {\n const row = {\n name: props.row.name,\n nationality: props.row.nationality,\n };\n row[field] = scope.value;\n scope.set();\n editAuthor(props.row.id, row);\n};\n\nconst editAuthor = async (id: string, reqBody: any) => {\n await useFetch(`http://localhost:8080/author/${id}`, {\n method: 'PUT',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(reqBody),\n })\n .put()\n .json();\n\n getAuthors();\n};\n\nconst deleteAuthor = async () => {\n await useFetch(`http://localhost:8080/author/${selectedRow.value.id}`, {\n method: 'DELETE',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n })\n .delete()\n .json();\n\n getAuthors();\n};\n</script>\n
"},{"location":"develop/paginated/vuejs/#paginado","title":"Paginado","text":"Lo primero que tenemos que hacer es usar las nuevas caracter\u00edsticas de nuestra tabla para poder a\u00f1adir datos y as\u00ed hacer funcionar el paginado correctamente.
Lo primero que vamos a hacer es cambiar el objeto de paginaci\u00f3n para que tenga lo siguiente:
const pagination = ref({\n page: 0,\n rowsPerPage: 5,\n rowsNumber: 10,\n});\n
Y debido a que la tabla y el back requieren de formatos diferentes para la paginaci\u00f3n, vamos a tener que realizar una funci\u00f3n que formatee el objeto para enviarlo al back. Esta funci\u00f3n ser\u00e1, m\u00e1s o menos, as\u00ed:
const formatPageableBody = (props: any) => {\n return {\n pageable: {\n pageSize:\n props.pagination.rowsPerPage !== 0\n ? props.pagination.rowsPerPage\n : props.pagination.rowsNumber,\n pageNumber: props.pagination.page - 1,\n sort: [\n {\n property: 'name',\n direction: 'ASC',\n },\n ],\n },\n };\n};\n
Tal y como podemos ver, se realiza una condici\u00f3n en el formato ya que, si el usuario selecciona que quiere ver todas las filas de golpe el valor de dicha variable ser\u00e1 0 y el back necesitar\u00e1 el valor del n\u00famero m\u00e1ximo de filas para que nosotros recibamos todas.
Y por \u00faltimo vamos a hacer que la funci\u00f3n de recibir los datos reciba por par\u00e1metro el paginado (siempre habr\u00e1 uno por defecto) y que cuando todo haya ido bien se actualice la paginaci\u00f3n local.
const updateLocalPagination = (props: any) => {\n pagination.value.page = props.pagination.page;\n pagination.value.rowsPerPage = props.pagination.rowsPerPage;\n};\n\nconst getAuthors = (props: any = { pagination: pagination.value }) => {\n const { data } = useFetch('http://localhost:8080/author', {\n method: 'POST',\n redirect: 'manual',\n headers: {\n accept: '*/*',\n origin: window.origin,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(formatPageableBody(props)),\n })\n .post()\n .json();\n whenever(data, () => {\n updateLocalPagination(props);\n authorsData.value = data.value.content;\n pagination.value.rowsNumber = data.value.totalElements;\n });\n};\n
Importante
En la primera de las peticiones (y si quieres en las dem\u00e1s tambi\u00e9n) se ha de recoger el atributo de filas totales y setearlo en el objeto de paginaci\u00f3n con el nombre de rowsNumber
. Esto se realiza en la zona subrayada anterior.
Y por \u00faltimo, hacemos que se realicen peticiones siempre que el usuario cambie par\u00e1metros de la tabla, como el cambio de p\u00e1gina o el cambio de filas mostradas. Esto se realiza a\u00f1adiendo a la creaci\u00f3n de la tabla la siguiente l\u00ednea:
<q-table\n :rows=\"authorsData\"\n :columns=\"columns\"\n v-model:pagination=\"pagination\"\n title=\"Autores\"\n class=\"my-sticky-header-table\"\n no-data-label=\"No hay resultados\"\n row-key=\"id\"\n @request=\"getAuthors\"\n >\n
Con estos cambios, la pantalla deber\u00eda funcionar correctamente con el paginado funcionando y todas sus funciones b\u00e1sicas.
"},{"location":"install/angular/","title":"Entorno de desarrollo - Angular","text":""},{"location":"install/angular/#instalacion-de-herramientas","title":"Instalaci\u00f3n de herramientas","text":"Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Lo primero de todo es instalar el IDE para el desarrollo front.
Te recomiendo utilizar Visual Studio Code, en un IDE que a nosotros nos gusta mucho y tiene muchos plugins configurables. Puedes entrar en su p\u00e1gina y descargarte la versi\u00f3n estable.
"},{"location":"install/angular/#nodejs","title":"Nodejs","text":"El siguiente paso ser\u00e1 instalar el motor de Nodejs. Entrando en la p\u00e1gina de descargas e instalando la \u00faltima versi\u00f3n estable. Con esta herramienta podremos compilar y ejecutar aplicaciones basadas en Javascript y Typescript, e instalar y gestionar las dependencias de las aplicaciones.
"},{"location":"install/angular/#angular-cli","title":"Angular CLI","text":"Por \u00faltimo vamos a instalar una capa de gesti\u00f3n por encima de Nodejs que nos ayudar\u00e1 en concreto con la funcionalida de Angular. En concreto instalaremos el CLI de Angular. Para poder instalarlo, tan solo hay que abrir una consola de msdos y ejecutar el comando y Nodejs ya har\u00e1 el resto:
npm install -g @angular/cli\n
Y con esto ya tendremos todo instalado, listo para empezar a crear los proyectos.
"},{"location":"install/angular/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":"La mayor\u00eda de los proyectos con Angular en los que trabajamos normalmente, suelen ser proyectos web usando las librer\u00edas mas comunes de angular, como Angular Material.
Crear un proyecto de Angular es muy sencillo si tienes instalado el CLI de Angular. Lo primero abrir una consola de msdos y posicionarte en el directorio raiz donde quieres crear tu proyecto Angular, y ejecutamos lo siguiente:
ng new tutorial --strict=false\n
El propio CLI nos ir\u00e1 realizando una serie de preguntas.
Would you like to add Angular routing? (y/N)
Preferiblemente: y
Which stylesheet format would you like to use?
Preferiblemente: SCSS
En el caso del tutorial como vamos a tener dos proyectos para nuestra aplicaci\u00f3n (front y back), para poder seguir correctamente las explicaciones, voy a renombrar la carpeta para poder diferenciarla del otro proyecto. A partir de ahora se llamar\u00e1 client
.
Info
Si durante el desarrollo del proyecto necesitas a\u00f1adir nuevos m\u00f3dulos al proyecto Angular, ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm update
y descargar\u00e1 e instalar\u00e1 las nuevas dependencias.
Para arrancar el proyecto, tan solo necesitamos ejecutar en consola el siguiente comando siempre dentro del directorio creado por Angular CLI:
ng serve\n
Angular compilar\u00e1 el c\u00f3digo fuente, levantar\u00e1 un servidor local al que podremos acceder por defecto mediante la URL: http://localhost:4200/
Y ya podemos empezar a trabajar con Angular.
Info
Cuando se trata de un proyecto nuevo recien descargado de un repositorio, recuerda que ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm update
y descargar\u00e1 e instalar\u00e1 las nuevas dependencias.
Comandos de Angular CLI
Si necesitas m\u00e1s informaci\u00f3n sobre los comandos que ofrece Angular CLI para poder crear aplicaciones, componentes, servicios, etc. los tienes disponibles en: https://angular.io/cli#command-overview
"},{"location":"install/nodejs/","title":"Entorno de desarrollo - Nodejs","text":""},{"location":"install/nodejs/#instalacion-de-herramientas","title":"Instalaci\u00f3n de herramientas","text":"Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Lo primero de todo es instalar el IDE para el desarrollo en node si no lo has hecho previamente.
Te recomiendo utilizar Visual Studio Code, en un IDE que a nosotros nos gusta mucho y tiene muchos plugins configurables. Puedes entrar en su p\u00e1gina y descargarte la versi\u00f3n estable.
"},{"location":"install/nodejs/#nodejs","title":"Nodejs","text":"El siguiente paso ser\u00e1 instalar el motor de Nodejs. Entrando en la p\u00e1gina de descargas e instalando la \u00faltima versi\u00f3n estable. Con esta herramienta podremos compilar y ejecutar aplicaciones basadas en Javascript y Typescript, e instalar y gestionar las dependencias de las aplicaciones.
"},{"location":"install/nodejs/#mongodb-atlas","title":"MongoDB Atlas","text":"Tambi\u00e9n necesitaremos crear una cuenta de MongoDB Atlas para crear nuestra base de datos MongoDB en la nube.
Accede a la URL, registrate gr\u00e1tis con cualquier cuenta de correo y elige el tipo de cuenta gratuita \ud83d\ude0a:
Configura el cluster a tu gusto (selecciona la opci\u00f3n gratuita en el cloud que m\u00e1s te guste) y ya tendr\u00edas una BBDD en cloud para hacer pruebas. Lo primero que se muestra es el dashboard que se ver\u00e1 algo similar a lo siguiente:
A continuaci\u00f3n, pulsamos en la opci\u00f3n Database
del men\u00fa y, sobre el Cluster0
, pulsamos tambi\u00e9n el bot\u00f3n Connect
. Se nos abrir\u00e1 el siguiente pop-up donde tendremos que elegir la opci\u00f3n Connect your application
:
En el siguiente paso es donde se nos muestra la url que tendremos que utilizar en nuestra aplicaci\u00f3n. La copiamos y guardamos para m\u00e1s tarde:
Pulsamos Close
y la BBDD ya estar\u00eda creada.
Nota: Al crear la base de datos te aprecer\u00e1 un aviso para introducir tu IP en la whitelist, aseg\u00farate no estar en la VPN cuando lo hagas, de lo contrario no tendr\u00e1s conexi\u00f3n posteriormente.
"},{"location":"install/nodejs/#herramientas-para-pruebas","title":"Herramientas para pruebas","text":"Para poder probar las operaciones de negocio que vamos a crear, lo mejor es utilizar una herramienta que permita realizar llamadas a API Rest. Para ello te propongo utilizar Postman, en su versi\u00f3n web o en su versi\u00f3n desktop, cualquiera de las dos sirve.
Con esta herramienta se puede generar peticiones GET, POST, PUT, DELETE contra el servidor y pasarle par\u00e1metros de forma muy sencilla y visual. Lo usaremos durante el tutorial.
"},{"location":"install/nodejs/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":"Para la creaci\u00f3n de nuestro proyecto Node nos crearemos una carpeta con el nombre que deseemos y accederemos a ella con la consola de comandos de windows. Una vez dentro ejecutaremos el siguiente comando para inicializar nuestro proyecto con npm:
npm init\n
Cuando ejecutemos este comando nos pedir\u00e1 los valores para distintos par\u00e1metros de nuestro proyecto. Aconsejo solo cambiar el nombre y el resto dejarlo por defecto pulsando enter para cada valor. Una vez que hayamos terminado se nos habr\u00e1 generado un fichero package.json
que contendr\u00e1 informaci\u00f3n b\u00e1sica de nuestro proyecto. Dentro de este fichero tendremos que a\u00f1adir un nuevo par\u00e1metro type
con el valor module
, esto nos permitir\u00e1 importar nuestros m\u00f3dulos con el est\u00e1ndar ES:
{\n\"name\": \"tutorialNode\",\n\"version\": \"1.0.0\",\n\"description\": \"\",\n\"main\": \"index.js\",\n\"scripts\": {\n\"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n},\n\"keywords\": [],\n\"author\": \"\",\n\"license\": \"ISC\",\n\"type\": \"module\"\n}\n
"},{"location":"install/nodejs/#instalar-dependencias","title":"Instalar dependencias","text":"En ese fichero aparte de la informaci\u00f3n de nuestro proyecto tambi\u00e9n tendremos que a\u00f1adir las dependencias que usara nuestra aplicaci\u00f3n.
Para a\u00f1adir las dependencias, desde la consola de comandos y situados en la misma carpeta donde se haya creado el fichero package.json
vamos a teclear los siguientes comandos:
npm i express\nnpm i express-validator\nnpm i dotenv\nnpm i mongoose\nnpm i mongoose-paginate-v2\nnpm i normalize-mongoose\nnpm i cors\nnpm i nodemon --save-dev\n
Tambi\u00e9n podr\u00edamos haber instalado todas a la vez en dos l\u00edneas:
npm i express express-validator dotenv mongoose mongoose-paginate-v2 normalize-mongoose cors\nnpm i nodemon --save-dev\n
Las dependencias que acabamos de instalar son las siguientes:
Ahora podemos fijarnos en nuestro fichero package.json
donde se habr\u00e1n a\u00f1adido dos nuevos par\u00e1metros: dependencies
y devDependencies
. La diferencia est\u00e1 en que las devDependencies
solo se utilizar en la fase de desarrollo de nuestro proyecto y las dependencies
se utilizar\u00e1n en todo momento.
A partir de aqu\u00ed ya podemos abrir Visual Studio Code, el IDE recomendado, y abrir la carpeta del proyecto para poder configurarlo y programarlo. Lo primero ser\u00e1 configurar el acceso con la BBDD.
Para ello vamos a crear en la ra\u00edz de nuestro proyecto una carpeta config
dentro de la cual crearemos un archivo llamado db.js
. Este archivo exportar\u00e1 una funci\u00f3n que recibe una url de nuestra BBDD y la conectar\u00e1 con mongoose. El contenido de este archivo debe ser el siguiente:
import mongoose from 'mongoose';\n\nconst connectDB = async (url) => {\n\ntry {\nawait mongoose.connect(url);\nconsole.log('BBDD connected');\n} catch (error) {\nthrow new Error('Error initiating BBDD:' + error);\n}\n}\n\nexport default connectDB;\n
Ahora vamos a crear en la ra\u00edz de nuestro proyecto un archivo con el nombre .env
. Este archivo tendr\u00e1 las variables de entorno de nuestro proyecto. Es aqu\u00ed donde pondremos la url que obtuvimos al crear nuestra BBDD. As\u00ed pues, crearemos una nueva variable y pegaremos la URL. Tambi\u00e9n vamos a configurar el puerto del servidor.
MONGODB_URL='mongodb+srv://<user>:<pass>@<url>.mongodb.net/?retryWrites=true&w=majority'\nPORT='8080'\n
"},{"location":"install/nodejs/#arrancar-el-proyecto","title":"Arrancar el proyecto","text":"Con toda esa configuraci\u00f3n, ahora ya podemos crear nuestra p\u00e1gina inicial. Dentro del fichero package.json
, en concreto en el contenido de main
vemos que nos indica el valor de index.js
. Este ser\u00e1 el punto de entrada a nuestra aplicaci\u00f3n, pero este fichero todav\u00eda no existe, as\u00ed que lo crearemos con el siguiente contenido:
import express from 'express';\nimport cors from 'cors';\nimport connectDB from './config/db.js';\nimport { config } from 'dotenv';\n\nconfig();\nconnectDB(process.env.MONGODB_URL);\nconst app = express();\n\napp.use(cors({\norigin: '*'\n}));\n\napp.listen(process.env.PORT, () => {\nconsole.log(`Server running on port ${process.env.PORT}`);\n});\n
El funcionamiento de este c\u00f3digo, resumiendo mucho, es el siguiente. Configurar la base de datos, configurar el CORS para que posteriormente podamos realizar peticiones desde el front y crea un servidor con express en el puerto 8080
.
Pero antes, para poder ejecutar nuestro servidor debemos modificar el fichero package.json
, y a\u00f1adir un script de arranque. A\u00f1adiremos la siguiente l\u00ednea:
\"dev\": \"nodemon ./index.js\"\n
Y ahora s\u00ed, desde la consola de comando ya podemos ejecutar el siguiente comando:
npm run dev\n
y ya podremos ver en la consola como la aplicaci\u00f3n ha arrancado correctamente con el mensaje que le hemos a\u00f1adido.
"},{"location":"install/react/","title":"Entorno de desarrollo - React","text":""},{"location":"install/react/#instalacion-de-herramientas","title":"Instalaci\u00f3n de herramientas","text":"Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Lo primero de todo es instalar el IDE para el desarrollo front.
Te recomiendo utilizar Visual Studio Code, en un IDE que a nosotros nos gusta mucho y tiene muchos plugins configurables. Puedes entrar en su p\u00e1gina y descargarte la versi\u00f3n estable.
"},{"location":"install/react/#nodejs","title":"Nodejs","text":"El siguiente paso ser\u00e1 instalar el motor de Nodejs. Entrando en la p\u00e1gina de descargas e instalando la \u00faltima versi\u00f3n estable. Con esta herramienta podremos compilar y ejecutar aplicaciones basadas en Javascript y Typescript, e instalar y gestionar las dependencias de las aplicaciones.
"},{"location":"install/react/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":"Hasta ahora para la generaci\u00f3n de un proyecto React se ha utilizado la herramienta \u201ccreate-react-app\u201d pero \u00faltimamente se usa m\u00e1s vite debido a su velocidad para desarrollar y su optimizaci\u00f3n en tiempos de construcci\u00f3n. En realidad, para realizar nuestro proyecto da igual una herramienta u otra m\u00e1s all\u00e1 de un poco de configuraci\u00f3n, pero para este proyecto elegiremos vite por su velocidad.
Para generar nuestro proyecto react con Vite abrimos una consola de Windows y escribimos lo siguiente en la carpeta donde queramos localizar nuestro proyecto:
npm create vite@latest\n
Con esto se nos lanzara un wizard para la creaci\u00f3n de nuestro proyecto donde elegiremos el nombre del proyecto (en mi caso ludoteca-react), el framework (react evidentemente) y en la variante elegiremos typescript. Tras estos pasos instalaremos las dependencias base de nuestro proyecto. Primero accedemos a la ra\u00edz y despu\u00e9s ejecutaremos el comando install de npm.
cd ludoteca-react\n
npm install\n
\u00f3
npm i\n
"},{"location":"install/react/#arrancar-el-proyecto","title":"Arrancar el proyecto","text":"Para arrancar el proyecto, tan solo necesitamos ejecutar en consola el siguiente comando siempre dentro del directorio creado por Vite:
npm run dev\n
Vite compilar\u00e1 el c\u00f3digo fuente, levantar\u00e1 un servidor local al que podremos acceder por defecto mediante la URL: http://localhost:5173/
Y ya podemos empezar a trabajar en nuestro proyecto React.
Info
Cuando se trata de un proyecto nuevo recien descargado de un repositorio, recuerda que ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm update
y descargar\u00e1 e instalar\u00e1 las nuevas dependencias.
Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Necesitamos instalar un IDE de desarrollo, en nuestro caso ser\u00e1 Eclipse IDE y la m\u00e1quina virtual de java necesaria para ejecutar el c\u00f3digo. Recomendamos Java 19, que es la versi\u00f3n con la que est\u00e1 desarrollado y probado el tutorial.
Para instalar el IDE deber\u00e1s acceder a la web de Eclipse IDE y descargarte la \u00faltima versi\u00f3n del instalador. Una vez lo ejecutes te pedir\u00e1 el tipo de instalaci\u00f3n que deseas instalar. Por lo general con la de \"Eclipse IDE for Java Developers\" es suficiente. Con esta versi\u00f3n ya tiene integrado los plugins de Maven y Git.
"},{"location":"install/springboot/#instalacion-de-java","title":"Instalaci\u00f3n de Java","text":"Una vez instalado eclipse, debes asegurarte que est\u00e1 usando por defecto la versi\u00f3n de Java 19 y para ello deber\u00e1s instalarla. Desc\u00e1rgala del siguiente enlace. Es posible que te pida un registro de correo, utiliza el email que quieras (corporativo o personal). Revisa bien el enlace para buscar y descargar la versi\u00f3n 19 para Windows:
Ya solo queda a\u00f1adir Java al Eclipse. Para ello, abre el men\u00fa Window -> Preferences
:
y dentro de la secci\u00f3n Java - Installed JREs
a\u00f1ade la versi\u00f3n que acabas de descargar, siempre pulsando el bot\u00f3n Add...
y buscando el directorio home
de la instalaci\u00f3n de Java. Adem\u00e1s, la debes marcar como default
.
Como complemento al Eclipse, con el fin de crear c\u00f3digo homog\u00e9neo y mantenible, vamos a configurar el formateador de c\u00f3digo autom\u00e1tico.
Para ello de nuevo abrimos el men\u00fa Window -> Preferences
, nos vamos a la secci\u00f3n Formatter
de Java:
Aqu\u00ed crearemos un nuevo perfil heredando la configuraci\u00f3n por defecto.
En el nuevo perfil configuramos que se use espacios en vez de tabuladores con sangrado de 4 caracteres.
Una vez cofigurado el nuevo formateador debemos activar que se aplique en el guardado. Para ello volvemos acceder a las preferencias de Eclipse y nos dirigimos a la sub secci\u00f3n Save Actions
del la secci\u00f3n Editor
nuevamente de Java.
Aqu\u00ed aplicamos la configuraci\u00f3n deseada.
"},{"location":"install/springboot/#herramientas-para-pruebas","title":"Herramientas para pruebas","text":"Para poder probar las operaciones de negocio que vamos a crear, lo mejor es utilizar una herramienta que permita realizar llamadas a API Rest. Para ello te propongo utilizar Postman, en su versi\u00f3n web o en su versi\u00f3n desktop, cualquiera de las dos sirve.
Con esta herramienta se puede generar peticiones GET, POST, PUT, DELETE contra el servidor y pasarle par\u00e1metros de forma muy sencilla y visual. Lo usaremos durante el tutorial.
"},{"location":"install/springboot/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":"La mayor\u00eda de los proyectos Spring Boot en los que trabajamos normalmente, suelen ser proyectos web sencillos con pocas dependencias de terceros o incluso proyectos basados en micro-servicios que ejecutan pocas acciones. Ahora tienes que preparar el proyecto SpringBoot,
"},{"location":"install/springboot/#crear-con-initilizr","title":"Crear con Initilizr","text":"Vamos a ver como configurar paso a paso un proyecto de cero, con las librer\u00edas que vamos a utilizar en el tutorial.
"},{"location":"install/springboot/#como-usarlo","title":"\u00bfComo usarlo?","text":"Spring ha creado una p\u00e1gina interactiva que permite crear y configurar proyectos en diferentes lenguajes, con diferentes versiones de Spring Boot y a\u00f1adi\u00e9ndole los m\u00f3dulos que nosotros queramos.
Esta p\u00e1gina est\u00e1 disponible desde Spring Initializr. Para seguir el ejemplo del tutorial, entraremos en la web y seleccionaremos los siguientes datos:
Esto nos generar\u00e1 un proyecto que ya vendr\u00e1 configurado con Spring Web, JPA y H2 para crear una BBDD en memoria de ejemplo con la que trabajaremos durante el tutorial.
"},{"location":"install/springboot/#importar-en-eclipse","title":"Importar en eclipse","text":"El siguiente paso, es descomprimir el proyecto generado e importarlo como proyecto Maven. Abrimos el eclipse, pulsamos en File \u2192 Import y seleccionamos Existing Maven Projects
. Buscamos el proyecto y le damos a importar.
Lo primero que vamos a hacer es a\u00f1adir las dependencias a algunas librer\u00edas que vamos a utilizar. Abriremos el fichero pom.xml
que nos ha generado el Spring Initilizr y a\u00f1adiremos las siguientes l\u00edneas:
<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project xmlns=\"http://maven.apache.org/POM/4.0.0\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\nxsi:schemaLocation=\"http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd\">\n<modelVersion>4.0.0</modelVersion>\n\n<parent>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-parent</artifactId>\n<version>3.0.4</version>\n<relativePath/> <!-- lookup parent from repository -->\n</parent>\n\n<groupId>com.ccsw</groupId>\n<artifactId>tutorial</artifactId>\n<version>0.0.1-SNAPSHOT</version>\n<name>tutorial</name>\n<description>Tutorial project for Spring Boot</description>\n\n<properties>\n<java.version>19</java.version>\n</properties>\n\n<dependencies>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-data-jpa</artifactId>\n</dependency>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-web</artifactId>\n</dependency>\n\n<dependency>\n<groupId>org.springdoc</groupId>\n<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>\n<version>2.0.3</version>\n</dependency>\n<dependency>\n<groupId>org.hibernate</groupId>\n<artifactId>hibernate-validator</artifactId>\n<version>8.0.0.Final</version>\n</dependency>\n<dependency>\n<groupId>org.modelmapper</groupId>\n<artifactId>modelmapper</artifactId>\n<version>3.1.1</version>\n</dependency>\n<dependency>\n<groupId>com.h2database</groupId>\n<artifactId>h2</artifactId>\n<scope>runtime</scope>\n</dependency>\n<dependency>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-starter-test</artifactId>\n<scope>test</scope>\n</dependency>\n</dependencies>\n\n<build>\n<plugins>\n<plugin>\n<groupId>org.springframework.boot</groupId>\n<artifactId>spring-boot-maven-plugin</artifactId>\n</plugin>\n</plugins>\n</build>\n\n</project>\n
Hemos a\u00f1adido las dependencias de que nos permite utilizar Open API para documentar nuestras APIs. Adem\u00e1s de esa dependencia, hemos a\u00f1adido una utilidad para hacer mapeos entre objetos y para configurar los servicios Rest. M\u00e1s adelante veremos como se utilizan.
"},{"location":"install/springboot/#configurar-librerias","title":"Configurar librer\u00edas","text":"El siguiente punto es crear las clases de configuraci\u00f3n para las librer\u00edas que hemos a\u00f1adido. Para ello vamos a crear un package de configuraci\u00f3n general de la aplicaci\u00f3n com.ccsw.tutorial.config
donde crearemos una clase que llamaremos ModelMapperConfig
y usaremos para configurar el bean de ModelMapper.
package com.ccsw.tutorial.config;\n\nimport org.modelmapper.ModelMapper;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\n/**\n * @author ccsw\n *\n */\n@Configuration\npublic class ModelMapperConfig {\n\n@Bean\npublic ModelMapper getModelMapper() {\n\nreturn new ModelMapper();\n}\n\n}\n
Esta configuraci\u00f3n nos permitir\u00e1 luego hacer transformaciones entre objetos de forma muy sencilla. Ya lo iremos viendo m\u00e1s adelante. Listo, ya podemos empezar a desarrollar nuestros servicios.
"},{"location":"install/springboot/#configurar-la-bbdd","title":"Configurar la BBDD","text":"Por \u00faltimo, vamos a dejar configurada la BBDD en memoria. Para ello crearemos un fichero, de momento en blanco, dentro de src/main/resources/
:
Este fichero no puede estar vac\u00edo, ya que si no dar\u00e1 un error al arrancar. Puedes a\u00f1adirle la siguiente query (que no hace nada) para que pueda arrancar el proyecto.
select 1 from dual;
Y ahora le vamos a decir a Spring Boot que la BBDD ser\u00e1 en memoria, que use un motor de H2 y que la cree autom\u00e1ticamente desde el modelo y que utilice el fichero data.sql
(por defecto) para cargar datos en esta. Para ello hay que configurar el fichero application.properties
que est\u00e1 dentro de src/main/resources/
:
#Database\nspring.datasource.url=jdbc:h2:mem:testdb\nspring.datasource.username=sa\nspring.datasource.password=sa\nspring.datasource.driver-class-name=org.h2.Driver\n\nspring.jpa.database-platform=org.hibernate.dialect.H2Dialect\nspring.jpa.defer-datasource-initialization=true\nspring.jpa.show-sql=true\n\nspring.h2.console.enabled=true\n
"},{"location":"install/springboot/#arrancar-el-proyecto","title":"Arrancar el proyecto","text":"Por \u00faltimo ya solo nos queda arrancar el proyecto creado. Para ello buscaremos la clase TutorialApplication.java
(o la clase principal del proyecto) y con el bot\u00f3n derecho seleccionaremos Run As \u2192 Java Application. La aplicaci\u00f3n al estar basada en Spring Boot arrancar\u00e1 internamente un Tomcat embebido donde se despliega el proyecto.
Si hab\u00e9is seguido el tutorial la aplicaci\u00f3n estar\u00e1 disponible en http://localhost:8080, aunque de momento a\u00fan no tenemos nada accesible y nos dar\u00e1 una p\u00e1gina de error Whitelabel Error Page
, error 404. Eso significa que el Tomcat embedido nos ha contestado pero no sabe que devolvernos porque no hemos implementado todav\u00eda nada.
Las herramientas b\u00e1sicas que vamos a utilizar para esta tecnolog\u00eda son:
Lo primero de todo es instalar el IDE para el desarrollo front.
Te recomiendo utilizar Visual Studio Code, en un IDE que a nosotros nos gusta mucho y tiene muchos plugins configurables. Puedes entrar en su p\u00e1gina y descargarte la versi\u00f3n estable.
"},{"location":"install/vuejs/#nodejs","title":"Nodejs","text":"El siguiente paso ser\u00e1 instalar el motor de Nodejs. Entrando en la p\u00e1gina de descargas e instalando la \u00faltima versi\u00f3n estable. Con esta herramienta podremos compilar y ejecutar aplicaciones basadas en Javascript y Typescript, e instalar y gestionar las dependencias de las aplicaciones.
"},{"location":"install/vuejs/#creacion-de-proyecto","title":"Creaci\u00f3n de proyecto","text":""},{"location":"install/vuejs/#generar-scaffolding","title":"Generar scaffolding","text":"Lo primero que haremos ser\u00e1 generar un proyecto mediante la librer\u00eda \"Quasar CLI\" para ello ejecutamos en consola el siguiente comando:
npm init quasar\n
Este comando detectar\u00e1 si tienes el CLI de Quasar instalado y en caso contrario te preguntar\u00e1 si deseas instalarlo. Debes responder que s\u00ed, que lo instale.
Una vez instalado, aparecer\u00e1 un wizzard en el que se ir\u00e1n preguntando una serie de datos para crear la aplicaci\u00f3n:
Y tendremos que elegir lo siguiente:
What would you like to build?
App with Quasar CLI, let's go!
Project folder
tutorial-vue
Pick Quasar version
Quasar v2 (Vue 3 | latest and greatest)
Pick script type
Typescript
Pick Quasar App CLI variant
Quasar App CLI with Vite
Package name
tutorial-vue
Project product name
Ludoceta Tan
Project description
Proyecto tutorial Ludoteca Tan
Author
<por defecto el email>
Pick a Vue component style
Composition API
Pick your CSS preprocessor
Sass with SCSS syntax
Check the features needed for your project
ESLint
Pick an ESLint preset
Prettier
Install project dependencies?
Yes, use npm
Cuando todo ha terminado el propio scaffolding te dice lo que tienes que hacer para poner el proyecto en marcha y ver lo que te ha generado, solo tienes que seguir esos pasos.
Accedes al directorio que acabas de crear y ejecutas
npx quasar dev\n
Esto arrancar\u00e1 el servidor y abrir\u00e1 un navegador en el puerto 9000 donde se mostrar\u00e1 la template creada.
Tambi\u00e9n podemos navegar nosotros mismos a la URL http://localhost:9000/
.
Info
Si durante el desarrollo del proyecto necesitas a\u00f1adir nuevos m\u00f3dulos al proyecto Vue.js, ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm install y descargar\u00e1 e instalar\u00e1 las nuevas dependencias..
Proyecto descargado
Cuando se trata de un proyecto nuevo recien descargado de un repositorio, recuerda que ser\u00e1 necesario resolver las dependencias antes de arrancar el servidor. Esto se puede realizar mediante el gestor de dependencias de Nodejs, directamente en consola ejecuta el comando npm install y descargar\u00e1 e instalar\u00e1 las nuevas dependencias.
"}]} \ No newline at end of file diff --git a/site/sitemap.xml.gz b/site/sitemap.xml.gz index e39d1e2..ff639fb 100644 Binary files a/site/sitemap.xml.gz and b/site/sitemap.xml.gz differ