Visualización de Héroes con Bootstrap-Vue en Diablo III

Clase 18 de 27Curso Avanzado de Vue.js 2

Vamos a continuar con el resto de componentes de nuestra página de perfil de jugador.
En esta ocasión, tenemos que mostrar el resto de héroes que posee un jugador, si es que tiene alguno más. Para ello, haremos uso de las tablas de bootstrap-vue, que son muy fáciles de usar y versátiles.

> Recuerda, si no eres jugador de Diablo III y/o no tienes perfil propio, puedes usar este battle-tag SuperRambo#2613
> Además, si estás en localhost, puedes usar esta URL: http://localhost:8080/#/region/eu/profile/SuperRambo-2613

Como vamos a crear otro bloque de componentes, tenemos que crear los directorios correspondientes. Lo que vamos a mostrar es un listado de héroes en una tabla, por lo tanto, suena razonable llamar a la carpeta de nuestro componente HeroesList. Creamos esta carpeta al mismo nivel que la de TopHeroes, es decir, dentro de MainBlock. Dentro de esta, vamos a crear tres componentes: Index.vue (el componente principal, como ya es habitual), HeroIco.vue y HeroClassLevel.vue.

📂 MainBlock/ └──📂 HeroesList/ ├── Index.vue ├── HeroIco.vue └── HeroClassLevel.vue

Al componente Index le vamos a dar este contenido, para poder usar luego:

<template> <div> <h1>Heroes List</h1> </div> </template> <script> export default { name: 'HeroesList' } </script>

Lo primero que vamos a hacer es ir a MainBlock (es decir, /MainBlock/Index.vue) para traer y usar el componente de HeroesList.

// MainBlock/Index.vue import HeroesList from './HeroesList/Index'
components: { HeroesList }

En el componente TopHeroes hemos usado los tres primeros héroes del array de héroes que nos devolvía la API. En este caso vamos a usar el resto de héroes que no se hayan usado en TopHeroes.
Antes de poder usarlo, tenemos que comprobar que el array de héroes tiene más de tres elementos, por lo tanto vamos a crear dos computed que nos ayuden con estas tareas:

// ¿Hay más de tres elementos en el array? hasHeroesList () { return this.profileData.heroes.length > 3 }

En caso afirmativo, dame todos los elementos del array sin contar los tres primeros. Seguimos con la otra propiedad computada:

heroesList () { return this.profileData.heroes.slice(3, this.profileData.heroes.length) }

Ahora sí, está listo para usarse en el HTML del componente MainBlock.

<HeroesList v-if="hasHeroesList" :heroes="heroesList"/>

Sin contar la parte del CSS (que no ha cambiado), el componente MainBlock/Index.vue tiene este contenido:

<template> <div class="grid-container"> <div class="grid-item item-left"> <TopHeroes v-if="hasHeroes" :heroes="topHeroes"/> <HeroesList v-if="hasHeroesList" :heroes="heroesList"/> </div> <div class="grid-item item-right"> <h1>Derecha</h1> </div> </div> </template> <script> import TopHeroes from './TopHeroes/Index' import HeroesList from './HeroesList/Index' export default { name: 'MainBlock', components: { TopHeroes, HeroesList }, props: { profileData: { type: Object, required: true } }, computed: { hasHeroes () { return this.profileData.heroes.length > 0 }, topHeroes () { return this.profileData.heroes.slice(0, 3) }, hasHeroesList () { return this.profileData.heroes.length > 3 }, heroesList () { return this.profileData.heroes.slice(3, this.profileData.heroes.length) } } } </script>

Y se ve de esta forma:

preview-1Ahora que tenemos inyectados en el hijo los datos de los héroes que faltan por pintar, podemos empezar a trabajar con ellos en el componente HeroesList/Index.vue.

Lo primero que vamos a hacer es definir las props, que se las estamos pasando desde el padre pero el componente hijo no las tiene definidas:

props: { heroes: { required: true, type: Array } }

Para pintar la tabla en pantalla lo que tenemos que hacer es usar el componente tabla y pasarle como prop el array de héroes.
Como tenemos el fondo oscuro, el texto negro de la tabla casi no se vería. Le agregamos la prop dark:

<b-table dark :items="heroes"/>

preview-2

> 📗 La documentación de las tablas de bootstrap-vue, muy bien explicadas: https://bootstrap-vue.js.org/docs/components/table

Nosotros no queremos mostrar todos los elementos del array de héroes, por lo tanto vamos a definir que campos (columnas) queremos mostrar en nuestra tabla de héroes.
Para ello, siguiendo la documentación, creamos un objeto fields con las opciones y las columnas que queremos mostrar de nuestra tabla. Lo hacemos de la siguiente forma:

