Amigo Secreto

TLDR; Vamos a jugar amigo secreto en la oficina, pero yo trabajo remoto. Que tal si en vez de meter los papelitos con los nombres en una bolsita, creo una aplicación realtime con react.js + firebase?

Vamos a ver que resulta…

El demo está en esta pagina https://upbeat-keller-5f955d.netlify.com en caso de que quieras ver el resultado final

Primero que todo, creamos el proyecto e instalamos algunos paquetes

$ create-react-app secret-friend
$ cd secret-friend
$ npm install @react-firebase/database
$ npm install firebase
$ npm start

It’s alive!

PDTA: Importamos bootstrapcdn en el index.html de la carpeta /publish dentro del head tag

<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css"
    integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" crossorigin="anonymous">

Bueno, cuando el usuario ingresa necesito solicitar el nombre de el, así que necesitamos crear el archivo src/Components/Users.js un componente que se encargue de eso:

import React, { Component } from 'react'

export class User extends Component {
    static propTypes = {
        onSubmit: PropTypes.func.isRequired,
    }

    state = {
        username: "",
        submited: false
    }

    handleSubmit = (event) => {
        event.preventDefault()

        if (this.state.submited)
            return;
        this.setState({
            submited: true
        })
        console.log("new user added", this.state.username)
        this.props.onSubmit(this.state.username)
    }

    handleChange = (event) => {
        let username = event.target.value;
        this.setState({
            username
        })
    }

    render() {
        return (
            <div>
                <form onSubmit={this.handleSubmit}>
                    <label htmlFor="username" >
                        Player
                    </label>
                    <div>
                        <input
                            type="text"
                            id="username"
                            placeholder="Nombres"
                            autoComplete="off"
                            value={this.state.username}
                            onChange={this.handleChange}
                            disabled={this.state.submited}
                        >
                        </input>
                        <button
                            type="submit"
                            disabled={!this.state.username}
                        >
                            Submit
                        </button>
                    </div>
                </form>
            </div>
        )
    }
}

export default User

La idea con este componente es permitirnos registrarnos en la aplicación. Aquí hay varias cosas, pero básicamente este componente nos permitirá solicitar y devolver el nombre al componente que lo invoca de la persona que esté usando la aplicación. En este caso nos iremos al componente App.js y lo invocaremos:

import React, { Component } from 'react'
import User from './Components/User';

export default class App extends Component {
  state = {
    currentUser: "",
  }

  handleSubmit = (currentUser) => {
    this.setState({
      currentUser
    });

    localStorage.clear("user")
    localStorage.setItem("user", JSON.stringify(currentUser))
  }

  componentDidMount() {
    let userSerialized = localStorage.getItem("user")
    if (userSerialized) {
      var user = JSON.parse(userSerialized)
      this.setState({
        currentUser: user
      })
    }
  }

  render() {
    return (
      <div>
        {!this.state.currentUser  && <User
          onSubmit={(currentUser) => this.handleSubmit(currentUser)}
        />}
        <span className="badge badge-info">{this.state.currentUser}</span>
      </div>
    );
  }
}

Lo que podemos ver aquí es que en cuando importamos User le estamos pasando un evento, para saber cuando alguien haya escrito su nombre. El evento será recibido por handleSubmit que se encargará de guardar esa variable en el estado.

Adicionalmente guardaremos la información en el localStorage con el fin de que si el usuario va a refrescar la ventana, nos me permita cargar la información previamente digitada. El evento componentDidMount nos permitirá al iniciar cargar dicha información.

Al hacer un set de la propiedad this.state.currentUser el componente volverá a renderizar la aplicación, la cual evaluará varias cosas:

  • Si el usuario existe, el quitará el componente User para evitar que vuelvan a escribir el usuario.
  • Mostrar un badge con el nombre de usuario actual.

Agreguemos algo de persistencia con Firebase

Para poder compartir con todos los usuarios la información, es importante conectarnos a una base de datos. En este caso vamos a usar la base de datos en tiempo real que nos provee google y que puede ser usada por medio de su SDK. Para tal razón vamos a hacer un simple step by step de como configurar firebase en react.js

  1. Vamos al portal https://console.firebase.google.com

  2. Creamos un nuevo proyecto. (continue, continue)

  3. Después en el overview, hay 4 opciones (iOS, Android, Web y Unity), damos click en Web. Registramos el nombre de la App “secret-friends” y click en “Register App”. Eso nos mostrará un script parecido a esto:

     <!-- The core Firebase JS SDK is always required and must be listed first -->
     <script src="https://www.gstatic.com/firebasejs/6.6.0/firebase-app.js"></script>
    
     <!-- TODO: Add SDKs for Firebase products that you want to use
         https://firebase.google.com/docs/web/setup#config-web-app -->
    
     <script>
     // Your web app's Firebase configuration
     var firebaseConfig = {
         apiKey: "API-KEY",
         authDomain: "project-name.firebaseapp.com",
         databaseURL: "https://project-name.firebaseio.com",
         projectId: "project-name",
         storageBucket: "",
         messagingSenderId: "APP-ID",
         appId: "ANOTHER-APP-ID"
     };
     // Initialize Firebase
     firebase.initializeApp(firebaseConfig);
     </script>
    
  4. Vamos al proyecto, creamos un archivo src/fire.js y allí adentro acomodamos la información anterior con la siguiente estructura

     import * as firebase from 'firebase';
    
     const firebaseConfig = {
         apiKey: "API-KEY",
         authDomain: "project-name.firebaseapp.com",
         databaseURL: "https://project-name.firebaseio.com",
         projectId: "project-name",
         storageBucket: "",
         messagingSenderId: "APP-ID",
         appId: "ANOTHER-APP-ID"
     };
    
     firebase.initializeApp(firebaseConfig);
     const database = firebase.database();
    
     export {
         database,
     };
    

    Todo bien allí en el projecto.

  5. Vamos a volver al portal de Firebase y en la opción Develop/Database bajamos a la opción Or choose Realtime Database damos click en Create Database, eso abrirá un modal, allí seleccionamos la opción Start in test mode (Get set up quickly by allowing all reads and writes to your database). Esto es temporal, recuerden que como dice firebase Sus reglas de seguridad se definen como públicas, por lo que cualquiera puede robar, modificar o eliminar datos en su base de datos. Cuando se vaya a desplegar en producción, debemos cambiar esta configuración.

Todo bien hasta el momento, ahora volveremos a nuestro proyecto en React.js y vamos a crear un nuevo componente que nos permitirá traer las personas creadas desde firebase.

El componente se llamará src/Components/Participants.js

import React, { Component } from 'react'
import { database } from '../fire';

export default class Participants extends Component {
    state = {
        users: [],
        currentUser: ""
    }

    componentDidMount() {
        let messagesRef = database.ref('users').orderByKey().limitToLast(100);
        messagesRef.on('child_added', snapshot => {
            /* Update React state when message is added at Firebase Database */
            let user = { text: snapshot.val(), id: snapshot.key };
            this.setState(state => {
                const users = state.users.concat(user);
                return {
                    users
                };
            });

            this.props.userUpdated(this.state.users)
        })
    }

    render() {

        return (
            <div>
                <ul>
                    { /* Render the list of messages */
                        this.state.users.map(user => <li key={user.id} style={user.text === this.props.currentUser ? { color: "red" } : null}>{user.text}</li>)
                    }
                </ul>
            </div>
        )
    }
}

Aquí hay varias cosas, primero que todo, importamos la clase que acabamos de hacer import { database } from '../fire';. Y vamos a hacer uso del componente componentDidMount() en el que obtendremos todos los usuarios registrados en la base de datos. En ese metodo inicialmente traerán todos los usuarios y vamos a quedar suscritos para cada vez que se agregue uno nuevo carga en este metodo permitiendonos que siempre que se agrega un nuevo usuario, este se agregará en el state en la propiedad this.state.users

this.setState(state => {
    const users = state.users.concat(user);
    return {
        users
    };
});

Esto permite que a partir de un array que actualmente está en el state, se concatena el nuevo usuario.

Despues de haber hecho esto, queremos saber en esa lista, quien soy yo? Entonces en el render vamos a hacer lo siguiente

this.state.users.map(user =>
    <li
        key={user.id}
        style={user.text === this.props.currentUser ? { color: "red" } : null}
    > {user.text}</li>)

Entonces al recorrer los usuarios, vamos a comparar cada uno con una propiedad que me pasará que vamos a implementar. Ahora vamos a agregarlo

Ahora para hacer uso de este componente Participants, en el Apps vamos a hacer las siguientes modificaciones:

// Another imports
import Participants from './Components/Participants';

export default class App extends Component {

    //Another Methods 
    handleUsersRefresh = (users) => {
        this.setState({
            users
        });

    }

    render() {
        return (
        <div>
            {!this.state.currentUser  && <User
                onSubmit={(currentUser) => this.handleSubmit(currentUser)}
            />}

            <span className="badge badge-info">{this.state.currentUser}</span>
            <Participants
                userUpdated={(userList) => this.handleUsersRefresh(userList)}
                currentUser={this.state.currentUser}
            />
        </div>
        );
    }
}

Aquí vamos a agregarle al componente Participants 2 parametros. userUpdated que es un evento que nos permitirá traer la lista de nuevos usuarios a este componente siempre que haya uno nuevo. Y el usuario actual que tenemos currentUser como parametro que se le enviará.

Ahora lo que haremos es agregar un nuevo componente que básicamente nos permitirá mostrar un mensaje de acuerdo a ciertas reglas. El componente se llamará src/Component/Status.js

import React from 'react'

export default function Status({ userLenght, handleStatus, MatchAlreadyRunned }) {

    let status = ""
    let canBePlayed = false
    if (userLenght === 0) {
        status = "Sin jugadores"
        canBePlayed = false
    }
    else {
        let par = (userLenght % 2)
        if (par) {
            status = "Hay una lista impar, falta alguien?"
            canBePlayed = false
        } else {
            status = "Hay una lista par, Jugamos?"
            canBePlayed = true
        }
    }
    if (MatchAlreadyRunned) {
        status = "Ya se han repartido las cartas..."
    }
    return (
        <div>
            {(<div className={`alert alert-${canBePlayed ? 'success' : 'danger'}`} role="alert">
                {`${userLenght}- ${status}`}
              </div>
            )}
            {canBePlayed && !MatchAlreadyRunned &&
                <button type="button" className="btn btn-danger" onClick={handleStatus} >Repartir</button>
            }
        </div>
    )
}

Aquí lo que básicamente estamos haciendo es pasandole a la función la cantidad de usuarios que hay (userLenght), el evento handleStatus que nos permitirá asignar el amigo secreto y MatchAlreadyRunned que nos dirá si ya se ha hecho el match de los usuarios.

Para poder usar este componente, vamos al componente Participants y agregamos el componente Status:

// Another imports
import Status from './Status';

export default class Participants extends Component {
    state = {
        users: [],
        currentUser: ""
    }

    // Another methods    

    render() {

        return (
            <div>
                <ul>
                    { /* Render the list of users */ }
                    <Status userLenght={this.state.users.length} 
                            handleStatus={this.props.handleStatus} 
                            MatchAlreadyRunned={this.props.MatchAlreadyRunned} 
                    />
                </ul>
            </div>
        )
    }
}

Pero esos metodos van a ser implemenetados en el componente App por tal razón, se delegará a las funciones mandadas desde el this.props.. this.props.handleStatus y this.props.MatchAlreadyRunned
Vamos a agregar esas funciones al componente App:

// Another Imports
import { database } from './fire';


export default class App extends Component {
  state = {
    currentUser: "",
    users: [],
    partner: ""
  }

  handleSubmit = (currentUser) => {
    this.setState({
      currentUser,
      MatchAlreadyRunned: false
    });

    localStorage.clear("user")
    localStorage.setItem("user", JSON.stringify(currentUser))
  }

  componentDidMount() {
    // Implementación más abajo
  }

  func(a, b) {
    return 0.5 - Math.random();
  }

  handleStatus = () => {
    // Implementación más abajo

  }

