From 3ef62e7225b1a308a19a682751eaaaafb77c50b6 Mon Sep 17 00:00:00 2001 From: Fabien BERNARD Date: Thu, 16 May 2019 15:10:47 +0200 Subject: [PATCH] Add the useMutate --- src/index.tsx | 1 + src/useGet.test.tsx | 10 +- src/useMutate.test.tsx | 349 +++++++++++++++++++++++++++++++++++++++++ src/useMutate.tsx | 162 +++++++++++++++++++ 4 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 src/useMutate.test.tsx create mode 100644 src/useMutate.tsx diff --git a/src/index.tsx b/src/index.tsx index 04e97f75..d051576b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,7 @@ export { default as Poll, PollProps } from "./Poll"; export { default as Mutate, MutateProps, MutateMethod } from "./Mutate"; export { useGet, UseGetProps } from "./useGet"; +export { useMutate, UseMutateProps } from "./useMutate"; export { Get, GetDataError, GetProps, GetMethod }; diff --git a/src/useGet.test.tsx b/src/useGet.test.tsx index 7df94a84..778c0a15 100644 --- a/src/useGet.test.tsx +++ b/src/useGet.test.tsx @@ -8,13 +8,19 @@ import { cleanup, fireEvent, render, wait, waitForElement } from "react-testing- import { RestfulProvider, useGet } from "./index"; import { Omit, UseGetProps } from "./useGet"; -// NOTES: -// We have react warning due to https://github.com/kentcdodds/react-testing-library/issues/281 describe("useGet hook", () => { + // Mute console.error -> https://github.com/kentcdodds/react-testing-library/issues/281 + // tslint:disable:no-console + const originalConsoleError = console.error; + beforeEach(() => { + console.error = jest.fn; + }); afterEach(() => { + console.error = originalConsoleError; cleanup(); nock.cleanAll(); }); + describe("classic usage", () => { it("should have a loading state on mount", async () => { nock("https://my-awesome-api.fake") diff --git a/src/useMutate.test.tsx b/src/useMutate.test.tsx new file mode 100644 index 00000000..c7c352a0 --- /dev/null +++ b/src/useMutate.test.tsx @@ -0,0 +1,349 @@ +import "isomorphic-fetch"; +import nock from "nock"; +import React from "react"; +import { renderHook } from "react-hooks-testing-library"; +import { RestfulProvider, useMutate } from "."; + +describe("useMutate", () => { + // Mute console.error -> https://github.com/kentcdodds/react-testing-library/issues/281 + // tslint:disable:no-console + const originalConsoleError = console.error; + beforeEach(() => { + console.error = jest.fn; + }); + afterEach(() => { + console.error = originalConsoleError; + }); + + describe("DELETE", () => { + it("should set loading to true after a call", async () => { + nock("https://my-awesome-api.fake") + .delete("/plop") + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useMutate("DELETE", ""), { wrapper }); + result.current.mutate("plop"); + + expect(result.current).toMatchObject({ + error: null, + loading: true, + }); + }); + + it("should call the correct url with a specific id", async () => { + nock("https://my-awesome-api.fake") + .delete("/plop") + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useMutate("DELETE", ""), { wrapper }); + const res = await result.current.mutate("plop"); + + expect(result.current).toMatchObject({ + error: null, + loading: false, + }); + expect(res).toEqual({ id: 1 }); + }); + + it("should call the correct url with a specific id (base in options)", async () => { + nock("https://my-awesome-api.fake") + .delete("/plop") + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useMutate("DELETE", "", { base: "https://my-awesome-api.fake" }), { + wrapper, + }); + const res = await result.current.mutate("plop"); + + expect(result.current).toMatchObject({ + error: null, + loading: false, + }); + expect(res).toEqual({ id: 1 }); + }); + + it("should call the correct url with a specific id (base and path in options)", async () => { + nock("https://my-awesome-api.fake/user") + .delete("/plop") + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => {children}; + const { result } = renderHook(() => useMutate("DELETE", "user", { base: "https://my-awesome-api.fake" }), { + wrapper, + }); + const res = await result.current.mutate("plop"); + + expect(result.current).toMatchObject({ + error: null, + loading: false, + }); + expect(res).toEqual({ id: 1 }); + }); + + it("should call the correct url without id", async () => { + nock("https://my-awesome-api.fake") + .delete("/") + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useMutate("DELETE", ""), { + wrapper, + }); + const res = await result.current.mutate(""); + + expect(result.current).toMatchObject({ + error: null, + loading: false, + }); + expect(res).toEqual({ id: 1 }); + }); + + it("should deal with query parameters", async () => { + nock("https://my-awesome-api.fake") + .delete("/") + .query({ + myParam: true, + }) + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useMutate("DELETE", "", { queryParams: { myParam: true } }), { + wrapper, + }); + const res = await result.current.mutate(""); + + expect(result.current).toMatchObject({ + error: null, + loading: false, + }); + expect(res).toEqual({ id: 1 }); + }); + }); + + describe("POST", () => { + it("should set loading to true after a call", async () => { + nock("https://my-awesome-api.fake") + .post("/plop") + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useMutate("POST", "plop"), { wrapper }); + result.current.mutate(); + + expect(result.current).toMatchObject({ + error: null, + loading: true, + }); + }); + + it("should call the correct url", async () => { + nock("https://my-awesome-api.fake") + .post("/") + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useMutate("POST", ""), { wrapper }); + const res = await result.current.mutate(); + + expect(result.current).toMatchObject({ + error: null, + loading: false, + }); + expect(res).toEqual({ id: 1 }); + }); + + it("should send the correct body", async () => { + nock("https://my-awesome-api.fake") + .post("/", { foo: "bar" }) + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useMutate("POST", ""), { wrapper }); + const res = await result.current.mutate({ foo: "bar" }); + + expect(result.current).toMatchObject({ + error: null, + loading: false, + }); + expect(res).toEqual({ id: 1 }); + }); + + it("should return the data and the message on error", async () => { + nock("https://my-awesome-api.fake") + .post("/") + .reply(500, { error: "I can't, I'm just a chicken!" }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook(() => useMutate("POST", ""), { wrapper }); + try { + await result.current.mutate({ foo: "bar" }); + expect("this statement").toBe("not executed"); + } catch (e) { + expect(result.current).toMatchObject({ + error: { + data: { error: "I can't, I'm just a chicken!" }, + message: "Failed to fetch: 500 Internal Server Error", + status: 500, + }, + loading: false, + }); + expect(e).toEqual({ + data: { error: "I can't, I'm just a chicken!" }, + message: "Failed to fetch: 500 Internal Server Error", + status: 500, + }); + } + }); + + it("should call the provider onError", async () => { + nock("https://my-awesome-api.fake") + .post("/") + .reply(500, { error: "I can't, I'm just a chicken!" }); + + const onError = jest.fn(); + const wrapper = ({ children }) => ( + + {children} + + ); + const { result } = renderHook(() => useMutate("POST", ""), { wrapper }); + await result.current.mutate({ foo: "bar" }).catch(() => { + /* noop */ + }); + expect(onError).toBeCalledWith( + { + data: { error: "I can't, I'm just a chicken!" }, + message: "Failed to fetch: 500 Internal Server Error", + status: 500, + }, + expect.any(Function), // retry + expect.any(Object), // response + ); + }); + + it("should be able to retry after error", async () => { + nock("https://my-awesome-api.fake") + .post("/") + .reply(401, { message: "You shall not pass!" }); + nock("https://my-awesome-api.fake") + .post("/") + .reply(200, { message: "You shall pass :)" }); + + const onError = jest.fn(); + const wrapper = ({ children }) => ( + + {children} + + ); + const { result } = renderHook(() => useMutate("POST", ""), { wrapper }); + + await result.current.mutate().catch(() => { + /* noop */ + }); + + expect(onError).toBeCalledWith( + { + data: { message: "You shall not pass!" }, + message: "Failed to fetch: 401 Unauthorized", + status: 401, + }, + expect.any(Function), // retry + expect.any(Object), // response + ); + + const data = await onError.mock.calls[0][1](); // call retry + expect(data).toEqual({ message: "You shall pass :)" }); + }); + + it("should not call the provider onError if localErrorOnly is true", async () => { + nock("https://my-awesome-api.fake") + .post("/") + .reply(500, { error: "I can't, I'm just a chicken!" }); + + const onError = jest.fn(); + const wrapper = ({ children }) => ( + + {children} + + ); + const { result } = renderHook(() => useMutate("POST", "", { localErrorOnly: true }), { wrapper }); + await result.current.mutate({ foo: "bar" }).catch(() => { + /* noop */ + }); + expect(onError).not.toBeCalled(); + }); + + it("should transform the data with the resolve function", async () => { + nock("https://my-awesome-api.fake") + .post("/") + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook( + () => useMutate<{ id: number }>("POST", "", { resolve: data => ({ id: data.id * 2 }) }), + { wrapper }, + ); + const res = await result.current.mutate(); + + expect(result.current).toMatchObject({ + error: null, + loading: false, + }); + expect(res).toEqual({ id: 2 }); + }); + + it("should forward the resolve error", async () => { + nock("https://my-awesome-api.fake") + .post("/") + .reply(200, { id: 1 }); + + const wrapper = ({ children }) => ( + {children} + ); + const { result } = renderHook( + () => + useMutate<{ id: number }>("POST", "", { + resolve: () => { + throw new Error("I don't like your data!"); + }, + }), + { wrapper }, + ); + + try { + await result.current.mutate(); + expect("this statement").toBe("not executed"); + } catch (e) { + expect(result.current).toMatchObject({ + error: { + data: "I don't like your data!", + message: "Failed to resolve: I don't like your data!", + }, + loading: false, + }); + expect(e.message).toEqual("I don't like your data!"); + } + }); + }); +}); diff --git a/src/useMutate.tsx b/src/useMutate.tsx new file mode 100644 index 00000000..0c20aa89 --- /dev/null +++ b/src/useMutate.tsx @@ -0,0 +1,162 @@ +import merge from "lodash/merge"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { Context } from "./Context"; +import { MutateMethod, MutateState } from "./Mutate"; +import { Omit, resolvePath, UseGetProps } from "./useGet"; +import { processResponse } from "./util/processResponse"; + +export interface UseMutateProps extends Omit, "lazy"> { + /** + * What HTTP verb are we using? + */ + verb: "POST" | "PUT" | "PATCH" | "DELETE"; +} + +export interface UseMutateReturn extends MutateState { + /** + * Absolute path resolved from `base` and `path` (context & local) + */ + absolutePath: string; + /** + * Cancel the current fetch + */ + cancel: () => void; + /** + * Call the mutate endpoint + */ + mutate: MutateMethod; +} + +export function useMutate( + props: UseMutateProps, +): UseMutateReturn; + +export function useMutate( + verb: UseMutateProps["verb"], + path: string, + props?: Omit, "path" | "verb">, +): UseMutateReturn; + +export function useMutate< + TData = any, + TError = any, + TQueryParams = { [key: string]: any }, + TRequestBody = any +>(): UseMutateReturn { + const props: UseMutateProps = + typeof arguments[0] === "object" ? arguments[0] : { ...arguments[2], path: arguments[1], verb: arguments[0] }; + + const context = useContext(Context); + const { verb, base = context.base, path, queryParams, resolve } = props; + const isDelete = verb === "DELETE"; + + const [state, setState] = useState>({ + error: null, + loading: false, + }); + + const abortController = useRef(new AbortController()); + + // Cancel the fetch on unmount + useEffect(() => () => abortController.current.abort(), []); + + const mutate = useCallback>( + async body => { + if (state.error || !state.loading) { + setState(prevState => ({ ...prevState, loading: true, error: null })); + } + + if (state.loading) { + // Abort previous requests + abortController.current.abort(); + abortController.current = new AbortController(); + } + const signal = abortController.current.signal; + + const requestOptions = + (typeof props.requestOptions === "function" ? props.requestOptions() : props.requestOptions) || {}; + + const contextRequestOptions = + (typeof context.requestOptions === "function" ? context.requestOptions() : context.requestOptions) || {}; + + const options: RequestInit = { + method: verb, + headers: { + "content-type": typeof body === "object" ? "application/json" : "text/plain", + }, + }; + + if (!isDelete) { + options.body = typeof body === "object" ? JSON.stringify(body) : ((body as unknown) as string); + } + + const request = new Request( + resolvePath(base, isDelete ? `${path}/${body}` : path, queryParams), + merge({}, contextRequestOptions, options, requestOptions, { signal }), + ); + + const response = await fetch(request); + const { data: rawData, responseError } = await processResponse(response); + + let data: TData | any; // `any` -> data in error case + try { + data = resolve ? resolve(rawData) : rawData; + } catch (e) { + const error = { + data: e.message, + message: `Failed to resolve: ${e.message}`, + }; + + setState(prevState => ({ + ...prevState, + error, + loading: false, + })); + throw e; + } + + if (signal.aborted) { + return; + } + + if (!response.ok || responseError) { + const error = { + data, + message: `Failed to fetch: ${response.status} ${response.statusText}`, + status: response.status, + }; + + setState(prevState => ({ + ...prevState, + error, + loading: false, + })); + + if (!props.localErrorOnly && context.onError) { + context.onError(error, () => mutate(body), response); + } + + throw error; + } + + setState(prevState => ({ ...prevState, loading: false })); + + return data; + }, + [context.base, context.requestOptions, context.resolve, state.error, state.loading], + ); + + return { + ...state, + absolutePath: "todo", + mutate, + cancel: () => { + setState(prevState => ({ + ...prevState, + loading: false, + })); + abortController.current.abort(); + abortController.current = new AbortController(); + }, + }; +}