Artigo original: How to Build a TodoApp using ReactJS and Firebase

Olá, pessoal. Boas-vindas a este tutorial. Antes de começarmos, você deve estar familiarizado com os conceitos básicos do ReactJS. Se não está, eu recomendo que você veja a documentação do ReactJS (em inglês).

Usaremos os seguintes componentes na aplicação:

  1. ReactJS
  2. Material UI
  3. Firebase
  4. ExpressJS
  5. Postman

Como nossa aplicação ficará:

Account-1
Criação de conta
ezgif.com-optimize
Painel da aplicação de lista de afazeres

Arquitetura da aplicação:

TodoApp-1
Arquitetura da aplicação

Entendendo nossos componentes:

Você talvez esteja se perguntando o motivo de estarmos usando o Firebase nessa aplicação. Bem, ele fornece autenticação segura, um banco de dados em tempo real, um componente serverless (em português, sem servidor) e um bucket de armazenamento.

Estamos usando o Express aqui. Assim, não precisaremos lidar com as exceções do HTTP. Usaremos todos os pacotes do Firebase em nossos componentes funcionais. Não queremos deixar o client da aplicação muito grande, o que tende a deixar o processo de carregamento da interface lento.

Observação: dividirei este tutorial em quatro seções separadas. Você encontrará um git commit com o código desenvolvido para cada seção. Se você quiser ver o código completo, também, ele estará disponível neste repositório.

Seção 1: desenvolvimento das APIs de afazeres

Nesta seção, vamos desenvolver os seguintes elementos:

  1. Configurar as funções do Firebase.
  2. Instalar o framework do Express e criar as APIs.
  3. Configurar o firestore como banco de dados.

O código da API implementada nesta seção pode ser encontrado neste commit.

Configurar as funções do firebase:

Vá até o Firebase console.‌‌

FirebaseFunctions
Firebase Console

Selecione a opção Add Project (Adicionar projeto). Depois disso, siga o passo a passo do gif abaixo para configurar o projeto do Firebase.

FirebaseConfigure
Configuração do Firebase

Vá até a aba Functions (Funções) e clique no botão Get Started (Iniciar):

FirebaseFunctionConfig1
Painel de funções

Você verá uma caixa de diálogo com instruções sobre Como configurar as funções do firebase. Vá até seu ambiente local. Abra a ferramenta de linha de comando. Para instalar as ferramentas do Firebase em sua máquina, use o comando abaixo:

npm install -g firebase-tools

Quando estiver concluído, use o comando firebase init para configurar as funções do Firebase em seu ambiente local. Selecione as seguintes opções quando estiver inicializando uma função do Firebase no ambiente local:

  1. Quais funcionalidades do Firebase CLI você quer configurar para esta pasta? Pressione a barra de espaço para selecionar as funcionalidades. Depois, aperte Enter para confirmar suas escolhas => Functions: Configure and deploy Cloud Functions
  2. Primeiro, vamos associar o diretório deste projeto com o projeto do Firebase… => Use an existing project
  3. Selecione um projeto padrão do Firebase para este diretório => application_name
  4. Com qual linguagem você gostaria de escrever as Cloud Functions? => JavaScript
  5. Você quer usar ESLint para saber sobre prováveis bugs e estilizar? => N
  6. Você quer instalar dependências com o npm agora? (Y/n) => Y

Depois que a configuração estiver concluída, você verá a seguinte mensagem:

✔ Firebase initialization complete!

Quando a inicialização estiver completa, a estrutura do nosso diretório ficará assim:

+-- firebase.json 
+-- functions
|   +-- index.js
|   +-- node_modules
|   +-- package-lock.json
|   +-- package.json

Agora, abra o index.js no diretório functions e copie e cole o seguinte código:

const functions = require('firebase-functions');

exports.helloWorld = functions.https.onRequest((request, response) => {
     response.send("Hello from Firebase!");
});

Envie o código para as funções do Firebase usando o seguinte comando:

firebase deploy

Assim que o envio concluir, você verá a seguinte linha de registro no fim da sua linha de comando:

> ✔  Deploy complete!
> Project Console: https://console.firebase.google.com/project/todoapp-<id>/overview

Vá até Project Console > Functions. Lá, você encontrará o URL da API. O URL será assim:

https://<região-de-hosting>-todoapp-<id>.cloudfunctions.net/helloWorld

Copie esse URL e cole-o no navegador. Você receberá a seguinte resposta:

Hello from Firebase!

Isso confirma que nossa função do Firebase foi configurada corretamente.

Instalar o framework do Express:

Agora vamos instalar o framework do Express em nosso projeto usando o seguinte comando:

npm i express

Vamos criar um diretório APIs dentro do diretório functions. Dentro desse diretório, vamos criar um arquivo chamado todos.js. Remova tudo do arquivo index.js e copie e cole o seguinte código:

//index.js

const functions = require('firebase-functions');
const app = require('express')();

const {
    getAllTodos
} = require('./APIs/todos')

app.get('/todos', getAllTodos);
exports.api = functions.https.onRequest(app);

Atribuímos a função getAllTodos à rota /todos. Então, todas as chamadas à API nesta rota executarão a partir da função getAllTodos. Em seguida, vá até o arquivo todos.js no diretório APIs e vamos escrever a função getAllTodos.

//todos.js

exports.getAllTodos = (request, response) => {
    todos = [
        {
            'id': '1',
            'title': 'greeting',
            'body': 'Hello world from sharvin shah' 
        },
        {
            'id': '2',
            'title': 'greeting2',
            'body': 'Hello2 world2 from sharvin shah' 
        }
    ]
    return response.json(todos);
}

Aqui, declaramos um objeto JSON de exemplo. Depois, vamos extrai-lo a partir do Firestore. Por agora, no entanto, vamos retornar isso. Envie isso para sua função do Firebase usando o comando firebase deploy. Ele pedirá permissão para apagar o módulo helloworld – apenas aperte Enter e  y.

The following functions are found in your project but do not exist in your local source code: helloWorld

Would you like to proceed with deletion? Selecting no will continue the rest of the deployments. (y/N) y

Depois de concluído, vá até Project Console > Functions. Lá, você encontrará o URL da API, que será algo como:

https://<região-de-hosting>-todoapp-<id>.cloudfunctions.net/api

Agora, vá ao navegador e copie e cole o URL, adicionando /todos no fim do URL. Você receberá o seguinte resultado:

[
        {
            'id': '1',
            'title': 'greeting',
            'body': 'Hello world from sharvin shah' 
        },
        {
            'id': '2',
            'title': 'greeting2',
            'body': 'Hello2 world2 from sharvin shah' 
        }
]

Firebase Firestore:

Usaremos o firestore do Firebase como banco de dados em tempo real para nossa aplicação. Vá até Console > Database no Firebase Console. Para configurar o firestore, siga o gif abaixo:

Firestore
Configurando o Firestore

Quando a configuração terminar, clique no botão Start Collection e indique Collection ID como sendo todos. Clique em Next e receberá o seguinte pop-up:

FireStore-collection
Criando o banco de dados manualmente

Ignore a chave DocumentID. Para preencher field, type e value, veja o JSON abaixo. Atualize o valor de acordo:

{
    Field: title,
    Type: String,
    Value: Hello World
},
{
    Field: body,
    Type: String,
    Value: Hello folks I hope you are staying home...
},
{
    Field: createtAt,
    type: timestamp,
    value: Add the current date and time here
}

Pressione o botão de salvar. Você verá que a coleção e o documento foram criados. Volte para o ambiente local. Precisamos instalar firebase-admin, que possui o pacote firestore de que precisamos. Use este comando para instalá-lo:

npm i firebase-admin

Crie um diretório chamado util no diretório functions. Vá até esse diretório e crie um arquivo chamado admin.js. Neste arquivo, vamos importar o pacote firebase admin e inicializar o objeto do banco de dados do firestore. Vamos exportá-lo para que outros módulos possam usá-lo.

//admin.js

const admin = require('firebase-admin');

admin.initializeApp();

const db = admin.firestore();

module.exports = { admin, db };

Agora, vamos escrever uma API para buscar esses dados. Vá até todos.js, no diretório functions > APIs. Remova o código antigo e copie e cole o código abaixo:

//todos.js

const { db } = require('../util/admin');

exports.getAllTodos = (request, response) => {
	db
		.collection('todos')
		.orderBy('createdAt', 'desc')
		.get()
		.then((data) => {
			let todos = [];
			data.forEach((doc) => {
				todos.push({
                    todoId: doc.id,
                    title: doc.data().title,
					body: doc.data().body,
					createdAt: doc.data().createdAt,
				});
			});
			return response.json(todos);
		})
		.catch((err) => {
			console.error(err);
			return response.status(500).json({ error: err.code});
		});
};

Aqui estamos buscando todos os afazeres do banco de dados e enviando-os para o client como uma lista.

Você também pode executar a aplicação localmente usando o comando firebase serve  ao invés de enviar toda vez. Quando você executar esse comando, talvez apareça um erro relacionado às credenciais. Para consertar, siga os passos mencionados abaixo:

  1. Vá até Project Settings (ícone de engrenagem ao lado esquerdo no topo)
  2. Vá até a aba Service Accounts
  3. Lá embaixo, você verá a opção de Generating a new key. Clique nessa opção e você baixará um arquivo JSON.
  4. Precisamos exportar essas credenciais para nossa seção de linha de comando. Use o comando abaixo para fazer isso:
export GOOGLE_APPLICATION_CREDENTIALS="/home/user/Downloads/[NOME_DO_ARQUIVO].json"

Depois disso, execute o comando firebase serve. Se você ainda tiver um erro, use o seguinte comando: firebase login --reauth. Ele abrirá a página para fazer registro com o Google no navegador. Depois de completar o registro, ele funcionará sem erros.

Você encontrará um URL nos registros da sua ferramenta de linha de comando quando executar o comando firebase serve. Abra esse URL no navegador e digite /todos no fim.