  render() {
    return (
      <div>
        { /* Another Components */}

        <Participants
          userUpdated={(userList) => this.handleUsersRefresh(userList)}
          currentUser={this.state.currentUser}
          handleStatus={this.handleStatus}
          MatchAlreadyRunned={this.state.MatchAlreadyRunned}
         />
        {this.state.partner &&
          <div className="alert alert-success" role="alert">
            <strong >You match with {this.state.partner}</strong>
          </div>
        }
      </div>
    );
  }
}

Aquí hay varias cosas, puede que no sea la forma más optima o la mejor; pero fue la solución que llegó a mi mente en su momento.

Bueno, tenemos handleStatus que es una función que me permite tomar una lista de usuarios, los desorganizo asignandolos a la variable usersShuffled, clono la lista original en la variable pendingUsersToAssign y recorro esa lista item por item diciendo:

Hey!? traeme alguien de forma random desde la lista clonada, y si es igual que el que se está evaluando, traeme otro. Si encontras uno que no sea yo, borralo de la lista clonada y metelo en la variable blendedUsers

Despues creo una lista nueva de usuarios a partir de la lista que está en this.state.users (La original) y la recorro agregandole la propiedad partner a cada usuario con el usuario al que quedó asignado y despues agarro esa lista y le hago push a firebase permitiendo que todas las instancias de la aplicación abiertas puedan obtener esa información. En este caso será a la referencia users-match database.ref('users-match').push(newUsers); la que acepte esa lista.


handleStatus = () => {
    let { users } = this.state

    let usersShuffled = users.sort(this.func);

    let blendedUsers = [];
    let pendingUsersToAssign = [...users];

    for (let i = 0; i < usersShuffled.length; i++) {
      const element = usersShuffled[i];
      let partner = pendingUsersToAssign[Math.floor(Math.random() * pendingUsersToAssign.length)];

      while (element.id === partner.id && pendingUsersToAssign.length > 1) {
        partner = pendingUsersToAssign[Math.floor(Math.random() * pendingUsersToAssign.length)]
      }
      pendingUsersToAssign = pendingUsersToAssign.filter(i => i.id !== partner.id)
      blendedUsers.push({
        element,
        partner
      })
    }

    let newUsers = []
    for (let i = 0; i < users.length; i++) {
      const element = users[i];
      let partner = blendedUsers.find(i => i.element.id === element.id)

      newUsers.push({
        id: element.id,
        text: element.text,
        partner: partner.partner
      })
    }

    database.ref('users-match').push(newUsers);

  }

Por tal razón hacemos uso de nuevo del metodo componentDidMount para, ademas de obtener el usuario creado, vamos a suscribirnos a firebase para que siempre que alguien haga push de algo en users-match podré obtener todos los matchs.

componentDidMount() {
    //Get User from LocalStorage 

    let messagesRef = database.ref('users-match').orderByKey().limitToLast(100);
    messagesRef.on('child_added', snapshot => {
      /* Update React state when message is added at Firebase Database */

      let users = { data: snapshot.val(), id: snapshot.key };
      let currentUserInList = users.data.filter(i => i.text === this.state.currentUser)
      this.setState({
        MatchAlreadyRunned : true
      })
      if (currentUserInList.length > 0) {
        this.setState({
          partner: currentUserInList[0].partner.text
        })

      }
    })
  }

Finalmente, agarramos esa lista y filtro mi usuario a partir del this.state.currentUser y le digo al estado que ya se ha realizado el proceso de MatchAlreadyRunned en todos los usuarios (Esto para evitar que alguien más le de click al boton del componente State) y adicional, si está mi usuario en esa lista, actualizo mi estado en la propiedad partner. El efecto se verá en el render en la siguiente linea

{this.state.partner &&
    <div className="alert alert-success" role="alert">
    <strong >You match with {this.state.partner}</strong>
    </div>
}

Básicamente dice que si tengo un partner asignado, me dirá quien es 😁!

Espero no haber olvidado nada. El demo está en esta pagina https://upbeat-keller-5f955d.netlify.com

PDTA: Si quieres hacerle deploy de la aplicación facilmente, sube este código a github (O tu gestor de repos favoritos) y sigue este tutorial https://dev.to/easybuoy/deploying-react-app-from-github-to-netlify-3a9j