Expectativas

Nessa aula vamos colocar React para rodar junto ao nosso projeto, e iremos adaptar nossa API para que ela seja consumida pelo nosso Front-end em React.


Ponto de partida desse post:

O que você precisa?

Você vai precisar ter NodeJS instalado, eu estou utilizando a versão v17.0.1, porém consulte a página dos módulos que utilizarmos para verificar compatibilidade. E também estou utilizando o yarn para rodar os comandos.

Criando nosso projeto

Abra um terminal dentro do nosso projeto, e rode:

yarn create react-app  qr-generator

O comando irá demorar um pouco para rodar, enquanto isso vamos preparar outros aquivos.

Rodando com o VSCode

Se você seguiu o tutorial inteiro, você deve estar rodando o projeto pelo VSCode, e agora nós podemos rodar em paralelo tanto o Front-end quando o Back-end. Abra o .vscode/launch.json, e modifique ela para ficar assim.

{
    "version": "0.2.0",
    "compounds": [
      {
        "name": "Launch Full Application",
        "configurations": ["Launch Application (Go)", "Launch Application (Node)"],
        "stopAll": true
      }
    ],
    "configurations": [
      {
        "name": "Launch Application (Node)",
        "command": "npm start",
        "request": "launch",
        "type": "node-terminal",
      },
      {
        "name": "Launch Application (Go)",
        "command": "air server --port 8000",
        "request": "launch",
        "type": "node-terminal"
      }
    ]
}

Com isso na aba Run and Debug, você verá a opção de rodar com Launch Full Application, isso conclui essa etapa.

Atualizando o Air

Abra o .air.toml, e vamos adicionar os arquivos de Front-end para ele ignorar quando tiverem atualizações.

[build]
  /* ... */
  exclude_dir = ["static", "tmp", "vendor", "testdata", "node_modules", "src"]
  /* ... */

Você também deve atualizar o .gitignore:

qrcode.png
tmp/*
db.json
/node_modules
.pnp*
/coverage
/build
.env.*
*-debug.log*

Copiando arquivos

Até aqui, o yarn create já deve ter terminado, e sim, vamos copiar os arquivos de dentro dessa past para a raiz do projeto.

mv ./qr-generator/public ./
mv ./qr-generator/node_modules ./
mv ./qr-generator/package.json ./

mkdir src

mv ./qr-generator/src/setupTest.js ./src/setupTest.js

rm -rf ./qr-generator/

Com isso seu projeto já deve estar pronto, vá para próxima seção e vamos começar a preencher nosso código.

Fetcher

Nós vamos escrever um componente que será responsável por fazer requisições HTTP e enviar os dados para os componentes filhos. Rode no seu terminal:

mkdir src/fetcher
touch src/fetcher/fetcher.jsx

Abra src/fetcher/fetcher.jsx e vamos preenchendo juntos.

import { useState, useEffect } from "react"

const Fetcher = ({ children, action }) => {
  const [loading, setLoading] = useState(true)
  const [data, setData] = useState(null)
  const [error, setError] = useState(null)

Até aqui, estamos apenas declarando as props do nosso Fetcher, e seus estados iniciais. Nós vamos fazer uso do useEffect para fazer a requisição HTTP assim que o componente renderizar.

  useEffect(() => {
    const loadData = async () => {
      setLoading(true)
      try {
        const response = await action()
        const data = await response.json()

        setData(data)
      } catch (error) {
        setError(error?.message)
      } finally {
        setLoading(false)
      }
    }

    loadData()
  }, [action])

Esse useEffect nos diz que ele tentará carregar os dados, e fará o controle do result, error, e loading conforme temos atualizações desses estados. E daqui para frente iremos só retornar com base em cada estado.

if (loading) {
    return <div>Loading...</div>
  }

  if (error) {
    return <div>{error}</div>
  }

  return children(data)
}

export default Fetcher

Espero que não tenha ficado muito abstrato por hora, vai ficar mais claro quando implementarmos o resto do nosso código. Abra o terminal, nós vamos trabalhar aqui em fazer o componente que vai receber a listagem de sites.

mkdir src/sites
touch src/sites/list.jsx

Abra o sites/list.jsx, e vamos preencher eles juntos.

import React from "react"

const SitesList = (props) => {
  const { sites = [] } = props?.data || {}

Até aqui, coisa simples, nós esperamos um array de sites, e vamos ou retornar uma lista, ou uma mensagem dizendo que não temos sites.

  return (
    <div>
      <h2>Sites:</h2>

      {sites?.length === 0 && <p>No sites</p>}
      <ul>
        {sites.map((site, index) => (
          <li key={`${site.URL}-${site.id}-${index}`}>{site.URL}</li>
        ))}
      </ul>
    </div>
  )
}

Agora, vamos trabalhar a action que o Fetcher vai receber.

SitesList.action = () => {
  return fetch("http://localhost:8000/api/sites", {
    headers: {
      "Content-Type": "application/json",
    },
    method: "GET",
    mode: "cors",
  })
}

Estaremos utilizando fetch, pois é o padrão recomendado para requisições HTTP. E por fim, vamos exportar o componente.

export default SitesList

A partir daqui, você já deve ter percebido que eu estou utilizando os nomes em minúsculo, e o CRA usa o padrão PascalCase. Eu prefiro a convenção de arquivos em minúsculo, mas você pode mudar isso se quiser. Outra coisa que você talvez já tenha percebido, é que tem arquivos com o final test.js, nós vamos escrever testes pros nossos arquivos, mas eu não vou me aprofundar em conteúdos de teste, pelo menos não por hora.

Abra um terminal e rode o seguinte comando:

touch src/sites/list.test.jsx

Abra o src/sites/list.test.jsx, e preencha comigo:

/* eslint-disable testing-library/no-node-access */
/* eslint-disable testing-library/no-container */
import React from 'react'
import { render, screen } from '@testing-library/react'
import SitesList from './list'