data () { return { fields: [ { key: 'name', label: 'Name', }, { key: 'class', label: 'Class', sortable: true }, { key: 'kills', label: 'Elite Kills', sortable: true } ] } }

Ya tenemos las columnas y las opciones que nos interesan listas para ser usadas. Actualicemos nuestra tabla agregando una nueva prop fields que recibirá como valor el objeto con los campos que acabamos de definir:

<b-table :items="heroes" :fields="fields" dark/>

Ahora nuestra tabla se ve mejor. Aún seguimos teniendo acceso a todos los demás datos, simplemente no los estamos mostrando.

preview-3

El componente tabla de bootstrap-vue soporta muchas opciones. Vamos a personalizarlo un poco más con esta configuración:

<b-table :items="heroes" :fields="fields" dark hover small striped stacked="sm"/>

Si seguimos lo que dice la documentación de las tablas de bootstrap-vue, podemos entender qué hace cada propiedad.
Aparte de los items que le hemos pasado y la definición de las columnas que queremos, le estamos dando un tema oscuro, con efecto "on mouse hover" en cada fila, tamaño pequeño, diferenciado por colores, y, por último, una alternativa responsive para cuando la tabla tenga un tamaño pequeño.

OJO: Las propiedades de tipo Boolean de un componente Vue, cuando estas pasando el valor true, las puedes usar de 2 formas:

  • Forma larga:
<b-table :items="heroes" :fields="fields" :dark="true" :hover="true" :small="true" :striped="true" stacked="sm"/>
  • Forma corta
<b-table :items="heroes" :fields="fields" dark hover small striped stacked="sm"/>

Es decir, si queremos que la tabla tenga el tema oscuro, según la definición de su propiedad dark, podemos usar <b-table dark/> o <b-table :dark="true"/>.
Estos nos permite tener, por ejemplo, una computed property que controle el estado de la propiedad dark. En ese supuesto caso podríamos alternar entre el tema oscuro y el tema claro de la tabla así: <b-table :dark="tableTheme"/>.

> 📗 Puedes ver más acerca de las props y los Boolean en este enlace: https://es.vuejs.org/v2/guide/components-props.html#Pasando-un-booleano

Ahora tenemos que darle formato a las filas de la tabla, por ejemplo, en base a la class que tenga el heroe. Es decir, en base al tipo de personaje que sea, podemos mostrar su ícono o rostro correspondiente. Lo mismo con el resto de valores; si es leyenda, si es hardcore, etc.

Para personalizar el contenido de la tabla vamos a hacer uso de los Scoped Slots de Vue junto con la funcionalidad de las tablas de Bootstrap-vue:

Los Scoped Slots nos brindan un mayor control sobre cómo aparecen los datos de la tabla. Podemos usar Scoped Slots para proporcionar una vista personalizada para un campo (columna) en particular.

Para poder continuar con los slots, necesitamos darle contenido a los componentes que hemos creado anteriormente (HeroIco y HeroClassLevel).

Estaría bien mostrar la imagen correspondiente al héroe, que representa su clase y género. Además del nombre, el nivel y la clase, deberíamos indicar de alguna forma si es de temporada y si es hardcore. Por último, deberíamos mostrar el nº de kills que tiene actualmente ese personaje.

En el componente HeroIco vamos a encargarnos de mostrar: la imagen del héroe, el nombre, si es de temporada o no (🍃) y si es hardcore.

  • HeroIco.vue:
<template> <div class="hero-ico d-flex align-items-center"> <span class="hero-image border" :class="heroClassImg"/> <span class="hero-name ml-2 font-weight-bold" :class="{'text-danger': hero.hardcore}"> {{ hero.name }} </span> <img v-if="hero.seasonal" src="@/assets/img/leaf.png" width="12px" class="ml-2" alt="seasonal_leaf"> </div> </template>

Este HTML es bastante sencillo. Reusamos clases CSS para mostrar el rostro de nuestro héroe a través de una clase según el tipo y el género. Si el hero es hardcore, le ponemos el border de la imagen rojo al igual que el texto del nombre. Si es de temporada, mostramos la ya reconocida hojita verde.

La lógica del componente es la siguiente:

<script> export default { name: 'HeroIco', props: { hero: { required: true, type: Object } }, computed: { heroClassImg () { const gender = this.hero.gender === 1 ? 'female' : 'male' const hardcore = this.hero.hardcore ? 'border-danger' : '' return `hero-${this.hero.classSlug} ${gender} ${hardcore}` } } } </script>