✔ functions[api]: http function initialized (http://localhost:5000/todoapp-<project-id>/<nome-da-região>/api).

Você terá o seguinte resultado em JSON no seu navegador:

[
    {
        "todoId":"W67t1kSMO0lqvjCIGiuI",
        "title":"Hello World",
        "body":"Hello folks I hope you are staying home...",
        "createdAt":{"_seconds":1585420200,"_nanoseconds":0 }
    }
]

Escrevendo outras APIs:

Está na hora de escrever outras APIs das quais precisaremos na nossa aplicação.

  1. Crie um afazer: vá até index.js no diretório functions. Importe o método postOneTodo abaixo de getAllTodos. Atribua, também, a rota POST para esse método.
//index.js

const {
    ..,
    postOneTodo
} = require('./APIs/todos')

app.post('/todo', postOneTodo);

Vá até todos.js dentro do diretório de funções e adicione um novo método postOneTodo abaixo do método getAllTodos.

//todos.js

exports.postOneTodo = (request, response) => {
	if (request.body.body.trim() === '') {
		return response.status(400).json({ body: 'Must not be empty' });
    }
    
    if(request.body.title.trim() === '') {
        return response.status(400).json({ title: 'Must not be empty' });
    }
    
    const newTodoItem = {
        title: request.body.title,
        body: request.body.body,
        createdAt: new Date().toISOString()
    }
    db
        .collection('todos')
        .add(newTodoItem)
        .then((doc)=>{
            const responseTodoItem = newTodoItem;
            responseTodoItem.id = doc.id;
            return response.json(responseTodoItem);
        })
        .catch((err) => {
			response.status(500).json({ error: 'Something went wrong' });
			console.error(err);
		});
};

Nesse método, estamos adicionando um novo afazer em nosso banco de dados. Se os elementos do nosso conteúdo estiverem vazios, retornaremos a resposta 400. Caso contrário, vamos adicionar os dados.

Execute o comando firebase serve e abra o Postman. Crie uma requisição e selecione o método de tipo POST. Adicione o URL e um body do tipo JSON.

URL: http://localhost:5000/todoapp-<app-id>/<nome-da-região>/api/todo

METHOD: POST

Body: {
   "title":"Hello World",
   "body": "We are writing this awesome API"
}

Pressione o botão de enviar e você terá a seguinte resposta:

{
     "title": "Hello World",
     "body": "We are writing this awesome API",
     "createdAt": "2020-03-29T12:30:48.809Z",
     "id": "nh41IgARCj8LPWBYzjU0"
}

2. Apague um afazer: vá até index.js no diretório functions. Importe o método deleteTodo abaixo do postOneTodo. Atribua a rota DELETE para esse método também.

//index.js

const {
    ..,
    deleteTodo
} = require('./APIs/todos')

app.delete('/todo/:todoId', deleteTodo);

Vá até todos.js e adicione um novo método deleteTodo abaixo do método postOneTodo.

//todos.js

exports.deleteTodo = (request, response) => {
    const document = db.doc(`/todos/${request.params.todoId}`);
    document
        .get()
        .then((doc) => {
            if (!doc.exists) {
                return response.status(404).json({ error: 'Todo not found' })
            }
            return document.delete();
        })
        .then(() => {
            response.json({ message: 'Delete successfull' });
        })
        .catch((err) => {
            console.error(err);
            return response.status(500).json({ error: err.code });
        });
};

Nesse método, estamos excluindo um afazer do nosso banco de dados. Execute o comando firebase serve e vá até o Postman. Crie uma requisição, selecione o tipo DELETE para o método e adicione o URL.

URL: http://localhost:5000/todoapp-<app-id>/<nome-da-região>/api/todo/<todo-id>

METHOD: DELETE

Pressione o botão de enviar e você terá a seguinte resposta:

{
   "message": "Delete successfull"
}

3. Edite um afazer: vá até index.js no diretório functions. Importe o método editTodo abaixo de deleteTodo. Atribua a rota PUT a esse método.

//index.js

const {
    ..,
    editTodo
} = require('./APIs/todos')

app.put('/todo/:todoId', editTodo);

Vá até  todos.js e adicione um novo método editTodo abaixo do método deleteTodo já existente.

//todos.js

exports.editTodo = ( request, response ) => { 
    if(request.body.todoId || request.body.createdAt){
        response.status(403).json({message: 'Not allowed to edit'});
    }
    let document = db.collection('todos').doc(`${request.params.todoId}`);
    document.update(request.body)
    .then(()=> {
        response.json({message: 'Updated successfully'});
    })
    .catch((err) => {
        console.error(err);
        return response.status(500).json({ 
                error: err.code 
        });
    });
};

Nesse método, estamos editando um afazer da nossa base de dados. Lembre-se de que não estamos permitindo que o usuário edite os campos todoId ou createdAt. Execute o comando firebase serve e vá até o Postman. Crie uma requisição, selecione o tipo PUT para o método e adicione o URL.

URL: http://localhost:5000/todoapp-<app-id>/<nome-da-região>/api/todo/<todo-id>

METHOD: PUT

Pressione o botão de enviar e você terá a seguinte resposta:

{  
   "message": "Updated successfully"
}

Estrutura do diretório até agora:

+-- firebase.json 
+-- functions
|   +-- API
|   +-- +-- todos.js
|   +-- util
|   +-- +-- admin.js
|   +-- index.js
|   +-- node_modules
|   +-- package-lock.json
|   +-- package.json
|   +-- .gitignore

Com isso, completamos a primeira seção da aplicação. Você pode ir tomar um café, descansar e, depois, vamos trabalhar no desenvolvimento das APIs dos usuários.

Seção 2: desenvolvimento das APIs dos usuários

Nesta seção, vamos desenvolver estes componentes:

  1. API de autenticação do usuário (login e registro).
  2. API para obter e atualizar os detalhes do usuário.
  3. API para atualizar a foto de perfil do usuário.
  4. API de afazeres mais segura.

O código da API de usuários implementado nesta seção pode ser encontrado neste commit.

Então, vamos começar construindo a API de autenticação do usuário. Vá até Firebase console > Authentication.

FirebaseAuthentication
Página de autenticação do Firebase

Clique no botão Set up sign-in method. Vamos usar e-mail e senha para a validação do usuário. Habilite a opção Email/Password.

FirebaseAuth1
Página de configuração do registro do Firebase

Agora, vamos criar nosso usuário manualmente. Primeiro, vamos criar a API de login. Depois, vamos criar a API de registro.

Vá até a aba Users, em Authentication, preencha os detalhes do usuário e clique no botão Add User.

Login
Adicionando o usuário manualmente

1. API de login do usuário:

Primeiro, precisamos instalar o pacote firebase, que consiste na biblioteca de autenticação do Firebase, usando o seguinte comando:

npm i firebase

Quando a instalação estiver concluída, vá até o diretório functions > APIs. Aqui, vamos criar um arquivo users.js. Agora, dentro de index.js, importamos um método loginUser e atribuímos a ele a rota POST.

//index.js

const {
    loginUser
} = require('./APIs/users')

// Users
app.post('/login', loginUser);

Vá até Project Settings > General. Lá, você encontrará o seguinte card:

app
Obtendo a configuração do Firebase

Selecione o ícone da Web e então siga o gif abaixo:

project

Selecione a opção Continue to console. Quando estiver concluído, você verá um JSON com as configurações do Firebase. Vá até o diretório functions > util e crie um arquivo config.js. Copie e cole o seguinte código nesse arquivo:

// config.js

module.exports = {
    apiKey: "............",
    authDomain: "........",
    databaseURL: "........",
    projectId: ".......",
    storageBucket: ".......",
    messagingSenderId: "........",
    appId: "..........",
    measurementId: "......."
};

Substitua ............ pelos valores que estão em Firebase console > Project settings >  General > Your apps > Firebase SD snippet > Config.

Copie e cole o seguinte código no arquivo users.js:

// users.js

const { admin, db } = require('../util/admin');
const config = require('../util/config');

const firebase = require('firebase');

firebase.initializeApp(config);

const { validateLoginData, validateSignUpData } = require('../util/validators');

// Login
exports.loginUser = (request, response) => {
    const user = {
        email: request.body.email,
        password: request.body.password
    }

    const { valid, errors } = validateLoginData(user);
	if (!valid) return response.status(400).json(errors);

    firebase
        .auth()
        .signInWithEmailAndPassword(user.email, user.password)
        .then((data) => {
            return data.user.getIdToken();
        })
        .then((token) => {
            return response.json({ token });
        })
        .catch((error) => {
            console.error(error);
            return response.status(403).json({ general: 'wrong credentials, please try again'});
        })
};

Estamos usando o módulo signInWithEmailAndPassword do Firebase para verificar se as credenciais enviadas pelo usuário estão certas. Se estiverem, então enviamos o token desse usuário. Caso contrário, enviamos um status 403 com mensagem "wrong credentials" (credenciais incorretas).

Agora vamos criar o arquivo validators.js no diretório functions > util. Copie e cole o seguinte código nesse arquivo:

// validators.js

const isEmpty = (string) => {
	if (string.trim() === '') return true;
	else return false;
};

exports.validateLoginData = (data) => {
   let errors = {};
   if (isEmpty(data.email)) errors.email = 'Must not be empty';
   if (isEmpty(data.password)) errors.password = 'Must not be  empty';
   return {
       errors,
       valid: Object.keys(errors).length === 0 ? true : false
    };
};

Com isso, a API de login está completa. Execute o comando firebase serve e vá até o Postman. Crie uma requisição, selecione o método POST, adicione o URL e o conteúdo.

URL: http://localhost:5000/todoapp-<app-id>/<nome-da-região>/api/login

METHOD: POST

Body: {   
    "email":"Add email that is assigned for user in console", 
    "password": "Add password that is assigned for user in console"
}

Aperte o botão para enviar a requisição no Postman e você receberá o seguinte resultado:

{   
    "token": ".........."
}

Usaremos esse token em uma parte futura para obter os detalhes do usuário. Lembre-se de que esse token expira em 60 minutos. Para gerar um novo token, use esta API novamente.

2. API de registro de usuário:

O mecanismo padrão de autenticação do Firebase permite apenas que você armazene informações como e-mail, senha, etc. Precisamos, porém, de mais informações para identificar se esse usuário possui esse afazer para que possa haver a leitura, a atualização e a exclusão.

Para isso, vamos criar uma coleção chamada users. Nessa coleção, vamos armazenar os dados do usuário que serão mapeados para o afazer baseado no nome de usuário. Cada nome de usuário será único para todos os usuários na plataforma.

Vá até index.js. Importaremos o método signUpUser e atribuiremos a ele a rota POST.

//index.js

const {
    ..,
    signUpUser
} = require('./APIs/users')

app.post('/signup', signUpUser);

Agora, vá até validators.js e adicione o seguinte código abaixo do método validateLoginData.

// validators.js

const isEmail = (email) => {
	const emailRegEx = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
	if (email.match(emailRegEx)) return true;
	else return false;
};

exports.validateSignUpData = (data) => {
	let errors = {};

	if (isEmpty(data.email)) {
		errors.email = 'Must not be empty';
	} else if (!isEmail(data.email)) {
		errors.email = 'Must be valid email address';
	}

	if (isEmpty(data.firstName)) errors.firstName = 'Must not be empty';
	if (isEmpty(data.lastName)) errors.lastName = 'Must not be empty';
	if (isEmpty(data.phoneNumber)) errors.phoneNumber = 'Must not be empty';
	if (isEmpty(data.country)) errors.country = 'Must not be empty';

	if (isEmpty(data.password)) errors.password = 'Must not be empty';
	if (data.password !== data.confirmPassword) errors.confirmPassword = 'Passowrds must be the same';
	if (isEmpty(data.username)) errors.username = 'Must not be empty';

	return {
		errors,
		valid: Object.keys(errors).length === 0 ? true : false
	};
};

Vá até users.js e adicione o seguinte código abaixo do módulo loginUser.

// users.js

exports.signUpUser = (request, response) => {
    const newUser = {
        firstName: request.body.firstName,
        lastName: request.body.lastName,
        email: request.body.email,
        phoneNumber: request.body.phoneNumber,
        country: request.body.country,
		password: request.body.password,
		confirmPassword: request.body.confirmPassword,
		username: request.body.username
    };

    const { valid, errors } = validateSignUpData(newUser);

	if (!valid) return response.status(400).json(errors);

    let token, userId;
    db
        .doc(`/users/${newUser.username}`)
        .get()
        .then((doc) => {
            if (doc.exists) {
                return response.status(400).json({ username: 'this username is already taken' });
            } else {
                return firebase
                        .auth()
                        .createUserWithEmailAndPassword(
                            newUser.email, 
                            newUser.password
                    );
            }
        })
        .then((data) => {
            userId = data.user.uid;
            return data.user.getIdToken();
        })
        .then((idtoken) => {
            token = idtoken;
            const userCredentials = {
                firstName: newUser.firstName,
                lastName: newUser.lastName,
                username: newUser.username,
                phoneNumber: newUser.phoneNumber,
                country: newUser.country,
                email: newUser.email,
                createdAt: new Date().toISOString(),
                userId
            };
            return db
                    .doc(`/users/${newUser.username}`)
                    .set(userCredentials);
        })
        .then(()=>{
            return response.status(201).json({ token });
        })
        .catch((err) => {
			console.error(err);
			if (err.code === 'auth/email-already-in-use') {
				return response.status(400).json({ email: 'Email already in use' });
			} else {
				return response.status(500).json({ general: 'Something went wrong, please try again' });
			}
		});
}

Validamos os dados do nosso usuário. Então, enviamos um e-mail e senha para o módulo createUserWithEmailAndPassword do Firebase para criar o usuário. Depois que o usuário foi criado com sucesso, salvamos as credenciais do usuário no banco de dados.

Com isso, a API de registro está completa. Execute o comando firebase serve  e vá até o Postman. Crie uma requisição, selecione o método POST. Adicione o URL e o conteúdo.

URL: http://localhost:5000/todoapp-<app-id>/<region-name>/api/signup

METHOD: POST

Body: {
   "firstName": "Add a firstName here",
   "lastName": "Add a lastName here",
   "email":"Add a email here",
   "phoneNumber": "Add a phone number here",
   "country": "Add a country here",
   "password": "Add a password here",
   "confirmPassword": "Add same password here",
   "username": "Add unique username here"
}

Aperte o botão para enviar a requisição no Postman e você receberá o seguinte resultado:

{   
    "token": ".........."
}

Agora, vá até Firebase console > Database e, lá, você verá o seguinte resultado:

database

Como pode ver, nossa coleção de usuário foi criada com sucesso com um documento nela.

3. Carregar a foto de perfil do usuário:

Nossos usuários serão capazes de carregar suas fotos de perfil. Para isso, estaremos usando o bucket Storage. Vá até Firebase console > Storage e clique no botão Get started. Siga o GIF abaixo para saber como configurar:

storage

Agora, vá na aba Rules, em Storage, e atualize a permissão para o acesso ao bucket, como na imagem abaixo:

storageRule

Para carregar a foto de perfil, usaremos o pacote chamado busboy. Para instalar o pacote, use o seguinte comando:

npm i busboy

Vá até index.js. Importe o método uploadProfilePhoto abaixo do método signUpUser. Também atribua a rota POST para ele.

//index.js

const auth = require('./util/auth');

const {
    ..,
    uploadProfilePhoto
} = require('./APIs/users')

app.post('/user/image', auth, uploadProfilePhoto);

Aqui, adicionamos uma camada de autenticação para que apenas um usuário associado àquela conta possa carregar a imagem. Agora, crie um arquivo chamado auth.js no diretório functions > utils. Copie e cole o seguinte código nesse arquivo:

// auth.js

const { admin, db } = require('./admin');

module.exports = (request, response, next) => {
	let idToken;
	if (request.headers.authorization && request.headers.authorization.startsWith('Bearer ')) {
		idToken = request.headers.authorization.split('Bearer ')[1];
	} else {
		console.error('No token found');
		return response.status(403).json({ error: 'Unauthorized' });
	}
	admin
		.auth()
		.verifyIdToken(idToken)
		.then((decodedToken) => {
			request.user = decodedToken;
			return db.collection('users').where('userId', '==', request.user.uid).limit(1).get();
		})
		.then((data) => {
			request.user.username = data.docs[0].data().username;
			request.user.imageUrl = data.docs[0].data().imageUrl;
			return next();
		})
		.catch((err) => {
			console.error('Error while verifying token', err);
			return response.status(403).json(err);
		});
};

Aqui, estamos usando o módulo verifyIdToken do Firebase para verificar o token. Depois disso, estaremos decodificando os detalhes do usuário e enviando-os na requisição existente.

Vá até users.js e adicione o seguinte código abaixo do método signup:

// users.js

deleteImage = (imageName) => {
    const bucket = admin.storage().bucket();
    const path = `${imageName}`
    return bucket.file(path).delete()
    .then(() => {
        return
    })
    .catch((error) => {
        return
    })
}

// Upload profile picture
exports.uploadProfilePhoto = (request, response) => {
    const BusBoy = require('busboy');
	const path = require('path');
	const os = require('os');
	const fs = require('fs');
	const busboy = new BusBoy({ headers: request.headers });

	let imageFileName;
	let imageToBeUploaded = {};

	busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
		if (mimetype !== 'image/png' && mimetype !== 'image/jpeg') {
			return response.status(400).json({ error: 'Wrong file type submited' });
		}
		const imageExtension = filename.split('.')[filename.split('.').length - 1];
        imageFileName = `${request.user.username}.${imageExtension}`;
		const filePath = path.join(os.tmpdir(), imageFileName);
		imageToBeUploaded = { filePath, mimetype };
		file.pipe(fs.createWriteStream(filePath));
    });
    deleteImage(imageFileName);
	busboy.on('finish', () => {
		admin
			.storage()
			.bucket()
			.upload(imageToBeUploaded.filePath, {
				resumable: false,
				metadata: {
					metadata: {
						contentType: imageToBeUploaded.mimetype
					}
				}
			})
			.then(() => {
				const imageUrl = `https://firebasestorage.googleapis.com/v0/b/${config.storageBucket}/o/${imageFileName}?alt=media`;
				return db.doc(`/users/${request.user.username}`).update({
					imageUrl
				});
			})
			.then(() => {
				return response.json({ message: 'Image uploaded successfully' });
			})
			.catch((error) => {
				console.error(error);
				return response.status(500).json({ error: error.code });
			});
	});
	busboy.end(request.rawBody);
};

Com isso, nossa API de carregar foto de perfil está completa. Execute o comando firebase serve e vá até o Postman. Crie uma requisição, selecione o método POST, adicione o URL e, na seção de conteúdo, selecione o tipo form-data.

A requisição está protegida. Você precisará enviar o token bearer também. Para enviar o token bearer, faça login novamente, caso ele tenha expirado. Depois disso, em Postman App > Authorization tab > Type > Bearer Token, cole o token na seção do token.

URL: http://localhost:5000/todoapp-<app-id>/<region-name>/api/user/image

METHOD: GET

Body: { REFER THE IMAGE down below }
cover-1

Aperte o botão para enviar a requisição no Postman e você receberá o seguinte resultado:

{        
    "message": "Image uploaded successfully"
}

4. Obter os detalhes do usuário:

Aqui, estamos buscando os dados dos nossos usuários a partir do banco de dados. Vá até index.js, importe o método getUserDetail e atribua a rota GET.

// index.js

const {
    ..,
    getUserDetail
} = require('./APIs/users')

app.get('/user', auth, getUserDetail);

Agora, vá até users.js e adicione o seguinte código depois do módulo uploadProfilePhoto:

// users.js

exports.getUserDetail = (request, response) => {
    let userData = {};
	db
		.doc(`/users/${request.user.username}`)
		.get()
		.then((doc) => {
			if (doc.exists) {
                userData.userCredentials = doc.data();
                return response.json(userData);
			}	
		})
		.catch((error) => {
			console.error(error);
			return response.status(500).json({ error: error.code });
		});
}

Estamos usando o módulo doc().get() do Firebase para retornar os detalhes do usuário. Com isso, nossa API de obter detalhes do usuário está completa. Execute o comando firebase serve e vá até o Postman. Crie uma requisição, selecione o método GET, adicione o URL e o conteúdo.

A requisição está protegida. Então, você precisará enviar o token bearer também. Para enviar o token bearer, faça login novamente caso o token já tenha expirado.

URL: http://localhost:5000/todoapp-<app-id>/<region-name>/api/user
METHOD: GET

Aperte o botão de enviar requisição no Postman e você receberá o seguinte resultado:

{
   "userCredentials": {
       "phoneNumber": "........",
       "email": "........",
       "country": "........",
       "userId": "........",
       "username": "........",
       "createdAt": "........",
       "lastName": "........",
       "firstName": "........"
    }
}

5. Atualizar os detalhes do usuário:

Agora, vamos adicionar a funcionalidade para atualizar os detalhes do usuário. Vá até index.js, copie e cole o seguinte código:

// index.js

const {
    ..,
    updateUserDetails
} = require('./APIs/users')

app.post('/user', auth, updateUserDetails);

Vá até users.js e adicione o módulo updateUserDetails abaixo do getUserDetails existente:

// users.js

exports.updateUserDetails = (request, response) => {
    let document = db.collection('users').doc(`${request.user.username}`);
    document.update(request.body)
    .then(()=> {
        response.json({message: 'Updated successfully'});
    })
    .catch((error) => {
        console.error(error);
        return response.status(500).json({ 
            message: "Cannot Update the value"
        });
    });
}

Aqui, estamos usando o método update do Firebase. Com isso, nossa API de atualizar os detalhes do usuário está completa. Siga o mesmo processo feito na API de obter detalhes do usuário para realizar uma requisição, com apenas uma mudança. Adicione o conteúdo na requisição e um método POST.

URL: http://localhost:5000/todoapp-<app-id>/<region-name>/api/user

METHOD: POST

Body : {
    // You can edit First Name, last Name and country
    // We will disable other Form Tags from our UI
}

Aperte o botão para enviar a requisição no Postman e você receberá o seguinte resultado:

{
    "message": "Updated successfully"
}

6. Tornar a API de afazeres segura:

Para tornar a API de afazeres segura, de modo que apenas o usuário escolhido possa acessá-la, faremos algumas pequenas mudanças em nosso código atual. Primeiramente, vamos atualizar index.js para:

// index.js

// Todos
app.get('/todos', auth, getAllTodos);
app.get('/todo/:todoId', auth, getOneTodo);
app.post('/todo',auth, postOneTodo);
app.delete('/todo/:todoId',auth, deleteTodo);
app.put('/todo/:todoId',auth, editTodo);

Atualizamos todas as rotas de afazeres adicionando auth. Assim, todas as chamadas para a API precisarão de um token e poderão ser acessadas apenas por um usuário em particular.

Depois disso, vá até todos.js no diretório functions > APIs.

  1. Crie a API de afazeres: abra todos.js e, no método postOneTodo, adicione a chave username, assim:
const newTodoItem = {
     ..,
     username: request.user.username,
     ..
}

2. API para obter todos os afazeres: abra todos.js e, no método getAllTodos, adicione a instrução where, assim:

db
.collection('todos')
.where('username', '==', request.user.username)
.orderBy('createdAt', 'desc')

Execute firebase serve e teste nossa API GET. Não se esqueça de mandar o token bearer. Aqui, você terá uma resposta de erro assim:

{   
    "error": 9
}

Vá até a linha de comando e você verá as seguintes linhas:

i  functions: Beginning execution of "api">  Error: 9 FAILED_PRECONDITION: The query requires an index. You can create it here: <URL>>      at callErrorFromStatus

Abra o <URL> acima e clique em Create index.

index

Quando o índice estiver pronto, envie a requisição novamente e você terá o seguinte resultado:

[
   {
      "todoId": "......",
      "title": "......",
      "username": "......",
      "body": "......",
      "createdAt": "2020-03-30T13:01:58.478Z"
   }
]

3. API para excluir um afazer: abra todos.js e, abaixo de deleteTodo, adicione a condição abaixo. Adicione esta condição dentro da pesquisa document.get().then(), abaixo da condição !doc.exists.

..
if(doc.data().username !== request.user.username){
     return response.status(403).json({error:"UnAuthorized"})
}

Estrutura do diretório até agora:

+-- firebase.json 
+-- functions
|   +-- API
|   +-- +-- todos.js 
|   +-- +-- users.js
|   +-- util
|   +-- +-- admin.js
|   +-- +-- auth.js
|   +-- +-- validators.js
|   +-- index.js
|   +-- node_modules
|   +-- package-lock.json
|   +-- package.json
|   +-- .gitignore

Com isso, completamos o back-end da nossa API. Faça uma pausa, tome um café e, depois disso, vamos começar a construir o front-end da nossa aplicação.

Seção 3: painel do usuário

Nesta seção, vamos desenvolver estes componentes:

  1. Configurar ReactJS e Material UI.
  2. Criar o formulário de login e registro.
  3. Criar a seção das contas.

O código do painel do usuário implementado nesta seção pode ser encontrado neste commit.

1. Configurar o ReactJS e o Material UI:

Vamos usar o template create-react-app. Ele nos dará uma estrutura fundamental para desenvolver a aplicação. Para instalá-lo, use o seguinte comando:

npm install -g create-react-app

Vá até a pasta da raiz do projeto onde se encontra o diretório functions. Inicialize o front-end da nossa aplicação usando o seguinte comando:

create-react-app view

Lembre-se de usar a versão v16.13.1 da biblioteca ReactJS.

Quando a instalação completar, você verá o seguinte nos registros da sua linha de comando:

cd view
  npm start
Happy hacking!

Com isso, configuramos nossa aplicação do React. Você terá a seguinte estrutura de projeto:

+-- firebase.json 
+-- functions { This Directory consists our API logic }
+-- view { This Directory consists our FrontEnd Compoenents }
+-- .firebaserc
+-- .gitignore

Agora, execute a aplicação usando o comando npm start. Vá até http://localhost:3000/ no navegador e você verá o seguinte resultado:

React1

Agora, vamos remover todos os componentes que não são necessários. Vá até o diretório view e remova todos os arquivos que tem a palavra [ Remove ] ao lado. Para isso, veja a árvore de estrutura do diretório abaixo.

+-- README.md [ Remove ]
+-- package-lock.json
+-- package.json
+-- node_modules
+-- .gitignore
+-- public
|   +-- favicon.ico [ Remove ]
|   +-- index.html
|   +-- logo192.png [ Remove ]
|   +-- logo512.png [ Remove ]
|   +-- manifest.json
|   +-- robots.txt
+-- src
|   +-- App.css
|   +-- App.test.js
|   +-- index.js
|   +-- serviceWorker.js
|   +-- App.js
|   +-- index.css [ Remove ]
|   +-- logo.svg [ Remove ]
|   +-- setupTests.js

Vá até index.html, no diretório public, e remova as seguintes linhas:

<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />

Vá até App.js, no diretório src e substitua o código antigo pelo seguinte código:

import React from 'react';
function App() {
  return (
    <div>
    </div>
  );
}
export default App;

Vá até index.js e remova a seguinte importação:

import './index.css'

Não apaguei o App.css nem o estou usando nesta aplicação. Porém, se você quiser apagá-lo ou usá-lo, fique à vontade.

Vá até http://localhost:3000/ no navegador e você verá uma tela branca como resultado.

Para instalar o Material UI, vá até o diretório view, copie e cole este comando no terminal:

npm install @material-ui/core

Lembre-se de usar a versão v4.9.8 da biblioteca Material UI.

2. Formulário de login:

Para desenvolver o formulário de login, vá até App.js. No topo do arquivo App.js, adicione as seguintes importações:

import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import login from './pages/login';

Estamos usando Switch e Route para atribuir rotas para nossa aplicação de afazeres. Agora, vamos adicionar apenas a rota de /login e atribuir um componente de login a ela.

// App.js

<Router>
    <div>
       <Switch>
           <Route exact path="/login" component={login}/>
       </Switch>
    </div>
</Router>

Crie um diretório pages abaixo do diretório view existente e um arquivo chamado login.js no diretório pages.

Vamos importar os componentes do Material UI e o pacote Axios em login.js:

// login.js

// Material UI components
import React, { Component } from 'react';
import Avatar from '@material-ui/core/Avatar';
import Button from '@material-ui/core/Button';
import CssBaseline from '@material-ui/core/CssBaseline';
import TextField from '@material-ui/core/TextField';
import Link from '@material-ui/core/Link';
import Grid from '@material-ui/core/Grid';
import LockOutlinedIcon from '@material-ui/icons/LockOutlined';
import Typography from '@material-ui/core/Typography';
import withStyles from '@material-ui/core/styles/withStyles';
import Container from '@material-ui/core/Container';
import CircularProgress from '@material-ui/core/CircularProgress';

import axios from 'axios';

Vamos adicionar a seguinte estilização para nossa página de login:

// login.js

const styles = (theme) => ({
	paper: {
		marginTop: theme.spacing(8),
		display: 'flex',
		flexDirection: 'column',
		alignItems: 'center'
	},
	avatar: {
		margin: theme.spacing(1),
		backgroundColor: theme.palette.secondary.main
	},
	form: {
		width: '100%',
		marginTop: theme.spacing(1)
	},
	submit: {
		margin: theme.spacing(3, 0, 2)
	},
	customError: {
		color: 'red',
		fontSize: '0.8rem',
		marginTop: 10
	},
	progess: {
		position: 'absolute'
	}
});

Vamos criar uma classe chamada login, que possui um formulário e uma função que lida com o envio desse formulário.

// login.js

class login extends Component {
	constructor(props) {
		super(props);

		this.state = {
			email: '',
			password: '',
			errors: [],
			loading: false
		};
	}

	componentWillReceiveProps(nextProps) {
		if (nextProps.UI.errors) {
			this.setState({
				errors: nextProps.UI.errors
			});
		}
	}

	handleChange = (event) => {
		this.setState({
			[event.target.name]: event.target.value
		});
	};

	handleSubmit = (event) => {
		event.preventDefault();
		this.setState({ loading: true });
		const userData = {
			email: this.state.email,
			password: this.state.password
		};
		axios
			.post('/login', userData)
			.then((response) => {
				localStorage.setItem('AuthToken', `Bearer ${response.data.token}`);
				this.setState({ 
					loading: false,
				});		
				this.props.history.push('/');
			})
			.catch((error) => {				
				this.setState({
					errors: error.response.data,
					loading: false
				});
			});
	};

	render() {
		const { classes } = this.props;
		const { errors, loading } = this.state;
		return (
			<Container component="main" maxWidth="xs">
				<CssBaseline />
				<div className={classes.paper}>
					<Avatar className={classes.avatar}>
						<LockOutlinedIcon />
					</Avatar>
					<Typography component="h1" variant="h5">
						Login
					</Typography>
					<form className={classes.form} noValidate>
						<TextField
							variant="outlined"
							margin="normal"
							required
							fullWidth
							id="email"
							label="Email Address"
							name="email"
							autoComplete="email"
							autoFocus
							helperText={errors.email}
							error={errors.email ? true : false}
							onChange={this.handleChange}
						/>
						<TextField
							variant="outlined"
							margin="normal"
							required
							fullWidth
							name="password"
							label="Password"
							type="password"
							id="password"
							autoComplete="current-password"
							helperText={errors.password}
							error={errors.password ? true : false}
							onChange={this.handleChange}
						/>
						<Button
							type="submit"
							fullWidth
							variant="contained"
							color="primary"
							className={classes.submit}
							onClick={this.handleSubmit}
							disabled={loading || !this.state.email || !this.state.password}
						>
							Sign In
							{loading && <CircularProgress size={30} className={classes.progess} />}
						</Button>
						<Grid container>
							<Grid item>
								<Link href="signup" variant="body2">
									{"Don't have an account? Sign Up"}
								</Link>
							</Grid>
						</Grid>
						{errors.general && (
							<Typography variant="body2" className={classes.customError}>
								{errors.general}
							</Typography>
						)}
					</form>
				</div>
			</Container>
		);
	}
}

No fim desse arquivo, adicione a seguinte exportação:

export default withStyles(styles)(login);

Adicione o URL das nossas funções do Firebase em view > package.json assim:

Lembre-se de adicionar uma chave chamada proxy abaixo do objeto de JSON browserslist
"proxy": "https://<nome-da-região>-todoapp-<id>.cloudfunctions.net/api"

Instale os pacotes Axios e Material Icons usando os seguintes comandos:

// Axios command:
npm i axios
// Material Icons:
npm install @material-ui/icons

Adicionamos a rota login em App.js. No arquivo login.js, criamos um componente de classe que lida com o estado e envia a requisição POST para a API de login usando o pacote Axios. Se a requisição for bem-sucedida, armazenamos o token. Se tivermos erros na resposta, simplesmente mostramos os erros na interface.

Vá até http://localhost:3000/login no navegador e você verá a seguinte interface de login.

LoginPage
Página de login

Tente preencher credenciais erradas ou enviar uma requisição vazia e você terá erros. Envie uma requisição válida. Vá até Developer console > Application. Você verá que o token dos usuários está armazenado no armazenamento local. Quando o login for feito com sucesso, vamos ser redirecionados para a página de início.

loginDev
Console de Desenvolvedor do Google Chrome

3. Formulário de registro:

Para desenvolver o formulário de registro, vá até App.js e atualize o componente Route existente com a linha abaixo:

// App.js

<Route exact path="/signup" component={signup}/>

Não se esqueça de importar:

// App.js

import signup from './pages/signup';

Crie um arquivo chamado signup.js no diretório pages.

Dentro de signup.js, vamos importar os pacotes Material UI e Axios:

// signup.js

import React, { Component } from 'react';
import Avatar from '@material-ui/core/Avatar';
import Button from '@material-ui/core/Button';
import CssBaseline from '@material-ui/core/CssBaseline';
import TextField from '@material-ui/core/TextField';
import Link from '@material-ui/core/Link';
import Grid from '@material-ui/core/Grid';
import LockOutlinedIcon from '@material-ui/icons/LockOutlined';
import Typography from '@material-ui/core/Typography';
import Container from '@material-ui/core/Container';
import withStyles from '@material-ui/core/styles/withStyles';
import CircularProgress from '@material-ui/core/CircularProgress';

import axios from 'axios';

Vamos adicionar a seguinte estilização em nossa página de registro:

// signup.js


const styles = (theme) => ({
	paper: {
		marginTop: theme.spacing(8),
		display: 'flex',
		flexDirection: 'column',
		alignItems: 'center'
	},
	avatar: {
		margin: theme.spacing(1),
		backgroundColor: theme.palette.secondary.main
	},
	form: {
		width: '100%', // Fix IE 11 issue.
		marginTop: theme.spacing(3)
	},
	submit: {
		margin: theme.spacing(3, 0, 2)
	},
	progess: {
		position: 'absolute'
	}
});

Vamos criar uma classe chamada signup, que possui um formulário e uma função que lida com o envio dele.

// signup.js

class signup extends Component {
	constructor(props) {
		super(props);

		this.state = {
			firstName: '',
			lastName: '',
			phoneNumber: '',
			country: '',
			username: '',
			email: '',
			password: '',
			confirmPassword: '',
			errors: [],
			loading: false
		};
	}

	componentWillReceiveProps(nextProps) {
		if (nextProps.UI.errors) {
			this.setState({
				errors: nextProps.UI.errors
			});
		}
	}

	handleChange = (event) => {
		this.setState({
			[event.target.name]: event.target.value
		});
	};

	handleSubmit = (event) => {
		event.preventDefault();
		this.setState({ loading: true });
		const newUserData = {
			firstName: this.state.firstName,
			lastName: this.state.lastName,
			phoneNumber: this.state.phoneNumber,
			country: this.state.country,
			username: this.state.username,
			email: this.state.email,
			password: this.state.password,
			confirmPassword: this.state.confirmPassword
		};
		axios
			.post('/signup', newUserData)
			.then((response) => {
				localStorage.setItem('AuthToken', `${response.data.token}`);
				this.setState({ 
					loading: false,
				});	
				this.props.history.push('/');
			})
			.catch((error) => {
				this.setState({
					errors: error.response.data,
					loading: false
				});
			});
	};

	render() {
		const { classes } = this.props;
		const { errors, loading } = this.state;
		return (
			<Container component="main" maxWidth="xs">
				<CssBaseline />
				<div className={classes.paper}>
					<Avatar className={classes.avatar}>
						<LockOutlinedIcon />
					</Avatar>
					<Typography component="h1" variant="h5">
						Sign up
					</Typography>
					<form className={classes.form} noValidate>
						<Grid container spacing={2}>
							<Grid item xs={12} sm={6}>
								<TextField
									variant="outlined"
									required
									fullWidth
									id="firstName"
									label="First Name"
									name="firstName"
									autoComplete="firstName"
									helperText={errors.firstName}
									error={errors.firstName ? true : false}
									onChange={this.handleChange}
								/>
							</Grid>
							<Grid item xs={12} sm={6}>
								<TextField
									variant="outlined"
									required
									fullWidth
									id="lastName"
									label="Last Name"
									name="lastName"
									autoComplete="lastName"
									helperText={errors.lastName}
									error={errors.lastName ? true : false}
									onChange={this.handleChange}
								/>
							</Grid>

							<Grid item xs={12} sm={6}>
								<TextField
									variant="outlined"
									required
									fullWidth
									id="username"
									label="User Name"
									name="username"
									autoComplete="username"
									helperText={errors.username}
									error={errors.username ? true : false}
									onChange={this.handleChange}
								/>
							</Grid>

							<Grid item xs={12} sm={6}>
								<TextField
									variant="outlined"
									required
									fullWidth
									id="phoneNumber"
									label="Phone Number"
									name="phoneNumber"
									autoComplete="phoneNumber"
									pattern="[7-9]{1}[0-9]{9}"
									helperText={errors.phoneNumber}
									error={errors.phoneNumber ? true : false}
									onChange={this.handleChange}
								/>
							</Grid>

							<Grid item xs={12}>
								<TextField
									variant="outlined"
									required
									fullWidth
									id="email"
									label="Email Address"
									name="email"
									autoComplete="email"
									helperText={errors.email}
									error={errors.email ? true : false}
									onChange={this.handleChange}
								/>
							</Grid>

							<Grid item xs={12}>
								<TextField
									variant="outlined"
									required
									fullWidth
									id="country"
									label="Country"
									name="country"
									autoComplete="country"
									helperText={errors.country}
									error={errors.country ? true : false}
									onChange={this.handleChange}
								/>
							</Grid>

							<Grid item xs={12}>
								<TextField
									variant="outlined"
									required
									fullWidth
									name="password"
									label="Password"
									type="password"
									id="password"
									autoComplete="current-password"
									helperText={errors.password}
									error={errors.password ? true : false}
									onChange={this.handleChange}
								/>
							</Grid>
							<Grid item xs={12}>
								<TextField
									variant="outlined"
									required
									fullWidth
									name="confirmPassword"
									label="Confirm Password"
									type="password"
									id="confirmPassword"
									autoComplete="current-password"
									onChange={this.handleChange}
								/>
							</Grid>
						</Grid>
						<Button
							type="submit"
							fullWidth
							variant="contained"
							color="primary"
							className={classes.submit}
							onClick={this.handleSubmit}
                            disabled={loading || 
                                !this.state.email || 
                                !this.state.password ||
                                !this.state.firstName || 
                                !this.state.lastName ||
                                !this.state.country || 
                                !this.state.username || 
                                !this.state.phoneNumber}
						>
							Sign Up
							{loading && <CircularProgress size={30} className={classes.progess} />}
						</Button>
						<Grid container justify="flex-end">
							<Grid item>
								<Link href="login" variant="body2">
									Already have an account? Sign in
								</Link>
							</Grid>
						</Grid>
					</form>
				</div>
			</Container>
		);
	}
}

No fim desse arquivo, adicione a seguinte exportação:

export default withStyles(styles)(signup);

A lógica para o componente Signup é a mesma do componente de login. Vá até http://localhost:3000/signup no navegador e você verá a interface de registro abaixo. Quando o registro for feito com sucesso, seremos redirecionados para a página inicial.

SignupPage
Formulário de registro

Tente preencher credenciais erradas ou enviar uma requisição vazia e você terá erros. Envie uma requisição válida. Vá até Developer console > Application. Você verá que o token dos usuários está armazenado no armazenamento local.

DevConsoleSignup
Console de Desenvolvedor do Chrome

4. Seção das contas:

Para construir a página de contas, vamos precisar primeiro criar nossa página inicial, que será onde carregaremos a seção da conta. Vá até App.js e atualize a seguinte rota:

// App.js

<Route exact path="/" component={home}/>

Não se esqueça de importar:

// App.js

import home from './pages/home';

Crie um arquivo chamado home.js. Esse arquivo será o índice da nossa aplicação. As seções sobre Contas e Afazeres serão carregadas com base nessa página no clique do botão.

Importe os pacotes do Material UI, o pacote Axios, nossa Conta personalizada, componentes de afazeres e middleware de autenticação.

// home.js

import React, { Component } from 'react';
import axios from 'axios';

import Account from '../components/account';
import Todo from '../components/todo';

import Drawer from '@material-ui/core/Drawer';
import AppBar from '@material-ui/core/AppBar';
import CssBaseline from '@material-ui/core/CssBaseline';
import Toolbar from '@material-ui/core/Toolbar';
import List from '@material-ui/core/List';
import Typography from '@material-ui/core/Typography';
import Divider from '@material-ui/core/Divider';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import withStyles from '@material-ui/core/styles/withStyles';
import AccountBoxIcon from '@material-ui/icons/AccountBox';
import NotesIcon from '@material-ui/icons/Notes';
import Avatar from '@material-ui/core/avatar';
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import CircularProgress from '@material-ui/core/CircularProgress';

import { authMiddleWare } from '../util/auth'

Vamos definir nosso drawerWidth assim:

const drawerWidth = 240;

Vamos adicionar a seguinte estilização na nossa página inicial:

const styles = (theme) => ({
	root: {
		display: 'flex'
	},
	appBar: {
		zIndex: theme.zIndex.drawer + 1
	},
	drawer: {
		width: drawerWidth,
		flexShrink: 0
	},
	drawerPaper: {
		width: drawerWidth
	},
	content: {
		flexGrow: 1,
		padding: theme.spacing(3)
	},
	avatar: {
		height: 110,
		width: 100,
		flexShrink: 0,
		flexGrow: 0,
		marginTop: 20
	},
	uiProgess: {
		position: 'fixed',
		zIndex: '1000',
		height: '31px',
		width: '31px',
		left: '50%',
		top: '35%'
	},
	toolbar: theme.mixins.toolbar
});

Vamos criar uma classe chamada home. Essa classe terá uma chamada de API para obter a foto de perfil do usuário, primeiro e último nome. Também terá a lógica para escolher qual componente mostrar, se o dos Afazeres ou o das Contas:

class home extends Component {
	state = {
		render: false
	};

	loadAccountPage = (event) => {
		this.setState({ render: true });
	};

	loadTodoPage = (event) => {
		this.setState({ render: false });
	};

	logoutHandler = (event) => {
		localStorage.removeItem('AuthToken');
		this.props.history.push('/login');
	};

	constructor(props) {
		super(props);

		this.state = {
			firstName: '',
			lastName: '',
			profilePicture: '',
			uiLoading: true,
			imageLoading: false
		};
	}

	componentWillMount = () => {
		authMiddleWare(this.props.history);
		const authToken = localStorage.getItem('AuthToken');
		axios.defaults.headers.common = { Authorization: `${authToken}` };
		axios
			.get('/user')
			.then((response) => {
				console.log(response.data);
				this.setState({
					firstName: response.data.userCredentials.firstName,
					lastName: response.data.userCredentials.lastName,
					email: response.data.userCredentials.email,
					phoneNumber: response.data.userCredentials.phoneNumber,
					country: response.data.userCredentials.country,
					username: response.data.userCredentials.username,
					uiLoading: false,
					profilePicture: response.data.userCredentials.imageUrl
				});
			})
			.catch((error) => {
				if(error.response.status === 403) {
					this.props.history.push('/login')
				}
				console.log(error);
				this.setState({ errorMsg: 'Error in retrieving the data' });
			});
	};

	render() {
		const { classes } = this.props;		
		if (this.state.uiLoading === true) {
			return (
				<div className={classes.root}>
					{this.state.uiLoading && <CircularProgress size={150} className={classes.uiProgess} />}
				</div>
			);
		} else {
			return (
				<div className={classes.root}>
					<CssBaseline />
					<AppBar position="fixed" className={classes.appBar}>
						<Toolbar>
							<Typography variant="h6" noWrap>
								TodoApp
							</Typography>
						</Toolbar>
					</AppBar>
					<Drawer
						className={classes.drawer}
						variant="permanent"
						classes={{
							paper: classes.drawerPaper
						}}
					>
						<div className={classes.toolbar} />
						<Divider />
						<center>
							<Avatar src={this.state.profilePicture} className={classes.avatar} />
							<p>
								{' '}
								{this.state.firstName} {this.state.lastName}
							</p>
						</center>
						<Divider />
						<List>
							<ListItem button key="Todo" onClick={this.loadTodoPage}>
								<ListItemIcon>
									{' '}
									<NotesIcon />{' '}
								</ListItemIcon>
								<ListItemText primary="Todo" />
							</ListItem>

							<ListItem button key="Account" onClick={this.loadAccountPage}>
								<ListItemIcon>
									{' '}
									<AccountBoxIcon />{' '}
								</ListItemIcon>
								<ListItemText primary="Account" />
							</ListItem>

							<ListItem button key="Logout" onClick={this.logoutHandler}>
								<ListItemIcon>
									{' '}
									<ExitToAppIcon />{' '}
								</ListItemIcon>
								<ListItemText primary="Logout" />
							</ListItem>
						</List>
					</Drawer>

					<div>{this.state.render ? <Account /> : <Todo />}</div>
				</div>
			);
		}
	}
}

Aqui no código, você verá que authMiddleWare(this.props.history); é usado. Esse middleware verifica se authToken é nulo. Se for, ele direcionará o usuário de volta para login.js. Isso foi adicionado para que nosso usuário não possa acessar a rota / sem se registrar ou realizar login. No fim desse arquivo, adicione a seguinte exportação:

export default withStyles(styles)(home);

Agora, você deve estar se perguntando o que este código em home.js faz?

<div>{this.state.render ? <Account /> : <Todo />}</div>

Ele está verificando o estado de renderização que estamos definindo no clique do botão. Vamos criar o diretório component e, nesse diretório, criaremos dois arquivos: account.js  e  todo.js.

Vamos criar um diretório chamado util e um arquivo chamado auth.js nesse diretório. Copie e cole o seguinte código em auth.js:

export const authMiddleWare = (history) => {
    const authToken = localStorage.getItem('AuthToken');
    if(authToken === null){
        history.push('/login')
    }
}

Por agora, dentro do arquivo todo.js, vamos apenas escrever uma classe que renderiza o texto Hello I am todo. Trabalharemos em nossos afazeres na próxima seção:

import React, { Component } from 'react'

import withStyles from '@material-ui/core/styles/withStyles';
import Typography from '@material-ui/core/Typography';

const styles = ((theme) => ({
    content: {
        flexGrow: 1,
        padding: theme.spacing(3),
    },
    toolbar: theme.mixins.toolbar,
    })
);

class todo extends Component {
    render() {
        const { classes } = this.props;
        return (
            <main className={classes.content}>
            <div className={classes.toolbar} />
            <Typography paragraph>
                Hello I am todo
            </Typography>
            </main>
        )
    }
}

export default (withStyles(styles)(todo));

Agora, é hora de fazer a seção das contas. Importe o Material UI, o clsx, o axios e o authmiddleWare em account.js.

// account.js

import React, { Component } from 'react';

import withStyles from '@material-ui/core/styles/withStyles';
import Typography from '@material-ui/core/Typography';
import CircularProgress from '@material-ui/core/CircularProgress';
import CloudUploadIcon from '@material-ui/icons/CloudUpload';
import { Card, CardActions, CardContent, Divider, Button, Grid, TextField } from '@material-ui/core';

import clsx from 'clsx';

import axios from 'axios';
import { authMiddleWare } from '../util/auth';

Vamos adicionar a seguinte estilização à nossa página de contas:

// account.js

const styles = (theme) => ({
	content: {
		flexGrow: 1,
		padding: theme.spacing(3)
	},
	toolbar: theme.mixins.toolbar,
	root: {},
	details: {
		display: 'flex'
	},
	avatar: {
		height: 110,
		width: 100,
		flexShrink: 0,
		flexGrow: 0
	},
	locationText: {
		paddingLeft: '15px'
	},
	buttonProperty: {
		position: 'absolute',
		top: '50%'
	},
	uiProgess: {
		position: 'fixed',
		zIndex: '1000',
		height: '31px',
		width: '31px',
		left: '50%',
		top: '35%'
	},
	progess: {
		position: 'absolute'
	},
	uploadButton: {
		marginLeft: '8px',
		margin: theme.spacing(1)
	},
	customError: {
		color: 'red',
		fontSize: '0.8rem',
		marginTop: 10
	},
	submitButton: {
		marginTop: '10px'
	}
});

Vamos criar um componente de classe chamado account. Por agora, apenas copie e cole o seguinte código:

// account.js

class account extends Component {
	constructor(props) {
		super(props);

		this.state = {
			firstName: '',
			lastName: '',
			email: '',
			phoneNumber: '',
			username: '',
			country: '',
			profilePicture: '',
			uiLoading: true,
			buttonLoading: false,
			imageError: ''
		};
	}

	componentWillMount = () => {
		authMiddleWare(this.props.history);
		const authToken = localStorage.getItem('AuthToken');
		axios.defaults.headers.common = { Authorization: `${authToken}` };
		axios
			.get('/user')
			.then((response) => {
				console.log(response.data);
				this.setState({
					firstName: response.data.userCredentials.firstName,
					lastName: response.data.userCredentials.lastName,
					email: response.data.userCredentials.email,
					phoneNumber: response.data.userCredentials.phoneNumber,
					country: response.data.userCredentials.country,
					username: response.data.userCredentials.username,
					uiLoading: false
				});
			})
			.catch((error) => {
				if (error.response.status === 403) {
					this.props.history.push('/login');
				}
				console.log(error);
				this.setState({ errorMsg: 'Error in retrieving the data' });
			});
	};

	handleChange = (event) => {
		this.setState({
			[event.target.name]: event.target.value
		});
	};

	handleImageChange = (event) => {
		this.setState({
			image: event.target.files[0]
		});
	};

	profilePictureHandler = (event) => {
		event.preventDefault();
		this.setState({
			uiLoading: true
		});
		authMiddleWare(this.props.history);
		const authToken = localStorage.getItem('AuthToken');
		let form_data = new FormData();
		form_data.append('image', this.state.image);
		form_data.append('content', this.state.content);
		axios.defaults.headers.common = { Authorization: `${authToken}` };
		axios
			.post('/user/image', form_data, {
				headers: {
					'content-type': 'multipart/form-data'
				}
			})
			.then(() => {
				window.location.reload();
			})
			.catch((error) => {
				if (error.response.status === 403) {
					this.props.history.push('/login');
				}
				console.log(error);
				this.setState({
					uiLoading: false,
					imageError: 'Error in posting the data'
				});
			});
	};

	updateFormValues = (event) => {
		event.preventDefault();
		this.setState({ buttonLoading: true });
		authMiddleWare(this.props.history);
		const authToken = localStorage.getItem('AuthToken');
		axios.defaults.headers.common = { Authorization: `${authToken}` };
		const formRequest = {
			firstName: this.state.firstName,
			lastName: this.state.lastName,
			country: this.state.country
		};
		axios
			.post('/user', formRequest)
			.then(() => {
				this.setState({ buttonLoading: false });
			})
			.catch((error) => {
				if (error.response.status === 403) {
					this.props.history.push('/login');
				}
				console.log(error);
				this.setState({
					buttonLoading: false
				});
			});
	};

