Todolist no React Hooks + TypeScript: da construção ao teste

A partir da versão 16.9, uma nova funcionalidade está disponível na biblioteca React JS - ganchos . Eles tornam possível usar o estado e outras funções React, livrando você de ter que escrever uma classe. Usar componentes funcionais em conjunto com ganchos permite desenvolver um aplicativo cliente completo.



Vamos considerar a criação de uma versão Todolist de um aplicativo React Hooks usando TypeScript .



Montagem



A estrutura do projeto é a seguinte:



├── src

| ├── componentes

| ├── index.html

| ├── index.tsx

├── package.json

├── tsconfig.json

├── webpack.config.json



Arquivo Package.json:
{
  "name": "todo-react-typescript",
  "version": "1.0.0",
  "description": "",
  "main": "index.tsx",
  "scripts": {
    "start": "webpack-dev-server --port 3000 --mode development --open --hot",
    "build": "webpack --mode production"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "ts-loader": "^5.2.1",
    "html-webpack-plugin": "^3.2.0",
    "typescript": "^3.8.2",
    "webpack": "^4.41.6",
    "webpack-cli": "^3.3.11",
    "webpack-dev-server": "^3.10.3"
  },
  "dependencies": {
    "@types/react": "^16.9.23",
    "@types/react-dom": "^16.9.5",
    "react": "^16.12.0",
    "react-dom": "^16.12.0"
  }
}


TypeScript, typescript, ts-loader, tsx- js-, React — @types/react @types/react-dom. html-webpack-plugin, dev- index.html — , production- .



Arquivo Tsconfig.json:
{
  "compilerOptions": {
    "sourceMap": true,
    "noImplicitAny": false,
    "module": "commonjs",
    "target": "es6",
    "lib": [
      "es2015",
      "es2017",
      "dom"
    ],
    "removeComments": true,
    "allowSyntheticDefaultImports": false,
    "jsx": "react",
    "allowJs": true,
    "baseUrl": "./",
    "paths": {
      "components/*": [
        "src/components/*"
      ]
    }
  }
}


«jsx» . 3 : «preserve», «react» «react-native».







Arquivo Webpack.config.json:
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
    entry: './src/index.tsx',
    resolve: {
        extensions: ['.ts', '.tsx', '.js']
    },
    output: {
        path: path.join(__dirname, '/dist'),
        filename: 'bundle.min.js'
    },
    module: {
        rules: [
            {
                test: /\.ts(x?)$/,
                exclude: /node_modules/,
                use: [
                    {
                        loader: "ts-loader"
                    }
                ]
            }
        ]
    },
    plugins: [
        new HtmlWebpackPlugin({
            template: './src/index.html'
        })
    ]
};


— ./src/index.tsx. resolve.extensions ts/tsx/js . ts-loader html-webpack-plugin. .



Desenvolvimento de



No arquivo index.html, registramos o contêiner onde o aplicativo será renderizado:



<div id="root"></div>


No diretório de componentes, crie nosso primeiro componente vazio, App.tsx.

Arquivo index.tsx:



import * as React from 'react';
import * as ReactDOM from 'react-dom';

import App from "./components/App";

ReactDOM.render (
    <App/>,
    document.getElementById("root")
);


O aplicativo Todolist terá a seguinte funcionalidade:



  • adicionar tarefa
  • deletar tarefa
  • alterar o status da tarefa (concluído / não concluído)


Será parecido com isto: um campo de texto para entrada + o botão Adicionar tarefa, e abaixo está uma lista de tarefas adicionadas. Você pode excluir tarefas e alterar seu status.







Para esses fins, você pode dividir o aplicativo em apenas dois componentes - criando uma nova tarefa e uma lista de todas as tarefas. Portanto, App.tsx no estágio inicial será semelhante a este:



import * as React from 'react';
import NewTask from "./NewTask";
import TasksList from "./TasksList";

const App = () => {

    return (
        <>
            <NewTask />
            <TasksList />
        </>
    )
}

export default App;


No diretório atual, crie e exporte os componentes NewTask e TasksList vazios. Como precisamos garantir o relacionamento entre eles, precisamos determinar como isso acontecerá. Existem duas abordagens para a comunicação entre os componentes no React:



  1. Armazenar o estado atual do aplicativo e todos os seus métodos no componente pai (em nosso caso, em App.tsx) e transmiti-lo aos componentes filhos por meio de props (da maneira clássica);
  2. Manter os métodos de gerenciamento de estado e estado separados. Nesse caso, o aplicativo precisa ser empacotado com um componente especial - um provedor, e os métodos e propriedades necessários para os componentes filhos devem ser passados ​​para ele (usando o gancho useContext).


Usaremos o segundo método e, neste exemplo, descartaremos completamente os adereços.



TypeScript ao passar adereços
* , TypeScript :



const NewTask: React.FC<MyProps> = ({taskName}) => {...


React.FC, , ( ) :



interface MyProps {
    taskName: String;
}




useContext



Portanto, para transferir o estado, usaremos o gancho useContext. Ele permite que você obtenha e modifique dados em qualquer um dos componentes agrupados pelo provedor.



Exemplo UseContext
import * as React from 'react';
import {useContext} from "react";

interface Person {
    name: String,
    surname: String
}

export const PersonContext = React.createContext<Partial<Person>>({});

const PersonWrapper = () => {

    const person: Person = {
        name: 'Spider',
        surname: 'Man'
    }

    return (
        <>
            <PersonContext.Provider value={ person }>
                <PersonComponent />
            </PersonContext.Provider>
        </>
    )
}

const PersonComponent = () => {
    const person = useContext(PersonContext);
    return (
        <div>
            Hello, {person.name} {person.surname}!
        </div>
    )
}

export default PersonWrapper;


— name surname, String.



createContext . , TypeScript « » , Partial — .



— person, . , . useContext.



useReducer



Você também precisará de useReducer para um trabalho mais conveniente com o armazenamento de estado.



Mais sobre useReducer
useReducer , : , type, — payload. :



import * as React from 'react';
import {useReducer} from "react";

interface PersonState {
    name: String,
    surname: String
}

interface PersonAction {
    type: 'CHANGE',
    payload: PersonState
}


const personReducer = (state: PersonState, action: PersonAction): PersonState => {
    switch (action.type) {
        case 'CHANGE':
            return action.payload;
        default: throw new Error('Unexpected action');
    }
}


const PersonComponent = () => {

    const initialState = {
        name: 'Unknown',
        surname: 'Guest'
    }

    const [person, changePerson] = useReducer<React.Reducer<PersonState, PersonAction>>(personReducer, initialState);

    return (
        <div onClick={() => changePerson({type: 'CHANGE', payload: {name: 'Jackie', surname: 'Chan'}})}>
            Hello, {person.name} {person.surname}!
        </div>
    )
}

export default PersonComponent;


useReducer - personReducer, changePerson.



person initialState, changePerson .



CHANGE, , :



case 'CHANGE':
   return action.payload;
case 'CLEAR':
   return {
      name: 'Undefined',
      surname: 'Undefined'
   };




useContext + useReducer



Uma substituição interessante para a biblioteca Redux pode ser o uso de contexto em conjunto com useReducer. Nesse caso, o resultado do gancho useReducer - o estado retornado e a função para atualizá-lo - será passado para o contexto. Vamos adicionar estes ganchos ao aplicativo:



import * as React from 'react';

import {useReducer} from "react";
import {Action, State, ContextState} from "../types/stateType";
import NewTask from "./NewTask";
import TasksList from "./TasksList";

//   
export const initialState: State = {
    newTask: '',
    tasks: []
}

// <Partial>      
export const ContextApp = React.createContext<Partial<ContextState>>({});

//  ,        Action   type  payload,     - State
export const todoReducer = (state: State, action: Action):State => {
    switch (action.type) {
        case ActionType.ADD: {
            return {...state, tasks: [...state.tasks, {
                    name: action.payload,
                    isDone: false
                }]}
        }
        case ActionType.CHANGE: {
            return {...state, newTask: action.payload}
        }
        case ActionType.REMOVE: {
            return {...state, tasks:  [...state.tasks.filter(task => task !== action.payload)]}
        }
        case ActionType.TOGGLE: {
            return {...state, tasks: [...state.tasks.map((task) => (task !== action.payload ? task : {...task, isDone: !task.isDone}))]}
        }
        default: throw new Error('Unexpected action');
    }
};

const App:  React.FC = () => {
//   todoReducer,     useReduser.     initialState,     (changeState)  .
    const [state, changeState] = useReducer<React.Reducer<State, Action>>(todoReducer, initialState);

    const ContextState: ContextState = {
        state,
        changeState
    };

//      useReducer -     
    return (
        <>
            <ContextApp.Provider value={ContextState}>
                <NewTask />
                <TasksList />
            </ContextApp.Provider>
        </>
    )
}

export default App;


Como resultado, conseguimos fazer um estado independente do componente raiz, que pode ser recebido e alterado em componentes dentro do provedor.



Datilografado. Adicionando tipos ao aplicativo



No arquivo stateType, escrevemos os tipos TypeScript para o aplicativo:



import {Dispatch} from "react";

//       
export type Task = {
    name: string;
    isDone: boolean
}

export type Tasks = Task[];

//        ,      
export type State = {
    newTask: string;
    tasks: Tasks
}

//      
export enum ActionType {
    ADD = 'ADD',
    CHANGE = 'CHANGE',
    REMOVE = 'REMOVE',
    TOGGLE = 'TOGGLE'
}

//   ADD  CHANGE     
type ActionStringPayload = {
    type: ActionType.ADD | ActionType.CHANGE,
    payload: string
}

//   TOGGLE  REMOVE      Task
type ActionObjectPayload = {
    type: ActionType.TOGGLE | ActionType.REMOVE,
    payload: Task
}

//        
export type Action = ActionStringPayload | ActionObjectPayload;

//       -,     Action.  Dispatch    react
export type ContextState = {
    state: State;
    changeState: Dispatch<Action>
}


Usando contexto



Agora o state está pronto e pode ser usado em componentes. Vamos começar com NewTask.tsx:



import * as React from 'react';

import {useContext} from "react";
import {ContextApp} from "./App";

import {TaskName} from "../types/taskType";
import {ActionType} from "../types/stateType";

const NewTask: React.FC = () => {
//  state  dispatch-
    const {state, changeState} = useContext(ContextApp);

//     todoReducer -     .      state .         React-
    const addTask = (event: React.FormEvent<HTMLFormElement>, task: TaskName) => {
        event.preventDefault();
        changeState({type: ActionType.ADD, payload: task})
        changeState({type: ActionType.CHANGE, payload: ''})
    }

//  -     
    const changeTask = (event: React.ChangeEvent<HTMLInputElement>) => {
        changeState({type: ActionType.CHANGE, payload: event.target.value})
    }

    return (
        <>
            <form onSubmit={(event)=>addTask(event, state.newTask)}>
                <input type='text' onChange={(event)=>changeTask(event)} value={state.newTask}/>
                <button type="submit">Add a task</button>
            </form>
        </>
    )
};

export default NewTask;


TasksList.tsx:



import * as React from 'react';

import {Task} from "../types/taskType";
import {ActionType} from "../types/stateType";
import {useContext} from "react";
import {ContextApp} from "./App";

const TasksList: React.FC = () => {
//     ( changeState)
    const {state, changeState} = useContext(ContextApp);

    const removeTask = (taskForRemoving: Task) => {
        changeState({type: ActionType.REMOVE, payload: taskForRemoving})
    }
    const toggleReadiness = (taskForChange: Task) => {
        changeState({type: ActionType.TOGGLE, payload: taskForChange})
    }

    return (
        <>
            <ul>
                {state.tasks.map((task,i)=>(
                    <li key={i} className={task.isDone ? 'ready' : null}>
                        <label>
                            <input type="checkbox" onChange={()=>toggleReadiness(task)} checked={task.isDone}/>
                        </label>
                        <div className="task-name">
                            {task.name}
                        </div>
                        <button className='remove-button' onClick={()=>removeTask(task)}>
                            X
                        </button>
                    </li>
                ))}
            </ul>
        </>
    )
};

export default TasksList;


O aplicativo está pronto! Resta testá-lo.



Testando



Para o teste, Jest + Enzyme será usado , bem como @ testing-library / react .

Você precisa instalar as dependências de desenvolvimento:



"@testing-library/react": "^10.4.3",
"@testing-library/react-hooks": "^3.3.0",
"@types/enzyme": "^3.10.5",
"@types/jest": "^24.9.1",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"enzyme-to-json": "^3.3.4",
"jest": "^26.1.0",
"ts-jest": "^26.1.1",


Adicione configurações de jest a package.json:



 "jest": {
    "preset": "ts-jest",
    "setupFiles": [
      "./src/__tests__/setup.ts"
    ],
    "snapshotSerializers": [
      "enzyme-to-json/serializer"
    ],
    "testRegex": "/__tests__/.*\\.test.(ts|tsx)$",
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "jsx",
      "json",
      "node"
    ]
  },


e no bloco "scripts", adicione um script para a execução de testes:



"test": "jest"


Crie um novo diretório __tests__ no diretório src e nele um arquivo setup.ts com o seguinte conteúdo:



import {configure} from 'enzyme';
import * as ReactSixteenAdapter from 'enzyme-adapter-react-16';
const adapter = ReactSixteenAdapter as any;

configure({ adapter: new adapter() });


Vamos criar um arquivo todoReducer.test.ts, no qual testaremos o redutor:



import {todoReducer} from "../reducers/todoReducer";
import {ActionType, Action, State} from "../types/stateType";
import {Task} from "../types/taskType";

describe('todoReducer',()=>{
    it('returns new state for "ADD" type', () => {
//      
        const initialState: State = {newTask: '', tasks: []};
//   'ADD'      'new task'
        const updateAction: Action = {type: ActionType.ADD, payload: 'new task'};
//       
        const updatedState = todoReducer(initialState, updateAction);
//      
        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: false}]});
    });

    it('returns new state for "REMOVE" type', () => {
        const task: Task = {name: 'new task', isDone: false}
        const initialState: State = {newTask: '', tasks: [task]};
        const updateAction: Action = {type: ActionType.REMOVE, payload: task};
        const updatedState = todoReducer(initialState, updateAction);
        expect(updatedState).toEqual({newTask: '', tasks: []});
    });

    it('returns new state for "TOGGLE" type', () => {
        const task: Task = {name: 'new task', isDone: false}
        const initialState: State = {newTask: '', tasks: [task]};
        const updateAction: Action = {type: ActionType.TOGGLE, payload: task};
        const updatedState = todoReducer(initialState, updateAction);
        expect(updatedState).toEqual({newTask: '', tasks: [{name: 'new task', isDone: true}]});
    });

    it('returns new state for "CHANGE" type', () => {
        const initialState: State = {newTask: '', tasks: []};
        const updateAction: Action = {type: ActionType.CHANGE, payload: 'new task'};
        const updatedState = todoReducer(initialState, updateAction);
        expect(updatedState).toEqual({newTask: 'new task', tasks: []});
    });
})


Para testar o redutor, basta passar para ele o estado e a ação atuais e, então, pegar o resultado de sua execução.



Testar o componente App.tsx, em contraste com o redutor, requer o uso de métodos adicionais de diferentes bibliotecas. Arquivo de teste App.test.tsx:



import * as React from 'react';
import {shallow} from 'enzyme';
import {fireEvent, render, cleanup} from "@testing-library/react";
import App from "../components/App";

describe('<App />', () => {
// jest- afterEach    cleanup        
    afterEach(cleanup);

    it('hasn`t got changes', () => {
//   shallow  enzyme   -,    . 
        const component = shallow(<App />);
//        .           .   snapshots      -u: jest -u
        expect(component).toMatchSnapshot();
    });

//         (   DOM-),    async
    it('should render right input value',  async () => {
// render()     @testing-library/react"    shallow() ,    DOM-   .  container  —   div,     .
        const { container } = render(<App/>);
        expect(container.querySelector('input').getAttribute('value')).toEqual('');
//         'test'
        fireEvent.change(container.querySelector('input'), {
            target: {
                value: 'test'
            },
        })
//      'test'
        expect(container.querySelector('input').getAttribute('value')).toEqual('test');
//     .       
        fireEvent.click(container.querySelector('button'))
//        value
        expect(container.querySelector('input').getAttribute('value')).toEqual('');
    });
})


No componente TasksList, verifique se o estado aprovado é exibido corretamente. Arquivo TasksList.test.tsx:



import * as React from 'react';
import {ContextApp, initialState} from "../components/App";

import {shallow} from "enzyme";
import {cleanup, render} from "@testing-library/react";

import TasksList from "../components/TasksList";
import {State} from "../types/stateType";

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

    afterEach(cleanup);

//   
    const testState: State = {
        newTask: '',
        tasks: [{name: 'test', isDone: false}, {name: 'test2', isDone: false}]
    }

//   ContextApp   
    const Wrapper = () => {
        return (
            <ContextApp.Provider value={{state: testState}}>
                <TasksList/>
            </ContextApp.Provider>
            )
    }

    it('should render right tasks length', async () => {
        const {container} = render(<Wrapper/>);

//    
        expect(container.querySelectorAll('li')).toHaveLength(testState.tasks.length);
    });

})


Uma verificação semelhante do campo newTask pode ser feita para o componente NewTask verificando o valor do elemento de entrada.



O projeto pode ser baixado do repositório GitHub .



Isso é tudo, obrigado pela atenção.



Recursos



React JS. Ganchos

trabalhando com React Hooks e TypeScript



All Articles