Definimos las propiedades que va a recibir el componente y con una computed generamos la clase CSS que va a tener: según el género, si es hardcore y el tipo (o clase) de héroe.
Recuerda que esto lo tenemos definido en el CSS global, que lo usamos con los Sprites de CSS.

Ya por último, un par de líneas de CSS para personalizar un poco más el diseño de nuestro componente:

<style lang="stylus"> .hero-ico vertical-align middle .hero-image width 30px height 26px display inline-block background-size 210% .hero-name height 24px display inline-block </style>
  • HeroClassLevel.vue:

En este componente vamos a incorporar algo que no hemos usado hasta el momento, los Mixins.
Los mixins son una forma flexible de crear funcionalidades reutilizables. Un objeto mixin puede contener cualquier opción de componente.

> 📗 Puedes leer más acerca de los Mixins de Vue en este enlace: https://es.vuejs.org/v2/guide/mixins.html

Al igual que a los componentes, a los mixins los podemos crear de manera global o de manera local. Con un ejemplo lo entenderás mejor.

Crea el archivo heroName.js dentro de /src/mixins y dale este contenido:

import classes from '../utils/heroClasses' export default { methods: { classToName (classSlug) { return classes[classSlug] } } }

Lo único que estamos haciendo en este mixin es exponer un method. Cuando importemos el mixin en un componente, en este componente tendremos acceso a este método classToName, como si de un método normal se tratara.

Además, estamos haciendo uso de otro archivo (heroClasses), que tenemos que crear ahora mismo dentro de /src/utils, con el nombre de heroClasses.js y que tiene un contenido muy simple:

const classes = { barbarian: 'Barbarian', crusader: 'Crusader', 'demon-hunter': 'Demon Hunter', monk: 'Monk', necromancer: 'Necromancer', 'witch-doctor': 'Witch Doctor', wizard: 'Wizard' } export default classes

De esta forma, si necesitamos pintar el tipo de una clase de personaje, podemos usar esto.
Para usar esta funcionalidad, tenemos que importar y dar de alta el mixin de manera local en nuestro componente. Una vez hayamos hecho esto, podremos acceder al método que acabamos de crear (classToName) como si fuese un method interno del componente.

Para hacer esto en nuestro componente HeroClassLevel.vue, en el bloque de javascript, hacemos lo siguiente:

<script> // Traemos el mixin import heroName from '@/mixins/heroName.js' export default { name: 'HeroNameLevel', // Lo damos de alta mixins: [heroName], props: { hero: { required: true, type: Object } } } </script>

Ahora tenemos acceso al método desde cualquier parte del componente, como si fuera un method normal:

  • En el HTML, classToName(val)
  • Desde JavaScript, this.classToName(val)

Ahora sí, podemos crear el HTML de nuestro componente HeroClassLevel.vue:

<template> <div class="hero-name-level"> <span> {{ classToName(hero.classSlug) }} </span> <span>·</span> <span class="text-monospace font-weight-bold"> {{ hero.level }} </span> </div> </template>

Haciendo uso del mixin, estamos mostrando el tipo de héroe normalizado. Es decir, si el tipo es demon-hunter, lo que vamos a renderizar en la vista es Demon Hunter. Además estamos mostrando el nivel. Es un componente muy básico.

Este componente no necesita clases CSS, por lo que ya estaría completo.

Hemos creado un mixin de Vue que hace uso de otros archivos y lo hemos usado en nuestro componente.


El código completo de lo que hemos hecho hasta el momento se vería así:

  • HeroIco.vue
<template> <div class="hero-ico d-flex align-items-center"> <span class="hero-image border" :class="heroClassImg"/> <span class="hero-name ml-2 font-weight-bold" :class="{'text-danger': hero.hardcore}"> {{ hero.name }} </span> <img v-if="hero.seasonal" src="@/assets/img/leaf.png" width="12px" class="ml-2" alt="seasonal_leaf"> </div> </template> <script> export default { name: 'HeroIco', props: { hero: { required: true, type: Object } }, computed: { heroClassImg () { const gender = this.hero.gender === 1 ? 'female' : 'male' const hardcore = this.hero.hardcore ? 'border-danger' : '' return `hero-${this.hero.classSlug} ${gender} ${hardcore}` } } } </script> <style lang="stylus"> .hero-ico vertical-align middle .hero-image width 30px height 26px display inline-block background-size 210% .hero-name height 24px display inline-block </style>
  • HeroClassLevel.vue
