Skip to content

Commit

Permalink
Improve useMutation and useQuery signatures
Browse files Browse the repository at this point in the history
  • Loading branch information
Julio García committed Aug 10, 2021
1 parent 582c47c commit 65357cc
Show file tree
Hide file tree
Showing 11 changed files with 3,046 additions and 78 deletions.
2 changes: 2 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import "whatwg-fetch";
import "@testing-library/jest-dom";
6 changes: 6 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** @type {import('@ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "jsdom",
setupFilesAfterEnv: ["<rootDir>/jest-setup.ts"],
};
15 changes: 12 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
{
"name": "@ableco/abledev-react",
"version": "0.0.1-alpha0",
"version": "0.0.1-alpha1",
"description": "",
"main": "dist/abledev-react.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "webpack"
"build": "webpack",
"test": "jest"
},
"author": "Julio García",
"license": "ISC",
Expand All @@ -16,15 +17,23 @@
"react": ">=16"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@types/jest": "^26.0.24",
"@types/node": "^16.4.13",
"@types/react": "^17.0.16",
"jest": "^27.0.6",
"jest-dom": "^4.0.0",
"msw": "^0.34.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"ts-jest": "^27.0.4",
"ts-loader": "^9.2.5",
"ts-node": "^10.2.0",
"tslib": "^2.3.0",
"typescript": "^4.3.5",
"webpack": "^5.49.0",
"webpack-cli": "^4.7.2"
"webpack-cli": "^4.7.2",
"whatwg-fetch": "^3.6.2"
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as useMutation } from "./useMutation";
export { default as useQuery } from "./useQuery";
export { default as wrapRootComponent } from "./wrapRootComponent";
export { invalidateQuery } from "./utils";
11 changes: 11 additions & 0 deletions src/ts-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type AnyFunction = (...args: any) => any;
export type NoArgsFunction = () => any;

export type FirstParamOrFallback<
Func extends AnyFunction,
Fallback,
> = Func extends NoArgsFunction
? Fallback
: Func extends (firstParam: infer FirstParamType, ...args: any) => any
? FirstParamType
: never;
127 changes: 127 additions & 0 deletions src/useMutation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import * as React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import wrapRootComponent from "./wrapRootComponent";
import { setupServer } from "msw/node";
import { rest } from "msw";
import { useUnsweetenedMutation } from "./useMutation";
import { useUnsweetenedQuery } from "./useQuery";
import { QueryClient, useQueryClient as useRQQueryClient } from "react-query";
import { AnyFunction } from "./ts-helpers";

interface ExtendedQueryClient extends QueryClient {
invalidateQuery: (queryKey: AnyFunction) => void;
invalidateQueryKey: (queryKey: string) => void;
}

function useQueryClient(
...args: Parameters<typeof useRQQueryClient>
): ExtendedQueryClient {
const client = useRQQueryClient(...args) as ExtendedQueryClient;

client.invalidateQueryKey = function (queryKey: string) {
this.invalidateQueries(queryKey);
};
client.invalidateQuery = function (queryKey: AnyFunction) {
// This is not a arbitrary convertion. queryKey works differently between the TS world
// and the runtime world:
// - In the TS world, this is a function
// - In the runtime world, this is a string
const queryKeyAsString = queryKey as unknown as string;
this.invalidateQueryKey(queryKeyAsString);
};

client.invalidateQueryKey = client.invalidateQueryKey.bind(client);
client.invalidateQuery = client.invalidateQuery.bind(client);

return client;
}

let serverData: { numbers: Array<number> } = {
numbers: [],
};

const server = setupServer(
rest.post("/abledev/call-mutation", (req, res, ctx) => {
const key = req.url.searchParams.get("key");

if (key === "mutations/push-next") {
const { numbers } = serverData;
serverData.numbers.push((numbers[numbers.length - 1] ?? 0) + 1);
}

if (key === "mutations/push") {
const newNumber = (req.body as any).number as number;
serverData.numbers.push(newNumber);
}

return res(ctx.json({}));
}),
rest.get("/abledev/call-query", (_req, res, ctx) => {
return res(ctx.json(serverData.numbers));
}),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

const TestComponent = wrapRootComponent(() => {
const { isLoading, data } = useUnsweetenedQuery("queries/get-all");
const { invalidateQueryKey } = useQueryClient();

const pushNextMutation = useUnsweetenedMutation("mutations/push-next", {
onSettled: () => {
invalidateQueryKey("queries/get-all");
},
});
const pushMutation = useUnsweetenedMutation("mutations/push", {
onSettled: () => {
invalidateQueryKey("queries/get-all");
},
});

const [newNumber, setNewNumber] = React.useState(99);

return (
<>
<button onClick={() => pushNextMutation.mutate({})}>Push Next</button>

<input
type="text"
value={newNumber.toString()}
onChange={(event) => {
setNewNumber(Number(event.target.value));
}}
placeholder="New Number"
/>
<button onClick={() => pushMutation.mutate({ number: newNumber })}>
Push
</button>
<div>
{isLoading ? (
<span>Loading...</span>
) : (
<span>{JSON.stringify(data as object)}</span>
)}
</div>
</>
);
});

test("It calls a server and updates something", async () => {
render(<TestComponent />);
fireEvent.click(screen.getByRole("button", { name: "Push Next" }));
fireEvent.click(screen.getByRole("button", { name: "Push Next" }));
expect(await screen.findByText("[1,2]"));
});

test("It can receive some arguments", async () => {
serverData.numbers = [];

render(<TestComponent />);
fireEvent.change(screen.getByPlaceholderText("New Number"), {
target: { value: "199" },
});
fireEvent.click(screen.getByRole("button", { name: "Push" }));
expect(await screen.findByText("[199]"));
});
65 changes: 49 additions & 16 deletions src/useMutation.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,63 @@
import { useMutation as useRQMutation } from "react-query";
import { useMutation as useRQMutation, UseMutationOptions } from "react-query";
import { AnyFunction, FirstParamOrFallback } from "./ts-helpers";

type AnyFunction = (...args: any) => any;
type OptionsFromResultAndArgsTypes<ResultType, ArgumentsType> =
UseMutationOptions<ResultType, unknown, ArgumentsType, string>;

type OptionsFromMutationKey<MutationKey extends AnyFunction> =
OptionsFromResultAndArgsTypes<
ReturnType<MutationKey>,
FirstParamOrFallback<MutationKey, object>
>;

function useMutation<MutationKey extends AnyFunction>(
mutationKey: MutationKey,
mutationConfiguration: OptionsFromMutationKey<MutationKey> = {},
) {
// This is not a arbitrary convertion. queryKey works differently between the TS world
// and the runtime world:
// - In the TS world, this is a function
// - In the runtime world, this is a string
const mutationKeyAsString = mutationKey as unknown as string;

return useRQMutation(mutationKeyAsString, async (args: object = {}) => {
const url = new URL(`${location.origin}/abledev/call-mutation`);
url.search = new URLSearchParams({ key: mutationKeyAsString }).toString();
return fetch(url.toString(), {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(args),
}).then((response) => {
return response.json() as ReturnType<MutationKey>;
});
});
return useUnsweetenedMutation<
ReturnType<MutationKey>,
FirstParamOrFallback<MutationKey, object>
>(mutationKeyAsString, mutationConfiguration);
}

function useUnsweetenedMutation<ResultType, ArgumentsType>(
mutationKey: string,
mutationConfiguration: OptionsFromResultAndArgsTypes<
ResultType,
ArgumentsType
> = {},
) {
return useRQMutation<ResultType, unknown, ArgumentsType, string>(
mutationKey,
async (mutationArguments) => {
const url = new URL(`${location.origin}/abledev/call-mutation`);
url.search = new URLSearchParams({ key: mutationKey }).toString();

try {
const response = await fetch(url.toString(), {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(mutationArguments),
});
const responseData = await response.json();
return responseData as unknown as ResultType;
} catch (error) {
throw error;
}
},
mutationConfiguration,
);
}

export { useUnsweetenedMutation };

export default useMutation;
62 changes: 62 additions & 0 deletions src/useQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from "react";
import { render, screen } from "@testing-library/react";
import { useUnsweetenedQuery } from "./useQuery";
import wrapRootComponent from "./wrapRootComponent";
import { setupServer } from "msw/node";
import { rest } from "msw";

const server = setupServer(
rest.get("/abledev/call-query", (req, res, ctx) => {
const response: any = { key: req.url.searchParams.get("key") };
if (req.url.searchParams.get("name")) {
response.name = req.url.searchParams.get("name");
}
return res(ctx.json(response));
}),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

const TestComponent = wrapRootComponent(() => {
const { isLoading, data } = useUnsweetenedQuery("queries/something");

return isLoading ? (
<span>loading...</span>
) : (
<>
<span>done</span>
<span>{JSON.stringify(data as object)}</span>
</>
);
});

const ParameterizedComponent = wrapRootComponent(
({ name }: { name: string }) => {
const { isLoading, data } = useUnsweetenedQuery("queries/something", {
name,
});

return isLoading ? (
<span>loading...</span>
) : (
<>
<span>done</span>
<span>{JSON.stringify(data as object)}</span>
</>
);
},
);

test("It calls a server", async () => {
render(<TestComponent />);
expect(screen.getByText("loading...")).toBeInTheDocument();
expect(await screen.findByText("done")).toBeInTheDocument();
expect(await screen.findByText(/queries\/something/)).toBeInTheDocument();
});

test("It can receive some arguments", async () => {
render(<ParameterizedComponent name="Rare name" />);
expect(await screen.findByText(/Rare name/)).toBeInTheDocument();
});
Loading

0 comments on commit 65357cc

Please sign in to comment.