Skip to content

Latest commit

 

History

History
365 lines (276 loc) · 12.9 KB

README.md

File metadata and controls

365 lines (276 loc) · 12.9 KB

Tareas de un equipo de desarrollo

Build React App coverage

video

El ejemplo que muestra las tareas de un equipo de desarrollo, permite asignar, cumplir o modificar la descripción de una tarea.

Conceptos

  • Componentes de React
  • Uso de componentes visuales de Material: select (combo), text field, snack bar (message box), tablas, entre otras
  • React router que define un master / detail
  • Uso de fetch para disparar pedidos asincrónicos tratados con promises
  • Manejo del estado

Arquitectura general

Página principal: ver tareas

master

  • TareasComponent: es el que sabe mostrar la tabla y delega en TareaRow la visualización de cada ítem
  • TareaRow: conoce cómo mostrar una tarea dentro de una fila de la tabla
  • PorcentajeCumplimiento: es un componente que muestra un avatar con el % de cumplimiento en diferentes colores. En rojo se visualizan las tareas cuyo % de cumplimiento es menor a 50, luego de 50 a 90% exclusive aparecen en amarillo y por último las que tienen 90% ó más se ven en verde.

image

A través de nuestro custom hook useOnInit disparamos la búsqueda de tareas:

  const traerTareas = async () => {
    try {
      const tareas = await tareaService.allInstances()
      setTareas(tareas)
    } catch (error) {
      mostrarMensajeError(error, setErrorMessage)
    }
  }

  useOnInit(traerTareas)

Definimos la función aparte para poder usarla como prop a nuestro componente hijo.

El service hace la llamada asincrónica al backend utilizando la biblioteca Axios, transformando la lista de objetos JSON en objetos Tarea y ordenándolas por descripción:

class TareaService {
  async allInstances() {
    const tareasJson = await axios.get(`${REST_SERVER_URL}/tareas`)
    const tareas = tareasJson.data.map((tareaJson) => Tarea.fromJson(tareaJson)) // o ... this.tareaAsJson
    return tareas.sort((a, b) => a.descripcion < b.descripcion ? -1 : 1)
  }

Cuando el pedido vuelve con un estado ok, se actualiza el estado del componente React: setTareas(tareas)

También podríamos utilizar la sintaxis de promises común then().catch().

traerTareas() {
  tareaService.allInstances()
    .then((tareas) => {
      setTareas(tareas)
    })
    .catch ((error) => {
      mostrarMensajeError(error, setErrorMessage)
    })
}

Cumplir una tarea

El componente TareaRow captura el evento del botón:

export const TareaRowexport const TareaRow = ({ tarea, actualizar }) => {

  <IconButton ... onClick={cumplirTarea}>
      <CheckCircleIcon />
  </IconButton>

En el método del componente delegamos el cumplimiento al objeto de dominio Tarea y pedimos al service que actualice el backend. Cuando la promise se cumple, disparamos la función que nos pasaron por props para buscar nuevamente las tareas al backend, así traemos la última información:

// en el componente funcional TareaRow
  const cumplirTarea = async () => {
    // debugger // para mostrar que no se cambia la ui despues de hacer tarea.cumplir()
    try {
      tarea.cumplir()
      await tareaService.actualizarTarea(tarea)
    } catch (error) {
      mostrarMensajeError(error, setErrorMessage)
    } finally {
      // viene como props
      await actualizar()
    }
  }

TareaComponent le pasa la función al componente TareaRow:

// en Tarea se envía para cada uno de los elementos de la lista
tareas.map((tarea) =>
  <TareaRow
    tarea={tarea}
    key={tarea.id}
    actualizar={traerTareas} />)

El método traerTareas ya lo hemos visto, es el que se dispara inicialmente en el componente principal.

Por su parte, el método actualizarTarea del service dispara el pedido PUT al backend, pasando como body la conversión de nuestro objeto de dominio Tarea a JSON:

actualizarTarea(tarea) {
  return axios.put(`${REST_SERVER_URL}/tareas/${tarea.id}`, tarea.toJSON())
}

Asignación de tareas

Navegación

El botón de asignación dispara la navegación de la ruta '/asignarTarea' (en TareaRow):

const goToAsignarTarea = () => {
    navigate(`/asignarTarea/${tarea.id}`)
}

navigate es una función que obtenemos mediante el hook useNavigate:

const navigate = useNavigate()

A su vez, en el archivo routes.js definimos que el path /asignarTarea/:id se mapea con el componente de React que permite asignar la tarea:

export const TareasRoutes = () => 
    <Router>
        <Routes>
            <Route exact={true} path="/" element={<TareasComponent/>} />
            <Route path="/asignarTarea/:id" element={<AsignarTareaComponent/>} />
        </Routes>
    </Router>

Para más información pueden ver esta página del Router de React.

image

Llamadas asincrónicas

En la asignación de tareas el combo de usuarios se llena con una llamada al servicio REST que trae los usuarios:

class UsuarioService {

  async allInstances() {
    const { data } = await axios.get(`${REST_SERVER_URL}/usuarios`)
    // { data } aplica destructuring sobre el objeto recibido por la promise, es equivalente a hacer
    // const response = await ...
    // return response.data
    return data
  }

Además de los usuarios, agregamos en el combo la opción "Sin Asignar", para poder desasignar una tarea (lo tenemos que asociar a un valor en blanco):

<Select
  /* Acá podemos ver cómo esta declarado nombreAsignatario */
  value={tarea.nombreAsignatario ?? ' '}
  onChange={(event) => asignar(event.target.value)}
  className="formControl"
  title="asignatario"
  inputProps={{
    name: 'asignatario',
    id: 'asignatario',
  }}
>
    >
        <MenuItem value=" ">
        <em>Sin Asignar</em>
    </MenuItem>
    {usuarios.map(usuario => <MenuItem value={usuario.nombre} key={usuario.nombre}>{usuario.nombre}</MenuItem>)}
</Select>

La clase formControl especifica un width más grande (el default es muy chico), en el archivo index.css:

.formControl {
  width: 35rem;
  min-width: 35rem;
}

Para entender cómo funciona la asignación, el combo dispara el evento de cambio al componente AsignarTareas:

... onChange={(event) => this.asignar(event.target.value)}

El método asignar recibe el nombre del nuevo asignatario (podríamos recibir el identificador, pero lamentablemente el servicio REST solo nos da el nombre), entonces delegamos a un método más general que actualiza el estado de la tarea. En el componente AsignarTareaComponent:

const asignar = (asignatario) => {
  const asignatarioNuevo = usuarios.find((usuario) => usuario.nombre === asignatario)
  tarea.asignarA(asignatarioNuevo)
  generarNuevaTarea(tarea)
}

const generarNuevaTarea = (tarea) => {
  const nuevaTarea = Object.assign(new Tarea(), tarea)
  setTarea(nuevaTarea)
  setErrorMessage('')
}

Un detalle importante es que no podemos hacer la copia de la tarea utilizando el spread operator ({...tarea}) porque solo copia los atributos del objeto y no sus métodos. Pueden investigar más en este link.

Al actualizar el estado se dispara el render que refleja el nuevo valor para el combo, y tenemos entonces siempre la tarea actualizada.

Aceptar los cambios de la asignación

Cuando el usuario presiona el botón Aceptar, se dispara el evento asociado que delega la actualización al service y regresa a la página principal.

const aceptarCambios = async () => {
  try {
    tarea.validarAsignacion()
    await tareaService.actualizarTarea(tarea)
    volver()
  } catch (error) {
    mostrarMensajeError(error, setErrorMessage)
  }
}

Se delega la validación en la tarea directamente. Pueden ver la implementación en el código.

Keys de componentes custom en un loop

Veamos el código que muestra la lista de tareas:

  <TableBody data-testid="resultados">
    {
      tareas.map((tarea) =>
        <TareaRow
          tarea={tarea}
          key={tarea.id}
          actualizar={traerTareas} />)
    }
  </TableBody>

Como lo cuenta la documentación de React, es importante dar a cada uno de nuestros componentes custom (TareaRow en este caso) una key para identificar rápidamente qué componentes están asociados a un cambio de estado (el Virtual DOM interno que maneja React). La restricción que deben cumplir los componentes hermanos es que a) sus key sean únicas, b) que existan.

Si eliminamos la línea que genera la key, el Linter de React nos muestra un mensaje de error: Missing "key" prop for element in iterator. Pero qué ocurre si definimos una clave constante, como por ejemplo 1:

<TableBody data-testid="resultados">
  {
    tareas.map((tarea) =>
      <TareaRow
        tarea={tarea}
        key={1}
        actualizar={traerTareas} />)
  }
</TableBody>
  • por un lado en la consola nos aparece un error en runtime, donde nos alerta que definir la misma clave puede producir inconsistencias en las actualizaciones de la página
  • por otro lado, cuando cumplimos una tarea, se actualizan innecesariamente todas las filas de la tabla. Podría pasar incluso que se actualice la información de las filas incorrectas

La necesidad de trabajar con key únicas entre hermanos solo es necesaria cuando tenemos un loop, una iteración (no ocurre cuando estamos definiendo un componente solo).

Testing

Ahora que separamos todo en componentes más chicos y con menos responsabilidades, son mucho más fáciles de testear 🎉

TareaRow

A este componente le pasamos una tarea por props y basándonos en los diferentes estados de la misma hacemos lo siguiente:

  • si está asignada nos aparece el botón que permite marcarla como cumplida
  • si está asignada pero su porcentaje de cumplimiento está completo no aparece el botón cumplir
  • cuando tocamos el botón asignar nos redirige hacia otra página
  • si no está asignada no aparece dicho botón

El lector puede ver la implementación en el archivo tareaRow.spec.js, vamos a detenernos en dos detalles de implementación nuevos. El primero es que la función getByTestId tira error si el elemento que buscamos no existe, por ese motivo usamos queryByTestId del objeto screen:

test('si su porcentaje de cumplimiento está completo NO se puede asignar', () => {
  tareaAsignada.cumplir()
  render(<BrowserRouter><TareaRow tarea={tareaAsignada} /></BrowserRouter>)
  expect(screen.queryByTestId('cumplir_' + tareaAsignada.id)).toBeNull()
})

Y el segundo es que usamos un spy para escuchar a qué ruta nos dirigimos cuando la asignación se hizo correctamente:

...
const mockedNavigate = vi.fn()
vi.mock('react-router-dom', async () => {
    const mockedRouter = await vi.importActua('react-router-dom')

    return {
        ...mockedRouter,
        useNavigate: () => mockedNavigate,
    }
})

...

test('y se clickea el boton de asignacion, nos redirige a la ruta de asignacion con el id de la tarea', async () => {
  render(
    <BrowserRouter>
      <TareaRow
        tarea={tareaAsignada}
      />
    </BrowserRouter>
  )

  await userEvent.click(screen.getByTestId('asignar_' + tareaAsignada.id))
  expect(mockedNavigate).toBeCalledWith(`/asignarTarea/${tareaAsignada.id}`)
})

Tareas

Mockear el servicio

La parte más interesante de los tests es cómo hacemos para interceptar las llamadas a nuestros services, lo primero es crear nuestros datos de mock (pueden ver la implementación en el archivo crearTarea.js). Y ahora sí podemos construir una promise mockeada, dentro de nuestros tests. Como nuestro servicio de tareas es un singleton, podemos pisar el método en el contexto de los tests haciendo que devuelva una promesa con lo que nosotros queramos directamente, de la siguiente manera:

tareaService.allInstances = () => Promise.resolve(mockTareas)

Y nuestro test queda de la siguiente forma :

describe('cuando el servicio responde correctamente', () => {
  test('se muestran las tareas en la tabla', async () => {
    tareaService.allInstances = () => Promise.resolve(mockTareas)
    render(<BrowserRouter><TareasComponent /></BrowserRouter>)
    expect(await screen.findByTestId('tarea_159')).toBeInTheDocument()
    expect(await screen.findByTestId('tarea_68')).toBeInTheDocument()
  })
})

De todas maneras este approach nos deja el comportamiento de tareaService fijo para que siempre devuelva mockTareas. Si queremos que luego del test vuelva a su comportamiento original deberíamos utilizar el mock que nos proporciona vi.

Es necesario envolver TareasComponent en el BrowserRouter para recibir la navegación y que funcione correctamente.