Implementación de Componentes Vue: MainBlock y PlayerStats

Clase 20 de 27Curso Avanzado de Vue.js 2

En este apartado vamos a terminar el componente de MainBlock. Vamos a crear, primero, la estructura de carpetas.

Dentro de /MainBlock creamos una nueva carpeta /PlayerStats. Dentro vamos a crear Index.vue, SingleStat.vue, TimePlayed.vue y TimePlayedHero.vue:

📂 /MainBlock └──📂 /PlayerStats ├── Index.vue ├── SingleStat.vue ├── TimePlayed.vue └── TimePlayedHero.vue

Ahora, en el componente MainBlock, en el bloque de la derecha, quitamos el texto que teníamos puesto y ponemos el componente que acabamos de crear. El HTML de /MainBlock/Index.vue se vería así:

<template> <div class="grid-container"> <div class="grid-item item-left"> <TopHeroes v-if="hasHeroes" :heroes="topHeroes"/> <HeroesList v-if="hasHeroesList" :heroes="heroesList"/> <ProgressList :acts="profileData.progression"/> </div> <!-- Right Bar--> <div class="grid-item item-right"> <PlayerStats :stats="statsData"/> </div> </div> </template>

La prop que le estamos pasando a PlayerStats con el nombre de stats, es una computed property. statsData tiene el siguiente contenido:

statsData () { const { paragonLevel, kills, timePlayed } = this.profileData return { paragonLevel, kills, timePlayed } }

Lo que devuelve statsData son los datos que necesita nuestro componente de stats:

  • Paragon level: nivel de leyenda.
  • Kills: monstruos y élites.
  • Time played: Tiempo jugado por héroe en porcentaje.
    • Esto quiere decir que si has jugado 10 minutos con el monje y 5 minutos con el cruzado vas a tener el monje al 100% y el cruzado al 50%. No está basado en horas o minutos.

El código completo del componente MainBlock (sin contar el CSS, que no ha cambiado) es este:

<template> <div class="grid-container"> <div class="grid-item item-left"> <TopHeroes v-if="hasHeroes" :heroes="topHeroes"/> <HeroesList v-if="hasHeroesList" :heroes="heroesList"/> <ProgressList :acts="profileData.progression"/> </div> <!-- Right Bar--> <div class="grid-item item-right"> <PlayerStats :stats="statsData"/> </div> </div> </template> <script> import TopHeroes from './TopHeroes/Index' import HeroesList from './HeroesList/Index' import ProgressList from './ProgressList/Index' import PlayerStats from './PlayerStats/Index' export default { name: 'MainBlock', components: { PlayerStats, ProgressList, HeroesList, TopHeroes }, props: { profileData: { type: Object, required: true } }, computed: { hasHeroes () { return this.profileData.heroes.length > 0 }, hasHeroesList () { return this.profileData.heroes.length > 3 }, topHeroes () { return this.profileData.heroes.slice(0, 3) }, heroesList () { return this.profileData.heroes.slice(3, this.profileData.heroes.length) }, statsData () { const { paragonLevel, kills, timePlayed } = this.profileData return { paragonLevel, kills, timePlayed } } } } </script>

El HTML del componente PlayerStats es el siguiente:

<template> <div class="multi-stats pl-lg-4"> <hr class="bg-white mt-5 d-lg-none"> <h2 class="font-diablo my-4">Stats</h2> <div class="stats d-flex flex-column bg-dark p-3"> <SingleStat class="mb-3" ico-name="skull" ico-color="#9E9E9E" :info="{value: stats.kills.monsters, text:'Lifetime Kills'}" /> <SingleStat class="mb-3" ico-name="crown" ico-color="#ffc107" :info="{value: stats.kills.elites, text:'Elite Kills'}"/> <SingleStat ico-name="dungeon" ico-color="#795548" :info="{value: stats.paragonLevel, text:'Paragon Level'}"/> </div> <TimePlayed :timePlayed="timePlayed"/> </div> </template>

PlayerStats hace uso de dos componentes distintos.

Por un lado tenemos el componente SingleStat.vue. Lo único distinto aquí es la propiedad ico-name, que se refiere al nombre del ícono de FontAwesome.