<template> <div class="hero-name-level"> <span> {{ classToName(hero.classSlug) }} </span> <span>·</span> <span class="text-monospace font-weight-bold"> {{ hero.level }} </span> </div> </template> <script> import heroName from '@/mixins/heroName.js' export default { name: 'HeroNameLevel', mixins: [heroName], props: { hero: { required: true, type: Object } } } </script>

Ahora que ya tenemos nuestros componentes, vamos a modificar la tabla que hemos creado anteriormente, y aprovecharemos para darle estilos CSS que mejoren el aspecto de nuestro componente.

<div class="heroes-list border-top border-secondary mt-5 pt-5"> <b-table hover striped dark :items="heroes" :fields="fields" stacked="sm" small > <!-- Contenido --> </b-table>

Ahora, haciendo uso de los slots de Vue y del componente tabla, vamos a personalizar el contenido de nuestras celdas.
En la primera columna vamos a insertar el componente HeroIco.vue. Lo haríamos de la siguiente forma, dentro de la tabla:

<template v-slot:cell(name)="data"> <HeroIco :hero="data.item"/> </template>

> 📗 En este artículo explican brevemente los slots: https://vuedose.tips/tips/new-v-slot-directive-in-vue-js-2-6-0/

En este fragmento de código estamos indicando que la columna name (nombre del campo que hemos definido en la variable fields) de la tabla muestre el componente HeroIco en vez de el texto por defecto.

> preview-4📗 Te recomiendo que leas esto, pues explican bastante bien el tema de los slots: https://github.com/vuejs/rfcs/blob/master/active-rfcs/0001-new-slot-syntax.md

Tendríamos que hacer lo mismo para la columna class (la segunda), pero cambiando de componente. En este caso HeroClassLevel:

<template v-slot:cell(class)="data"> <HeroClassLevel :hero="{ class: data.item.class, level: data.item.level}"/> </template>

A estas alturas, seguramente te hayas dado cuenta de que esto no puede funcionar si no has registrado el componente.
Por lo tanto, siguiendo en la tabla del componente /HeroesList/Index.vue, deberíamos hacer lo siguiente:

<script> import HeroIco from './HeroIco' import HeroClassLevel from './HeroClassLevel' export default { name: 'HeroesList', components: { HeroIco, HeroClassLevel } // ... } </script>

A falta de la última columna de la tabla, nuestra app debería verse así:

preview-5Lo bueno de hacer componentes es que nuestra app es más modular, y, por lo tanto, fácil de testear.
Sin embargo, podemos hacerlo sin usar un componente. Para ver cómo se haría, en la tercera columna de nuestra tabla no vamos a usar un componente para mostrar los datos, aunque no sea lo recomendado:

<template v-slot:cell(kills)="data"> <span>{{ data.item.kills.elites }}</span> </template>

preview-6¡Estupendo! Ya estaría nuestra tabla lista.

La columna kills de la tabla tiene (o puede tener) unos valores numéricos bastante grandes. Parece una buena idea formatear el valor de dicho campo haciendo uso de los puntos (.) y/o de las comas (,) para facilitar su lectura.

Para realizar esta tarea, en vez de desarrollar una función que haga el trabajo, vamos a usar una librería JavaScript llamada Numeral.js. Numeral es, según dicen en la web, una librería para formatear y manipular números. Justo lo que necesitamos.

Vamos a crear un filtro de Vue, es decir, una función JavaScript que cuando le pasemos un número nos lo devuelva formateado.

> 📗 La documentación de los filtros de Vue: https://es.vuejs.org/v2/guide/filters.html

Para eso, en nuestra carpeta /filters creamos un archivo llamado numeral.js. Los pasos a seguir son 3:

  • Importar la librería
  • Crear la función que será nuestro filtro Vue
  • Exportar la función para que pueda ser usada
// Paso 1 import numeral from 'numeral' // Paso 2 // Función que recibe un argumento (Número o String numérico) y lo devuelve formateado // Si no hay numero, devolvemos 0 const formatNumber = (num) => { if (!num) { return 0 } return numeral(Number(num)).format() } // Paso 3 export { formatNumber }

Esto que acabamos de hacer de manera tan sencilla, es un filtro. Para usarlo, es lo mismo que con los mixins o con los componentes.
Puedes usarlo a nivel global de la app o usarlo a nivel local en el componente que lo necesites. En este caso lo vamos a usar de manera local.

Para ello, en el componente donde tenemos la tabla que estamos usando, es decir, en /Profile/MainBlock/HeroesList/Index.vue, agregamos lo siguiente:

  • Lo primero, traer la función que acabamos de crear
import { formatNumber } from '@/filters/numeral'
  • Lo segundo, dar de alta esta función (formatNumber) en el componente, para que pueda ser usado desde el template. Como se trata de un filtro, lo haremos desde el bloque de filters:
export default { name: 'HeroesList', filters: { formatNumber } // ... }

Ahora solo queda usarlo. Usar un filtro es muy sencillo:

<template v-slot:cell(kills)="data"> <span>{{ data.item.kills.elites | formatNumber }}</span> </template>

Ahora la tercera columna de nuestra tabla se vería así: preview-7

Haciendo pruebas con usuarios de otras regiones, encontré uno de la region de Korea (KR) con personajes hardcore de temporada: 오빠-3239. Este usuario (http://localhost:8080/#/region/kr/profile/오빠-3239) se vería así:

preview-8

Como puedes ver en esta imagen, los tres Top Heroes tienen los elite kills sin formatear. Ahora que tenemos el filtro creado, ¿por qué no lo usamos para formatear este valor?

Abrimos el fichero /views/Profile/MainBlock/TopHeroes/TopHero.vue, importamos el filtro, lo habilitamos para poder usarlo en el template y lo usamos para formatear:

// /TopHeroes/TopHero.vue import { formatNumber } from '@/filters/numeral'
// Creamos el bloque filters filters: { formatNumber }
<small class="elite-kills"> <span class="text-monospace">{{ hero.kills.elites | formatNumber }}</span> Elite kills </small>

Y ahora el componente de TopHeroes se vería así:

preview-9A partir de ahora, cada vez que tengamos que formatear un valor numérico, podemos usar este filtro que hemos creado. Y si nos hace falta formatear otro valor con otro formato, siempre podemos crear otro.


Este es el código completo de los componentes que hemos usado en este tema:

  • /HeroesList/Index.vue
<template> <div class="heroes-list border-top border-secondary mt-5 pt-5"> <b-table hover striped dark :items="heroes" :fields="fields" stacked="sm" small > <!-- Contenido --> <template v-slot:cell(name)="data"> <HeroIco :hero="data.item"/> </template> <template v-slot:cell(class)="data"> <HeroClassLevel :hero="{ class: data.item.classSlug, level: data.item.level}"/> </template> <template v-slot:cell(kills)="data"> <span>{{ data.item.kills.elites | formatNumber }}</span> </template> </b-table> </div> </template> <script> import { formatNumber } from '@/filters/numeral' import HeroIco from './HeroIco' import HeroClassLevel from './HeroClassLevel' export default { name: 'HeroesList', filters: { formatNumber }, components: { HeroIco, HeroClassLevel }, props: { heroes: { required: true, type: Array } }, data () { return { fields: [ { key: 'name', label: 'Name' }, { key: 'class', label: 'Class', sortable: true }, { key: 'kills', label: 'Elite Kills', sortable: true } ] } } } </script>
  • /HeroesList/HeroIco.vue
<template> <div class="hero-ico d-flex align-items-center"> <span class="hero-image border" :class="heroClassImg"/> <span class="hero-name ml-2 font-weight-bold" :class="{'text-danger': hero.hardcore}"> {{ hero.name }} </span> <img v-if="hero.seasonal" src="@/assets/img/leaf.png" width="12px" class="ml-2" alt="seasonal_leaf"> </div> </template> <script> export default { name: 'HeroIco', props: { hero: { required: true, type: Object } }, computed: { heroClassImg () { const gender = this.hero.gender === 1 ? 'female' : 'male' const hardcore = this.hero.hardcore ? 'border-danger' : '' return `hero-${this.hero.classSlug} ${gender} ${hardcore}` } } } </script> <style lang="stylus"> .hero-ico vertical-align middle .hero-image width 30px height 26px display inline-block background-size 210% .hero-name height 24px display inline-block </style>
  • /HeroesList/HeroClassLevel.vue
<template> <div class="hero-name-level"> <span> {{ classToName(hero.class) }} </span> <span>·</span> <span class="text-monospace font-weight-bold"> {{ hero.level }} </span> </div> </template> <script> import heroName from '@/mixins/heroName.js' export default { name: 'HeroNameLevel', mixins: [heroName], props: { hero: { required: true, type: Object } } } </script>
  • /mixins/heroName.js
import classes from '../utils/heroClasses' export default { methods: { classToName (classSlug) { return classes[classSlug] } } }
  • /utils/heroClasses.js
const classes = { barbarian: 'Barbarian', crusader: 'Crusader', 'demon-hunter': 'Demon Hunter', monk: 'Monk', necromancer: 'Necromancer', 'witch-doctor': 'Witch Doctor', wizard: 'Wizard' } export default classes

El componente que veremos en la siguiente lectura mostrará el progreso del usuario en la historia (o campaña) del juego.