	render() {
		const { classes, ...rest } = this.props;
		if (this.state.uiLoading === true) {
			return (
				<main className={classes.content}>
					<div className={classes.toolbar} />
					{this.state.uiLoading && <CircularProgress size={150} className={classes.uiProgess} />}
				</main>
			);
		} else {
			return (
				<main className={classes.content}>
					<div className={classes.toolbar} />
					<Card {...rest} className={clsx(classes.root, classes)}>
						<CardContent>
							<div className={classes.details}>
								<div>
									<Typography className={classes.locationText} gutterBottom variant="h4">
										{this.state.firstName} {this.state.lastName}
									</Typography>
									<Button
										variant="outlined"
										color="primary"
										type="submit"
										size="small"
										startIcon={<CloudUploadIcon />}
										className={classes.uploadButton}
										onClick={this.profilePictureHandler}
									>
										Upload Photo
									</Button>
									<input type="file" onChange={this.handleImageChange} />

									{this.state.imageError ? (
										<div className={classes.customError}>
											{' '}
											Wrong Image Format || Supported Format are PNG and JPG
										</div>
									) : (
										false
									)}
								</div>
							</div>
							<div className={classes.progress} />
						</CardContent>
						<Divider />
					</Card>

					<br />
					<Card {...rest} className={clsx(classes.root, classes)}>
						<form autoComplete="off" noValidate>
							<Divider />
							<CardContent>
								<Grid container spacing={3}>
									<Grid item md={6} xs={12}>
										<TextField
											fullWidth
											label="First name"
											margin="dense"
											name="firstName"
											variant="outlined"
											value={this.state.firstName}
											onChange={this.handleChange}
										/>
									</Grid>
									<Grid item md={6} xs={12}>
										<TextField
											fullWidth
											label="Last name"
											margin="dense"
											name="lastName"
											variant="outlined"
											value={this.state.lastName}
											onChange={this.handleChange}
										/>
									</Grid>
									<Grid item md={6} xs={12}>
										<TextField
											fullWidth
											label="Email"
											margin="dense"
											name="email"
											variant="outlined"
											disabled={true}
											value={this.state.email}
											onChange={this.handleChange}
										/>
									</Grid>
									<Grid item md={6} xs={12}>
										<TextField
											fullWidth
											label="Phone Number"
											margin="dense"
											name="phone"
											type="number"
											variant="outlined"
											disabled={true}
											value={this.state.phoneNumber}
											onChange={this.handleChange}
										/>
									</Grid>
									<Grid item md={6} xs={12}>
										<TextField
											fullWidth
											label="User Name"
											margin="dense"
											name="userHandle"
											disabled={true}
											variant="outlined"
											value={this.state.username}
											onChange={this.handleChange}
										/>
									</Grid>
									<Grid item md={6} xs={12}>
										<TextField
											fullWidth
											label="Country"
											margin="dense"
											name="country"
											variant="outlined"
											value={this.state.country}
											onChange={this.handleChange}
										/>
									</Grid>
								</Grid>
							</CardContent>
							<Divider />
							<CardActions />
						</form>
					</Card>
					<Button
						color="primary"
						variant="contained"
						type="submit"
						className={classes.submitButton}
						onClick={this.updateFormValues}
						disabled={
							this.state.buttonLoading ||
							!this.state.firstName ||
							!this.state.lastName ||
							!this.state.country
						}
					>
						Save details
						{this.state.buttonLoading && <CircularProgress size={30} className={classes.progess} />}
					</Button>
				</main>
			);
		}
	}
}

No fim desse arquivo, adicione a seguinte exportação:

export default withStyles(styles)(account);

Em account.js, há vários componentes sendo usados. Vamos ver como está nossa aplicação. Depois disso, vamos explicar todos os componentes que estão sendo usados e o motivo de estarem sendo usados.

Vá até o navegador. Se o token expirou, ele redirecionará você para a página de login. Adicione seus detalhes e faça login novamente. Quando fizer isso, vá até a aba Account e você verá a seguinte interface:

image-88
Seção da conta

Há 3 funções que lidam com questões diferentes na seção das contas:

  1. componentWillMount: este é o método incorporado ao ciclo de vida do React. Estamos usando esse método para carregar os dados antes do ciclo de vida de renderização e para atualizar nossos valores do estado.
  2. ProfilePictureUpdate: esta é nossa função personalizada que estamos usando para lidar com o clique do usuário no botão de carregar foto. Ela enviará os dados para um servidor e recarregará a página para mostrar a nova foto de perfil do usuário.
  3. updateFormValues: esta também é nossa função personalizada que lida com a atualização dos detalhes do usuário. Aqui, o usuário pode atualizar seu primeiro nome, sobrenome e país. Não estamos permitindo a atualização de e-mail e nome de usuário, pois nossa lógica do back-end depende dessas chaves.

Além dessas três funções, há uma página de formulário estilizada. Aqui está a estrutura do diretório até aqui dentro da pasta view:

+-- public 
+-- src
|   +-- components
|   +-- +-- todo.js
|   +-- +-- account.js
|   +-- pages
|   +-- +-- home.js
|   +-- +-- login.js
|   +-- +-- signup.js
|   +-- util
|   +-- +-- auth.js 
|   +-- README.md
|   +-- package-lock.json
|   +-- package.json
|   +-- .gitignore

Com isso, completamos nosso painel de contas. Agora tome um café, faça uma pausa e, na próxima seção, vamos criar o painel de afazeres.

Seção 4: painel de afazeres

Nesta seção, vamos desenvolver a interface para essas funcionalidades do painel de afazeres:

  1. Adicionar um afazer
  2. Obter todos os afazeres
  3. Excluir um afazer
  4. Editar um afazer
  5. Obter um afazer
  6. Aplicar o tema

O código do painel de afazeres implementado nesta seção pode ser encontrado neste commit.

Vá até todos.js no diretório components. Adicione as seguintes importações às importações existentes:

import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import AddCircleIcon from '@material-ui/icons/AddCircle';
import AppBar from '@material-ui/core/AppBar';
import Toolbar from '@material-ui/core/Toolbar';
import IconButton from '@material-ui/core/IconButton';
import CloseIcon from '@material-ui/icons/Close';
import Slide from '@material-ui/core/Slide';
import TextField from '@material-ui/core/TextField';
import Grid from '@material-ui/core/Grid';
import Card from '@material-ui/core/Card';
import CardActions from '@material-ui/core/CardActions';
import CircularProgress from '@material-ui/core/CircularProgress';
import CardContent from '@material-ui/core/CardContent';
import MuiDialogTitle from '@material-ui/core/DialogTitle';
import MuiDialogContent from '@material-ui/core/DialogContent';

import axios from 'axios';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { authMiddleWare } from '../util/auth';

Precisamos, também, adicionar os seguintes elementos do CSS nos componentes de estilo abaixo:

const styles = (theme) => ({
	.., // Existing CSS elements
	title: {
		marginLeft: theme.spacing(2),
		flex: 1
	},
	submitButton: {
		display: 'block',
		color: 'white',
		textAlign: 'center',
		position: 'absolute',
		top: 14,
		right: 10
	},
	floatingButton: {
		position: 'fixed',
		bottom: 0,
		right: 0
	},
	form: {
		width: '98%',
		marginLeft: 13,
		marginTop: theme.spacing(3)
	},
	toolbar: theme.mixins.toolbar,
	root: {
		minWidth: 470
	},
	bullet: {
		display: 'inline-block',
		margin: '0 2px',
		transform: 'scale(0.8)'
	},
	pos: {
		marginBottom: 12
	},
	uiProgess: {
		position: 'fixed',
		zIndex: '1000',
		height: '31px',
		width: '31px',
		left: '50%',
		top: '35%'
	},
	dialogeStyle: {
		maxWidth: '50%'
	},
	viewRoot: {
		margin: 0,
		padding: theme.spacing(2)
	},
	closeButton: {
		position: 'absolute',
		right: theme.spacing(1),
		top: theme.spacing(1),
		color: theme.palette.grey[500]
	}
});

Vamos adicionar a transição para o pop-up da caixa de diálogo:

const Transition = React.forwardRef(function Transition(props, ref) {
	return <Slide direction="up" ref={ref} {...props} />;
});

Remova a classe todo abaixo e copie e cole a seguinte classe:

class todo extends Component {
	constructor(props) {
		super(props);

		this.state = {
			todos: '',
			title: '',
			body: '',
			todoId: '',
			errors: [],
			open: false,
			uiLoading: true,
			buttonType: '',
			viewOpen: false
		};

		this.deleteTodoHandler = this.deleteTodoHandler.bind(this);
		this.handleEditClickOpen = this.handleEditClickOpen.bind(this);
		this.handleViewOpen = this.handleViewOpen.bind(this);
	}

	handleChange = (event) => {
		this.setState({
			[event.target.name]: event.target.value
		});
	};

	componentWillMount = () => {
		authMiddleWare(this.props.history);
		const authToken = localStorage.getItem('AuthToken');
		axios.defaults.headers.common = { Authorization: `${authToken}` };
		axios
			.get('/todos')
			.then((response) => {
				this.setState({
					todos: response.data,
					uiLoading: false
				});
			})
			.catch((err) => {
				console.log(err);
			});
	};

	deleteTodoHandler(data) {
		authMiddleWare(this.props.history);
		const authToken = localStorage.getItem('AuthToken');
		axios.defaults.headers.common = { Authorization: `${authToken}` };
		let todoId = data.todo.todoId;
		axios
			.delete(`todo/${todoId}`)
			.then(() => {
				window.location.reload();
			})
			.catch((err) => {
				console.log(err);
			});
	}

	handleEditClickOpen(data) {
		this.setState({
			title: data.todo.title,
			body: data.todo.body,
			todoId: data.todo.todoId,
			buttonType: 'Edit',
			open: true
		});
	}

	handleViewOpen(data) {
		this.setState({
			title: data.todo.title,
			body: data.todo.body,
			viewOpen: true
		});
	}