describe('<SitesList />', () => {

Sim, eu adicionei algumas regras que ignoram warnings do Eslint, você pode colocar num arquivo separado, eu deixei aqui mais para simplicidade.

O describe agrupa nossos testes, pense como se estivéssemos descrevendo determinado componente. E vamos começar com cada situação, a primeira é o componente não tendo uma lista para renderizar.

  test('renders an empty list', () => {
    render(<SitesList />)

    const element = screen.getByText(/No sites/i)

    expect(element).toBeInTheDocument()
  })

Próxima condição seria tendo múltiplos elemento, e vamos testar isso.

  test('renders an list with multiple elements', () => {
    const { container } = render(<SitesList data={{sites: [ { URL: "test" }, { URL: "test" } ,{ URL: "test" } ]}} />)

    const element = container.querySelectorAll('li')

    expect(element.length).toBe(3)
  })
})

Sei que pode parecer bobo, porém esses testes são cruciais para quando formos adicionar opção de editar, e remover sites. E aproveitando a vibe, vamos testar o fetcher?

touch src/fetcher/fetcher.test.jsx

No arquivo fetcher/fetcher.test.jsx vamos lidar com testes assíncronos, você pode se aprofundar mais na documentação de cada método, porém iremos utilizar a seguinte estrutura:

  • action: MockedPromise<>
  • WaitFor
  • Expect

Vamos ver na prática:

/* eslint-disable testing-library/prefer-query-by-disappearance */
/* eslint-disable testing-library/no-unnecessary-act */
import React from 'react'
import { render, screen, waitFor, waitForElementToBeRemoved, act } from '@testing-library/react'
import Fetcher from './fetcher'

describe('<Fetcher />', () => {

Até agora nada atípico, certo? Primeiro teste será o de loading:

  test('> loading: should call action function', async () => {
    const action = jest.fn(() => Promise.resolve({ json: () => Promise.resolve({ info: "test loading" }) }))

    render(
      <Fetcher action={action}>
        {(data) => <div>{data.info}</div>}
      </Fetcher>
    )

    await waitFor(() => expect(action).toBeCalled())

    expect(action).toBeCalled()
  })

Perceba que utilizamos os jest para mockar uma response, chamamos o render normal, e utilizamos o await waitFor para esperar que a função action seja chamada, pelo menos uma vez, e em seguida fazemos o teste de que a função action foi chamada.

Para o Error, e Success, eles seguem uma estrutura muito parecida:

test('> error: should show error', async () => {
    const action = jest.fn(() => Promise.resolve({ json: () => Promise.reject({ message: "test error" }) }))

    act(() => {
      render(
        <Fetcher action={action}>
          {(data) => <div>{data.info}</div>}
        </Fetcher>
      )
    })

    await waitForElementToBeRemoved(() => screen.getByText(/Loading/i))

    await screen.findByText(/test error/i)
  })

Porém, para esse cenário, não basta apenas a action ser chamada, o Loading tem que sair da tela, então nós esperamos pelo elemento ser removido. O success seria a mesma coisa, porém trocando reject por resolve.

test('> success: should show the content', async () => {
    const action = jest.fn(() => Promise.resolve({ json: () => Promise.resolve({ info: "test success" }) }))

    act(() => {
      render(
        <Fetcher action={action}>
          {(data) => <div>{data.info}</div>}
        </Fetcher>
      )
    })


    await waitForElementToBeRemoved(() => screen.getByText(/Loading/i))

    await screen.findByText(/test success/i)
  })
})

Com isso, você já entendeu como vai funcionar testes assíncronos, teste síncronos, e de quebra, você também viu como é a assinatura do nosso componente. Vamos agora fazer tudo renderizar.

touch src/app.jsx
touch src/index.jsx

Abra o src/app.jsx, e vamos importar ambos o Fetcher e o SitesList, e renderizar:

import Fetcher from './fetcher/fetcher'
import SiteList from './sites/list'

function App() {
  return (
    <div className="App">
      <Fetcher action={SiteList.action}>
        {(data) => {
          return (
            <SiteList data={data} />
          )
        }}
      </Fetcher>
    </div>
  )
}

export default App

Depois, abra o src/index.jsx para chamarmos o nosso App na DOM:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './app';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Se você seguiu o tutorial até aqui, você pode abrir o http://localhost:3000 e ver o resultado.

Você deve abrir as ferramentas de desenvolvedor do seu navegador, lá você verá que a requisição deu errado por conta do CORS, nó vamos resolver isso agora.

Veja mais sobre o CORS aqui.

Resolvendo o CORS

Resolver CORS costuma ser algo que atormenta muitos desenvolvedores, porém a solução costuma também ser simples. Rode o comando no terminal.

go get github.com/gin-contrib/cors

Em seguida, abra o server/http.go, e adicione o módulo de CORS:

import (
  "github.com/gin-contrib/cors"
  /* ... */
)

/* ... */

func (a *Server) Run() {
  database.NewDB()
  defer database.Close()

  router := gin.Default()
  router.Use(cors.Default())
  /* ... */

Prontinho, com isso seu Front-end já deve estar funcionando, e mostrando pelo menos uma lista básica de Sites.

Nas próximas aulas, nós vamos fazer o Front-end enviar POST, DELETE, e PUT para o Back-end, nesse meio tempo, que tal você entender o que fizemos no Front-end de forma mais profunda?

Aguardo na próxima aula.

Lista de aulas: