From a078b0d56a1c9cb5c975b4b2b83cac9c60cdbb7f Mon Sep 17 00:00:00 2001 From: Ragnar Rebase Date: Sat, 25 Jan 2020 02:08:54 +0200 Subject: [PATCH] feat: add async fetch flow to Counter component --- .../src/features/counter/Counter.module.scss | 8 +- .../src/features/counter/Counter.test.tsx | 149 +++++++++++++++--- template/src/features/counter/Counter.tsx | 48 ++++-- .../src/features/counter/CounterActions.tsx | 26 +++ .../src/features/counter/CounterSlice.tsx | 27 +++- template/src/utils/sleep.tsx | 3 + 6 files changed, 222 insertions(+), 39 deletions(-) create mode 100644 template/src/features/counter/CounterActions.tsx create mode 100644 template/src/utils/sleep.tsx diff --git a/template/src/features/counter/Counter.module.scss b/template/src/features/counter/Counter.module.scss index 6f71a19..fa29777 100644 --- a/template/src/features/counter/Counter.module.scss +++ b/template/src/features/counter/Counter.module.scss @@ -1,5 +1,5 @@ .button { - margin: 0 0.75rem; + margin: 0.5rem 0.75rem; font-size: 1.25rem; background: transparent; border-color: #5fd0ae; @@ -18,6 +18,12 @@ } } +.loading { + font-size: 2rem; + color: #fba300; + margin: 3.75rem 0; +} + .value { font-size: 5rem; color: #5fd0ae; diff --git a/template/src/features/counter/Counter.test.tsx b/template/src/features/counter/Counter.test.tsx index 6d41104..eb1977f 100644 --- a/template/src/features/counter/Counter.test.tsx +++ b/template/src/features/counter/Counter.test.tsx @@ -1,34 +1,135 @@ -import { screen, fireEvent } from "@testing-library/react"; +import { screen, fireEvent, wait } from "@testing-library/react"; import Counter from "./Counter"; import React from "react"; -import counterReducer, { increment, decrement } from "./CounterSlice"; +import counterReducer, { + increment, + decrement, + fetchValueStart, + fetchValueSuccess, + fetchValueError +} from "./CounterSlice"; import { renderWithRedux, rootInitialState } from "utils/test-helpers"; +import axios from "axios"; -test("displays zero as initial value", () => { - renderWithRedux(); - expect(screen.getByText("0")).toBeVisible(); -}); +jest.mock("axios"); +const mockedAxios = axios as jest.Mocked; -test("calls increment on click", () => { - const { mockStore } = renderWithRedux(); - fireEvent.click(screen.getByText(/increment/i)); - expect(mockStore.getActions()).toEqual([{ type: increment.type }]); -}); +describe("", () => { + test("shows zero as initial value", () => { + renderWithRedux(); + expect(screen.getByText("0")).toBeVisible(); + }); -test("calls decrement on click", () => { - const { mockStore } = renderWithRedux(); - fireEvent.click(screen.getByText(/decrement/i)); - expect(mockStore.getActions()).toEqual([{ type: decrement.type }]); -}); + test("shows loading message", () => { + renderWithRedux(, { + ...rootInitialState, + counter: { ...rootInitialState.counter, loading: true } + }); + expect(screen.getByText(/loading/i)).toBeVisible(); + }); + + test("shows error message", () => { + const errorMessage = "An error occured"; + renderWithRedux(, { + ...rootInitialState, + counter: { ...rootInitialState.counter, error: errorMessage } + }); + expect(screen.getByText(errorMessage)).toBeVisible(); + }); + + test("calls increment on click", () => { + const { mockStore } = renderWithRedux(); + fireEvent.click(screen.getByText(/increment/i)); + expect(mockStore.getActions()).toEqual([{ type: increment.type }]); + }); + + test("calls decrement on click", () => { + const { mockStore } = renderWithRedux(); + fireEvent.click(screen.getByText(/decrement/i)); + expect(mockStore.getActions()).toEqual([{ type: decrement.type }]); + }); + + test("slow fetch success", async () => { + const name = "cra-template-typekit"; + mockedAxios.get.mockResolvedValueOnce({ status: 200, data: { name } }); + jest.useFakeTimers(); + + const { mockStore } = renderWithRedux(); + + fireEvent.click(screen.getByText(/slow fetch/i)); + + jest.runAllTimers(); + // Normally we would wait for an element to show up + // https://github.com/testing-library/react-testing-library#complex-example + await wait(() => null, { timeout: 500 }); -test("increases number by one", () => { - expect( - counterReducer({ ...rootInitialState.counter, value: 1 }, increment) - ).toEqual({ ...rootInitialState.counter, value: 2 }); + expect(mockStore.getActions()).toEqual([ + { type: fetchValueStart.type, payload: undefined }, + { type: fetchValueSuccess.type, payload: name.length } + ]); + }); + + test("slow fetch error", async () => { + mockedAxios.get.mockResolvedValueOnce({ status: 500 }); + jest.useFakeTimers(); + + const { mockStore } = renderWithRedux(); + + fireEvent.click(screen.getByText(/slow fetch/i)); + + jest.runAllTimers(); + // Normally we would wait for an element to show up + // https://github.com/testing-library/react-testing-library#complex-example + await wait(() => null, { timeout: 500 }); + + expect(mockStore.getActions()).toEqual([ + { type: fetchValueStart.type, payload: undefined }, + { type: fetchValueError.type, payload: "Something went wrong." } + ]); + }); }); -test("decreases number by one", () => { - expect( - counterReducer({ ...rootInitialState.counter, value: 1 }, decrement) - ).toEqual({ ...rootInitialState.counter, value: 0 }); +describe("CounterSlice", () => { + test("sets loading on fetch start", () => { + expect( + counterReducer( + { ...rootInitialState.counter, loading: false }, + fetchValueStart + ) + ).toEqual({ ...rootInitialState.counter, loading: true }); + }); + + test("sets value and stop loading on fetch success", () => { + expect( + counterReducer( + { ...rootInitialState.counter, loading: true }, + { type: fetchValueSuccess, payload: 100 } + ) + ).toEqual({ ...rootInitialState.counter, loading: false, value: 100 }); + }); + + test("sets error and stop loading on fetch error", () => { + expect( + counterReducer( + { ...rootInitialState.counter, loading: true }, + { type: fetchValueError, payload: "Some error message." } + ) + ).toEqual({ + ...rootInitialState.counter, + loading: false, + error: "Some error message." + }); + }); + + test("increases number by one", () => { + expect( + counterReducer({ ...rootInitialState.counter, value: 1 }, increment) + ).toEqual({ ...rootInitialState.counter, value: 2 }); + }); + + test("decreases number by one", () => { + expect( + counterReducer({ ...rootInitialState.counter, value: 1 }, decrement) + ).toEqual({ ...rootInitialState.counter, value: 0 }); + }); }); diff --git a/template/src/features/counter/Counter.tsx b/template/src/features/counter/Counter.tsx index d65065a..8d10fbe 100644 --- a/template/src/features/counter/Counter.tsx +++ b/template/src/features/counter/Counter.tsx @@ -2,28 +2,54 @@ import React from "react"; import { useDispatch, useSelector } from "react-redux"; import styles from "./Counter.module.scss"; import { increment, decrement } from "./CounterSlice"; +import { fetchInitialValue } from "./CounterActions"; import { RootState } from "store"; const Counter: React.FC = () => { const count = useSelector((state: RootState) => state.counter.value); + const loading = useSelector((state: RootState) => state.counter.loading); + const error = useSelector((state: RootState) => state.counter.error); const dispatch = useDispatch(); return (
-

{count}

- + {error &&

{error}

} + + {loading ? ( +

+ Loading... +

+ ) : ( +

+ {count} +

+ )} + +
+ + +
); diff --git a/template/src/features/counter/CounterActions.tsx b/template/src/features/counter/CounterActions.tsx new file mode 100644 index 0000000..89b21c3 --- /dev/null +++ b/template/src/features/counter/CounterActions.tsx @@ -0,0 +1,26 @@ +import { AppDispatch, AppThunk } from "../../store"; +import { + fetchValueStart, + fetchValueSuccess, + fetchValueError +} from "./CounterSlice"; +import axios from "axios"; + +export const sleep = (t = Math.random() * 200 + 300) => + new Promise(resolve => setTimeout(resolve, t)); + +export const fetchInitialValue = (): AppThunk => async ( + dispatch: AppDispatch +) => { + dispatch(fetchValueStart()); + try { + // For demo purpose let's say out value is the length + // of project name in manifest and the api call is slow + await sleep(); + const response = await axios.get("/manifest.json"); + const value = response.data.name.length; + dispatch(fetchValueSuccess(value)); + } catch (e) { + dispatch(fetchValueError("Something went wrong.")); + } +}; diff --git a/template/src/features/counter/CounterSlice.tsx b/template/src/features/counter/CounterSlice.tsx index 7e0d6c6..76d3352 100644 --- a/template/src/features/counter/CounterSlice.tsx +++ b/template/src/features/counter/CounterSlice.tsx @@ -1,17 +1,32 @@ -import { createSlice } from "@reduxjs/toolkit"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; interface InitialState { value: number; + loading: boolean; + error: string | null; } export const initialState: InitialState = { - value: 0 + value: 0, + loading: false, + error: null }; export const slice = createSlice({ name: "counter", initialState, reducers: { + fetchValueStart: state => { + state.loading = true; + }, + fetchValueSuccess: (state, action: PayloadAction) => { + state.value = action.payload; + state.loading = false; + }, + fetchValueError: (state, action: PayloadAction) => { + state.error = action.payload; + state.loading = false; + }, increment: state => { state.value += 1; }, @@ -21,6 +36,12 @@ export const slice = createSlice({ } }); -export const { increment, decrement } = slice.actions; +export const { + fetchValueStart, + fetchValueSuccess, + fetchValueError, + increment, + decrement +} = slice.actions; export default slice.reducer; diff --git a/template/src/utils/sleep.tsx b/template/src/utils/sleep.tsx new file mode 100644 index 0000000..8a68014 --- /dev/null +++ b/template/src/utils/sleep.tsx @@ -0,0 +1,3 @@ +/* istanbul ignore file */ +export const sleep = (t = Math.random() * 200 + 300) => + new Promise(resolve => setTimeout(resolve, t));