	render() {
		const DialogTitle = withStyles(styles)((props) => {
			const { children, classes, onClose, ...other } = props;
			return (
				<MuiDialogTitle disableTypography className={classes.root} {...other}>
					<Typography variant="h6">{children}</Typography>
					{onClose ? (
						<IconButton aria-label="close" className={classes.closeButton} onClick={onClose}>
							<CloseIcon />
						</IconButton>
					) : null}
				</MuiDialogTitle>
			);
		});

		const DialogContent = withStyles((theme) => ({
			viewRoot: {
				padding: theme.spacing(2)
			}
		}))(MuiDialogContent);

		dayjs.extend(relativeTime);
		const { classes } = this.props;
		const { open, errors, viewOpen } = this.state;

		const handleClickOpen = () => {
			this.setState({
				todoId: '',
				title: '',
				body: '',
				buttonType: '',
				open: true
			});
		};

		const handleSubmit = (event) => {
			authMiddleWare(this.props.history);
			event.preventDefault();
			const userTodo = {
				title: this.state.title,
				body: this.state.body
			};
			let options = {};
			if (this.state.buttonType === 'Edit') {
				options = {
					url: `/todo/${this.state.todoId}`,
					method: 'put',
					data: userTodo
				};
			} else {
				options = {
					url: '/todo',
					method: 'post',
					data: userTodo
				};
			}
			const authToken = localStorage.getItem('AuthToken');
			axios.defaults.headers.common = { Authorization: `${authToken}` };
			axios(options)
				.then(() => {
					this.setState({ open: false });
					window.location.reload();
				})
				.catch((error) => {
					this.setState({ open: true, errors: error.response.data });
					console.log(error);
				});
		};

		const handleViewClose = () => {
			this.setState({ viewOpen: false });
		};

		const handleClose = (event) => {
			this.setState({ open: false });
		};

		if (this.state.uiLoading === true) {
			return (
				<main className={classes.content}>
					<div className={classes.toolbar} />
					{this.state.uiLoading && <CircularProgress size={150} className={classes.uiProgess} />}
				</main>
			);
		} else {
			return (
				<main className={classes.content}>
					<div className={classes.toolbar} />

					<IconButton
						className={classes.floatingButton}
						color="primary"
						aria-label="Add Todo"
						onClick={handleClickOpen}
					>
						<AddCircleIcon style={{ fontSize: 60 }} />
					</IconButton>
					<Dialog fullScreen open={open} onClose={handleClose} TransitionComponent={Transition}>
						<AppBar className={classes.appBar}>
							<Toolbar>
								<IconButton edge="start" color="inherit" onClick={handleClose} aria-label="close">
									<CloseIcon />
								</IconButton>
								<Typography variant="h6" className={classes.title}>
									{this.state.buttonType === 'Edit' ? 'Edit Todo' : 'Create a new Todo'}
								</Typography>
								<Button
									autoFocus
									color="inherit"
									onClick={handleSubmit}
									className={classes.submitButton}
								>
									{this.state.buttonType === 'Edit' ? 'Save' : 'Submit'}
								</Button>
							</Toolbar>
						</AppBar>

						<form className={classes.form} noValidate>
							<Grid container spacing={2}>
								<Grid item xs={12}>
									<TextField
										variant="outlined"
										required
										fullWidth
										id="todoTitle"
										label="Todo Title"
										name="title"
										autoComplete="todoTitle"
										helperText={errors.title}
										value={this.state.title}
										error={errors.title ? true : false}
										onChange={this.handleChange}
									/>
								</Grid>
								<Grid item xs={12}>
									<TextField
										variant="outlined"
										required
										fullWidth
										id="todoDetails"
										label="Todo Details"
										name="body"
										autoComplete="todoDetails"
										multiline
										rows={25}
										rowsMax={25}
										helperText={errors.body}
										error={errors.body ? true : false}
										onChange={this.handleChange}
										value={this.state.body}
									/>
								</Grid>
							</Grid>
						</form>
					</Dialog>

					<Grid container spacing={2}>
						{this.state.todos.map((todo) => (
							<Grid item xs={12} sm={6}>
								<Card className={classes.root} variant="outlined">
									<CardContent>
										<Typography variant="h5" component="h2">
											{todo.title}
										</Typography>
										<Typography className={classes.pos} color="textSecondary">
											{dayjs(todo.createdAt).fromNow()}
										</Typography>
										<Typography variant="body2" component="p">
											{`${todo.body.substring(0, 65)}`}
										</Typography>
									</CardContent>
									<CardActions>
										<Button size="small" color="primary" onClick={() => this.handleViewOpen({ todo })}>
											{' '}
											View{' '}
										</Button>
										<Button size="small" color="primary" onClick={() => this.handleEditClickOpen({ todo })}>
											Edit
										</Button>
										<Button size="small" color="primary" onClick={() => this.deleteTodoHandler({ todo })}>
											Delete
										</Button>
									</CardActions>
								</Card>
							</Grid>
						))}
					</Grid>

					<Dialog
						onClose={handleViewClose}
						aria-labelledby="customized-dialog-title"
						open={viewOpen}
						fullWidth
						classes={{ paperFullWidth: classes.dialogeStyle }}
					>
						<DialogTitle id="customized-dialog-title" onClose={handleViewClose}>
							{this.state.title}
						</DialogTitle>
						<DialogContent dividers>
							<TextField
								fullWidth
								id="todoDetails"
								name="body"
								multiline
								readonly
								rows={1}
								rowsMax={25}
								value={this.state.body}
								InputProps={{
									disableUnderline: true
								}}
							/>
						</DialogContent>
					</Dialog>
				</main>
			);
		}
	}
}

No fim desse arquivo, adicione a seguinte exportação:

export default withStyles(styles)(todo);

Primeiro, vamos entender como nossa interface funciona e, depois disso, vamos entender o código. Vá até o navegador e você verá a seguinte interface:

TodoDashboard
Painel de afazeres

Clique no botão Add, no canto inferior direito, e verá a seguinte tela:

AddTodo
Adicionar afazer

Adicione o título do afazer e os detalhes. Depois, pressione o botão de envio. Você verá a seguinte tela:

Added-Todo
Painel de afazeres

Depois desse clique no botão de visualização, você poderá ver os detalhes completos do afazer:

View-Todo
Ver afazer único

Clique no botão Edit e você poderá editar o afazer:

EditTodo
Editar afazer

Clique no botão de exclusão e você poderá excluir o afazer. Agora que sabemos como o painel funciona, entenderemos os componentes usados.

1. Adicionar afazer: para adicionar um afazer, usaremos o componente Dialogue do Material UI. Esse componente implementa a funcionalidade hook. Usamos classes. Por isso, removeremos essa funcionalidade.

// This sets the state to open and buttonType flag to add:
const handleClickOpen = () => {
      this.setState({
           todoId: '',
           title: '',
           body: '',
           buttonType: '',
           open: true
     });
};

// This sets the state to close:
const handleClose = (event) => {
      this.setState({ open: false });
};

Além disso, também mudaremos a posição do botão de adicionar afazeres.

// Position our button
floatingButton: {
    position: 'fixed',
    bottom: 0,
    right: 0
},

<IconButton className={classes.floatingButton} ... >

Agora, substituiremos a tag list por form dentro deste Dialogue. Isso nos ajudará a adicionar o novo afazer.

// Show Edit or Save depending on buttonType state
{this.state.buttonType === 'Edit' ? 'Save' : 'Submit'}

// Our Form to add a todo
<form className={classes.form} noValidate>
	<Grid container spacing={2}>
		<Grid item xs={12}>
        // TextField here
        </Grid>
        <Grid item xs={12}>
        // TextField here
        </Grid>
    </Grid>
</form>

A função handleSubmit consiste de lógica para realizar a leitura do estado buttonType. Se o estado for uma string vazia (""), realizará um post na API de adicionar afazer. Se o estado for um Edit, realizará um update na API editar afazer.

2. Obter afazeres: para mostrar os afazeres vamos usar o Grid container e, dentro dele, colocaremos o Grid item. Dentro desse item, vamos usar um componente Card para mostrar os dados.

<Grid container spacing={2}>
    {this.state.todos.map((todo) => (
	<Grid item xs={12} sm={6}>
	<Card className={classes.root} variant="outlined">
	    <CardContent>
        // Here will show Todo with view, edit and delete button
        </CardContent>
    </Card>
    </Grid>))}
</Grid>

Usamos a função map para mostrar um afazer de cada vez, à medida que a API envia os afazeres como uma lista. Vamos usar a função de ciclo de vida componentWillMount para obter e enviar o estado antes da renderização ser executada. Há três botões (view, edit e delete). Vamos precisar de três funções para lidar com a operação quando o botão for clicado. Vamos aprender sobre esses botões nas respectivas subseções.

3. Editar afazer: para editar o afazer, estamos reutilizando o código de pop-up de diálogo que está sendo usado para adicionar afazeres. Para diferenciar entre os cliques dos botões, usaremos um estado buttonType. Para adicionar um afazer, o estado buttonType é (""). Para editar um afazer, ele é Edit.

handleEditClickOpen(data) {
	this.setState({
		..,
		buttonType: 'Edit',
		..
	});
}

No método handleSubmit, lemos o estado buttonType e enviamos a requisição de acordo.

4. Excluir afazer: quando esse botão é clicado, enviamos o afazer como objeto para nosso deleteTodoHandler. Então, ele envia a requisição para o back-end.

<Button size="small" onClick={() => this.deleteTodoHandler({ todo })}>Delete</Button>

5. Visualizar afazer: quando mostramos os dados, mostramos apenas uma parte deles. Assim, o usuário terá uma ideia sobre o assunto do afazer. Se, no entanto, o usuário quiser saber mais sobre ele, é preciso clicar no botão de visualizar.

Para isso, vamos usar o diálogo customizado. Dentro dele, usamos DialogTitle e DialogContent. Isso mostra nosso título e conteúdo. No DialougeContent, vamos usar o formulário para mostrar o conteúdo que o usuário postou (essa é uma solução que encontrei – há muitas e você pode tentar a que quiser).

// This is used to remove the underline of the Form
InputProps={{
       disableUnderline: true
}}

// This is used so that user cannot edit the data
readonly

6. Aplicar o tema: este é o último passo da nossa aplicação. Vamos aplicar um tema a ela. Para isso, usaremos createMuiTheme e ThemeProvider, do Material UI. Copie e cole o seguinte código em App.js:

import { ThemeProvider as MuiThemeProvider } from '@material-ui/core/styles';
import createMuiTheme from '@material-ui/core/styles/createMuiTheme';

const theme = createMuiTheme({
	palette: {
		primary: {
			light: '#33c9dc',
			main: '#FF5722',
			dark: '#d50000',
			contrastText: '#fff'
		}
	}
});

function App() {
	return (
        <MuiThemeProvider theme={theme}>
        // Router and switch will be here.
        </MuiThemeProvider>
    );
}

Esquecemos de aplicar um tema para nosso botão de todo.js, em CardActions. Adicione a tag color aos botões de visualização, edição e exclusão.

<Button size="small" color="primary" ...>

Vá até o navegador e você verá que tudo está igual. A única diferença é a cor da aplicação.

FinalTodo
TodoApp depois de aplicar o tema

Assim, chegamos ao final do projeto! Criamos uma aplicação de afazeres (TodoApp) usando o ReactJS e o Firebase. Se chegou até aqui, meus parabéns por ter conseguido.

Fique à vontade para se conectar com o autor pelo Twitter e pelo Github.