> 📗 Ver documentación de Vue FontAwesome: https://github.com/FortAwesome/vue-fontawesome#usage

Por otro lado, el componente TimePlayed.vue itera sobre el objeto que contiene los tiempos por cada personaje, y, en su interior, hace uso del componente de barra de progreso de bootstrap-vue (https://bootstrap-vue.js.org/docs/components/progress).

El bloque <script></script> del componente PlayerStats/Index.vue es el siguiente:

import heroName from '@/mixins/heroName' import SingleStat from './SingleStat' import TimePlayed from './TimePlayed' export default { name: 'PlayerStats', mixins: [heroName], components: { TimePlayed, SingleStat }, props: { stats: { required: true, type: Object } } }

De momento, no hay nada nuevo que explicar. Tenemos un mixin y dos componentes.
Nos vamos a centrar en el segundo componente, TimePlayed.vue.

Antes de revisar el componente TimePlayed, vamos a copiar el contenido del componente SingleStat.vue para pegarlo en nuestro fichero:

<!-- SingleStat.vue --> <template> <div class="single-stat w-100"> <b-card class="text-body"> <div class="d-flex"> <div class="d-block ico-cont"> <div class="text-center"> <!-- Ícono & Color--> <font-awesome-icon :icon="icoName" class="fa-3x" :style="{color: icoColor}"/> </div> </div> <div class="flex-grow-1"> <!-- Valor & Filtro --> <h4 class="font-weight-bold mb-0">{{ info.value | formatNumber }}</h4> <!-- Texto --> <span class="text-muted font-weight-light mb-0">{{ info.text }}</span> </div> </div> </b-card> </div> </template> <script> import { formatNumber } from '@/filters/numeral' export default { name: 'SingleStat', filters: { formatNumber }, props: { icoName: { required: true, type: String }, icoColor: { required: true, type: String }, info: { required: true, type: Object } } } </script> <style lang="stylus" scoped> .single-stat .ico-cont width 80px </style>

Como has podido ver, este componente no tiene ninguna complicación. Simplemente pinta los datos que le pasamos: el ícono, color del ícono y valor numérico (formateado con el filtro de numeral). Deberías ver algo así:

preview-1.png

Acuérdate, a los íconos los estamos pintando gracias a FontAwesome.

TimePlayed.vue tiene una configuración especial que vamos a explicar ahora. Cuando estamos definiendo las props de un componente, podemos validar el valor que recibe la propiedad a través de una función y definir el tipo de variable que espera dicha propiedad. Lo hemos hecho en algún caso. Ejemplo:

props: { type: { required: false, type: String, default: 'border', validator: (value) => { return ['border', 'grow'].indexOf(value) !== -1 } } }

¿Qué pasa si yo quiero validar un tipo (type) de objeto específico?
Algo que no sea, por ejemplo, un String o un Object. Vue nos permite crear nuestros propios tipos de dato y poder usarlos como type.

Custom Types

Con un ejemplo lo verás más claro. Imagina que queremos crear un tipo que sea Persona. Una Persona tiene nombre y el apellido. Podríamos crear algo como esto:

function Persona (nombre, apellido) { this.nombre = nombre this.apellido = apellido }

Y luego, dentro de nuestro componente lo podríamos usar así:

props: { author: { type: Persona, // Tipo que acabamos de crear required: true } }

> 📗 Aquí explican, un poco más, como funciona esto de los custom types: https://es.vuejs.org/v2/guide/components-props.html#Tipos-de-la-validacion

Esto es lo que vamos a hacer en nuestro componente TimePlayed: vamos a crearnos un tipo de dato personalizado para poder usarlo en la definición de las props.

Lo primero que vamos a hacer es crear la función tipo. Vamos a crearla en la carpeta /utils. Al fichero, le vamos a dar el nombre de typeValidation.js. El contenido es el siguiente:

/** * Used for custom validations * @param hero {String} * @param time {String} * @param classSlug {String} * @constructor */ function HeroData (hero, time, classSlug) { this.hero = hero this.time = time this.classSlug = classSlug } export { HeroData }

Con esto hemos creado una función que usaremos como un nuevo tipo de dato. Podemos usar esta función y definir nuestra propiedad del componente como HeroData 💃.

A continuación, si revisas cómo está usándose el componente TimePlayed en PlayerStats/Index.vue, puedes ver que tiene este HTML:

<TimePlayed :timePlayed="timePlayed"/>

Sin embargo, no tenemos nada definido como timePlayed dentro de nuestro componente PlayerStats.
Para crear este dato, en el componente PlayerStats/Index.vue creamos una computed property con este nombre, de esta forma:

computed: { timePlayed () { return Object.keys(this.stats.timePlayed) .sort() .map(hero => { return new HeroData( this.classToName(hero), this.stats.timePlayed[hero], hero ) }) } }

Si inspeccionamos el navegador y analizamos el objeto de stats.timePlayed, vemos que tiene este contenido:

{ "demon-hunter":0.424, "barbarian":0.055, "witch-doctor":0.319, "necromancer":0.226, "wizard":0.103, "monk":1, "crusader":0.399 }

Lo que estamos haciendo en esta computed property es obtener todas las claves (keys) de este objeto, ordenarlas con sort, y, sobre este array de items ordenados, usar la función map. Con esto estaríamos generando un array de objetos con estos datos:

[ { hero: 'Barbarian', time: 0.055, classSlug: 'barbarian' }, { hero: 'Crusader', time: 0.399, classSlug: 'crusader' }, { hero: 'Demon Hunter', time: 0.424, classSlug: 'demon-hunter' } // ... ]

Con esto ya tenemos el nombre del héroe normalizado y el valor del tiempo. Además, estos datos los hemos creado con nuestra función HeroData, que utilizaremos más adelante.

Vayamos entonces al componente PlayerStats/TimePlayed.vue. Este componente también es muy sencillo y no requiere una explicación extensa:

<template> <div class="time-played mt-3"> <hr class="bg-white mt-5"> <h2 class="font-diablo my-4">Time Played</h2> <div class="bg-dark p-3"> <div v-for="hero in timePlayed" :key="hero.classSlug"> <TimePlayedHero :hero-time="hero"/> </div> </div> </div> </template> <script> import TimePlayedHero from './TimePlayedHero' export default { name: 'TimePlayed', components: { TimePlayedHero }, props: { timePlayed: { required: true, type: Array } } } </script>

Un v-for para recorrer el array de elementos de tipo HeroData que le hemos pasado como property y para cada elemento renderizamos el componente TimePlayedHero. Es aquí donde vamos a usar nuestra custom validation.


Vamos a explicar el componente TimePlayedHero.vue:

  • El bloque de JavaScript tiene lo siguiente:
<script> // Traemos nuestro tipo personalizado import { HeroData } from '@/utils/typeValidation' export default { name: 'TimePlayedHero', props: { // Definimos la propiedad con el tipo personalizado 'HeroData' heroTime: { type: HeroData, required: true } }, computed: { // Cada héroe tiene un color // Creamos una clase por cada héroe classHeroBg () { return `hero-bg-color-${this.heroTime.classSlug}` } } } </script>

La propiedad heroTime que recibe el componente , y que es de tipo HeroData, tiene este formato:

{ "hero": "Barbarian", // String "time": 0.055, // Number "classSlug": "barbarian" // String }

Lo bueno de haber usado el custom type es que si en vez de pasarle un dato de tipo HeroData le hubiéramos pasado un dato con el mismo formato que HeroData (es decir, un objeto con las 3 claves: hero, time y classSlug) pero que no ha sido creado con el constructor de HeroData, Vue nos indicará que ese tipo no es el esperado.

En este caso, para forzar el error, he usado un objeto en vez del tipo HeroData. Si lo cambias, deberías ver un error como este: custom-type-err.pngEs aquí dónde vemos la verdadera utilidad de los custom types.

Recuerda que vamos a hacer uso del componente progress de bootstrap-vue, que se utiliza bajo el nombre de <b-progress>.
Más info: https://bootstrap-vue.js.org/docs/components/progress

  • El HTML (de TimePlayedHero) es el siguiente:
<template> <div class="progress-time-played"> <div class="d-flex justify-content-between"> <h5 class="mb-0 font-weight-lighter"> {{heroTime.hero}} </h5> <span> <b-badge class="w-50p">{{ (heroTime.time * 100).toFixed(2) }}</b-badge> </span> </div> <b-progress :max="1" height="14px" class="mb-3 rounded-0"> <b-progress-bar :value="heroTime.time" :class="classHeroBg"> {{ heroTime.hero }} </b-progress-bar> </b-progress> </div> </template>

Como hemos establecido el valor máximo (:max) de la barra de progreso en 1, no hace falta que transformemos el valor (heroTime.time).
Con la clase CSS resultante de ejecutar classHeroBg, estamos estableciendo un color para cada personaje.

preview-2.pngAhora solo falta el bloque de CSS. En esta ocasión, vamos a ver cómo generar bucles y variables de CSS con Stylus.

// Definimos una variable, parecida a un objeto javascript // clave:valor $heroesBg = { barbarian: #4e1c16, crusader: #795548, demon-hunter: #F44336, monk: #ff9800, necromancer: #00bcd4, witch-doctor: #8bc34a, wizard: #3f51b5 }
// Creamos las clases CSS necesarias .progress-time-played h5.title color #000 .w-50p position relative width 50px bottom -2px border-bottom-left-radius 0 border-bottom-right-radius 0 // Bucle "for" // Itera sobre la variable $heroesBg // "hero" es la clave // "bgColor" es el valor for hero, bgColor in $heroesBg .hero-bg-color-{hero} background-color bgColor
  • {hero} se va a sustituir por la clave en $heroesBg, que correspondería a: 'barbarian', 'crusader', 'monk', 'necromancer', etc.
  • bgColor es el valor, es decir, los colores que hemos definido: '#4e1c16', '#795548', etc.

Con este bucle (de tres líneas) estamos creando todas la clases CSS necesarias para cada tipo de personaje.

> 📗 Documentación de bucles con Stylus: https://stylus-lang.com/docs/iteration.html

Es decir, estamos generando este código (repetitivo) a través de una de las funcionalidades que nos proporciona Stylus:

.progress-time-played .hero-bg-color-barbarian { background-color: #4e1c16; } .progress-time-played .hero-bg-color-crusader { background-color: #795548; } .progress-time-played .hero-bg-color-demon-hunter { background-color: #f44336; } .progress-time-played .hero-bg-color-monk { background-color: #ff9800; } .progress-time-played .hero-bg-color-necromancer { background-color: #00bcd4; } .progress-time-played .hero-bg-color-witch-doctor { background-color: #8bc34a; } .progress-time-played .hero-bg-color-wizard { background-color: #3f51b5; }

Este es el componente de TimePlayedHero.vue completo:

<template> <div class="progress-time-played"> <div class="d-flex justify-content-between"> <h5 class="mb-0 font-weight-lighter"> {{heroTime.hero}} </h5> <span> <b-badge class="w-50p">{{ (heroTime.time * 100).toFixed(2) }}</b-badge> </span> </div> <b-progress :max="1" height="14px" class="mb-3 rounded-0"> <b-progress-bar :value="heroTime.time" :class="classHeroBg"> {{ heroTime.hero }} </b-progress-bar> </b-progress> </div> </template> <script> // Custom validator import { HeroData } from '@/utils/typeValidation' export default { name: 'TimePlayedHero', props: { heroTime: { type: HeroData, required: true } }, computed: { classHeroBg () { return `hero-bg-color-${this.heroTime.classSlug}` } } } </script> <style lang="stylus" scoped> $heroesBg = { barbarian: #4e1c16, crusader: #795548, demon-hunter: #F44336, monk: #ff9800, necromancer: #00bcd4, witch-doctor: #8bc34a, wizard: #3f51b5 } .progress-time-played h5.title color #000 .w-50p position relative width 50px bottom -2px border-bottom-left-radius 0 border-bottom-right-radius 0 for hero, bgColor in $heroesBg .hero-bg-color-{hero} background-color bgColor </style>

Si todo va bien, se debería ver así:

preview-3.png

¡Llegaste al final... De este capítulo!

No te preocupes, todavía no hemos terminado. Te espero en la siguiente lectura.