Skip to content
This repository has been archived by the owner on Nov 11, 2023. It is now read-only.

**Feature:** Add support for validators that are promise-based or throw errors #72

Merged
merged 1 commit into from
Oct 31, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ typings/
public
lib

# Cache for parcel serverl in example
.cache

# Files managed by operational-scripts
tslint.json
tsconfig.json
Expand Down
1 change: 1 addition & 0 deletions example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This is a simple live demo of `restul-react` components primarily used to test features. Run it with `npm run example`.
Copy link
Contributor

@fabien0102 fabien0102 Oct 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have codesandbox examples and unit tests. If you can avoid to introduce something else to maintain… And I'm sorry but this is not replacing unit tests. I can help you to add the correct unit tests for this feature if needed but please remove this.

Copy link
Contributor

@TejasQ TejasQ Oct 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a dev-server kind of like in @operational/components. It doesn't really add much bloat to the library. It's just an on-device, in-browser player like GraphiQL. Ideally, we don't maintain this but make sure our features work in a browser context.

I think the tests:

  • should be maintained
  • add a lot of value

but, there is no guarantee that they work 100% in a browser environment with calls to real APIs. This will serve as a check we do during development to make sure things work before pushing.

I don't really see any major costs to having this around. Do you?

Copy link
Contributor

@TejasQ TejasQ Oct 19, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@peterszerzo he does have a point though – we should ideally add unit tests for the resolve behavior as well. I'm happy to add these if you like.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see this more as an integration test as opposed to a unit test - it makes sure that a real request can be made, goes through the state management, and renders something in the end. Which is something I am not confident a unit test suite would catch, especially after a feature this complicated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't dispute the need for improving the tests for the new resolve - I just don't feel comfortable shipping this feature without validating it with an end-to-end example. This could even grow to be like the visual test cases suite in https://github.com/contiamo/operational-visualizations

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And at this library stage, I'm not agree with you, we must have unit tests for every features. Without tests, we can't be sure that the functionally works as expected, it's hard to maintained and everybody can broke the feature by mistake (and by everybody, I include myself of course).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm maybe a bit strict on this stack, but all our product depends on this library 😉

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just worry about the "it's works on my little example, let's skip the tests for now" 😉

As long as we have you on the team, I am confident that this will never happen. 😉 Let's also look at making the examples richer and exportable to CodeSandbox (thanks, by the way, @CompuIves) at a later stage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fabien0102 we don't have to merge until we have a satisfactory test suite, and that would have been the case whether we had the example server or not. Both methods of testing are good for reliability and for catching issues that can bubble up in dependent projects - there is no reason to jump against one of them.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@peterszerzo I agree that both methods of testing are good in the context of a PR, but our goal is to have a maintainable product in the long term. For this purpose, unit tests ensure a non regression in the future, it's automated in the CI, etc etc... So no, both methods of testing are not equal in this case.

2 changes: 2 additions & 0 deletions example/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<div id="app"></div>
<script src="index.tsx"></script>
32 changes: 32 additions & 0 deletions example/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as React from "react";
import { render } from "react-dom";

import { Get, RestfulProvider } from "../src";

const wait = timeout =>
new Promise((resolve, reject) => {
setTimeout(() => {
resolve();
}, timeout);
});

const App: React.SFC<{}> = props => (
<RestfulProvider base="https://dog.ceo">
<Get path="/api/breeds/list/all" resolve={res => wait(1500).then(() => Promise.resolve(Object.keys(res.message)))}>
{(breeds, { loading, error }) => {
if (loading) {
return "loading..";
}
if (error) {
return <code>{JSON.stringify(error)}</code>;
}
if (breeds) {
return <ul>{breeds.map((breed, breedIndex) => <li key={breedIndex}>{breed}</li>)}</ul>;
}
return null;
}}
</Get>
</RestfulProvider>
);

render(<App />, document.querySelector("#app"));
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
},
"scripts": {
"start": "operational-scripts start",
"example": "parcel example/index.html",
"test": "operational-scripts test",
"build": "operational-scripts build --for npm",
"preversion": "npm run build",
Expand All @@ -54,6 +55,7 @@
"jest-dom": "^1.12.0",
"lint-staged": "^7.2.0",
"nock": "^10.0.0",
"parcel": "^1.10.3",
"prettier": "^1.13.5",
"react-dom": "^16.4.2",
"react-testing-library": "^5.0.0",
Expand All @@ -64,7 +66,7 @@
"typescript": "^3.0.3"
},
"dependencies": {
"lodash": "^4.17.10",
"lodash": "^4.17.11",
"react": "^16.4.1",
"react-fast-compare": "^2.0.1",
"url": "^0.11.0"
Expand Down
85 changes: 62 additions & 23 deletions src/Get.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,68 @@ describe("Get", () => {
await wait(() => expect(children.mock.calls.length).toBe(2));
expect(children.mock.calls[1][0]).toEqual({ hello: "world", foo: "bar" });
});

it("should transform data with a promise", async () => {
nock("https://my-awesome-api.fake")
.get("/")
.reply(200, { hello: "world" });

const children = jest.fn();
children.mockReturnValue(<div />);

render(
<RestfulProvider base="https://my-awesome-api.fake">
<Get path="" resolve={data => Promise.resolve({ ...data, foo: "bar" })}>
{children}
</Get>
</RestfulProvider>,
);

await wait(() => expect(children.mock.calls.length).toBe(2));
expect(children.mock.calls[1][0]).toEqual({ hello: "world", foo: "bar" });
});

it("should pass an error when the resolver throws a runtime error", async () => {
nock("https://my-awesome-api.fake")
.get("/")
.reply(200, { hello: "world" });

const children = jest.fn();
children.mockReturnValue(<div />);

render(
<RestfulProvider base="https://my-awesome-api.fake">
<Get path="" resolve={data => data.apples.oranges}>
{children}
</Get>
</RestfulProvider>,
);

await wait(() => expect(children.mock.calls.length).toBe(2));
expect(children.mock.calls[1][1].error.message).toEqual("RESOLVE_ERROR");
expect(children.mock.calls[1][0]).toEqual(null);
});

it("should pass an error when the resolver is a promise that rejects", async () => {
nock("https://my-awesome-api.fake")
.get("/")
.reply(200, { hello: "world" });

const children = jest.fn();
children.mockReturnValue(<div />);

render(
<RestfulProvider base="https://my-awesome-api.fake">
<Get path="" resolve={data => Promise.reject("nogood")}>
{children}
</Get>
</RestfulProvider>,
);

await wait(() => expect(children.mock.calls.length).toBe(2));
expect(children.mock.calls[1][1].error).toEqual({ message: "RESOLVE_ERROR", data: JSON.stringify("nogood") });
expect(children.mock.calls[1][0]).toEqual(null);
});
});

describe("with lazy", () => {
Expand Down Expand Up @@ -494,23 +556,18 @@ describe("Get", () => {
</RestfulProvider>,
),
);

await wait(() => expect(apiCalls).toEqual(1));
});

it("should call the API only 10 times without debounce", async () => {
let apiCalls = 0;
nock("https://my-awesome-api.fake")
.filteringPath(/test=[^&]*/g, "test=XXX")
.get("/?test=XXX")
.reply(200, () => ++apiCalls)
.persist();

const children = jest.fn();
children.mockReturnValue(<div />);

const resolve = a => a;

/**
* A new instance of RestfulProvider is created on every rerender.
* This will create a new resolve function every time forcing Get to
Expand All @@ -524,15 +581,13 @@ describe("Get", () => {
<Get path="?test=1">{children}</Get>
</RestfulProvider>,
);

times(10, i =>
rerender(
<RestfulProvider base="https://my-awesome-api.fake" resolve={resolve}>
<Get path={`?test=${i + 1}`}>{children}</Get>
</RestfulProvider>,
),
);

expect(apiCalls).toEqual(10);
});
});
Expand All @@ -543,41 +598,34 @@ describe("Get", () => {
.get("/")
.reply(200, () => ++apiCalls)
.persist();

const children = jest.fn();
children.mockReturnValue(<div />);

const resolve = a => a;
const { rerender } = render(
<RestfulProvider base="https://my-awesome-api.fake" resolve={resolve}>
<Get path="">{children}</Get>
</RestfulProvider>,
);

rerender(
<RestfulProvider base="https://my-awesome-api.fake" resolve={resolve}>
<Get path="">{children}</Get>
</RestfulProvider>,
);

expect(apiCalls).toEqual(1);
});
it("should refetch when base changes", () => {
let apiCalls = 0;
nock("https://my-awesome-api.fake")
.get("/")
.reply(200, () => ++apiCalls);

const children = jest.fn();
children.mockReturnValue(<div />);

const resolve = a => a;
const { rerender } = render(
<RestfulProvider base="https://my-awesome-api.fake" resolve={resolve}>
<Get path="">{children}</Get>
</RestfulProvider>,
);

nock("https://my-new-api.fake")
.get("/")
.reply(200, () => ++apiCalls);
Expand All @@ -588,7 +636,6 @@ describe("Get", () => {
</Get>
</RestfulProvider>,
);

expect(apiCalls).toEqual(2);
});
it("should refetch when path changes", () => {
Expand All @@ -598,23 +645,19 @@ describe("Get", () => {
.get("/?test=XXX")
.reply(200, () => ++apiCalls)
.persist();

const children = jest.fn();
children.mockReturnValue(<div />);

const resolve = a => a;
const { rerender } = render(
<RestfulProvider base="https://my-awesome-api.fake" resolve={resolve}>
<Get path="/?test=0">{children}</Get>
</RestfulProvider>,
);

rerender(
<RestfulProvider base="https://my-awesome-api.fake" resolve={resolve}>
<Get path="/?test=1">{children}</Get>
</RestfulProvider>,
);

expect(apiCalls).toEqual(2);
});
it("should refetch when resolve changes", () => {
Expand All @@ -623,17 +666,14 @@ describe("Get", () => {
.get("/")
.reply(200, () => ++apiCalls)
.persist();

const children = jest.fn();
children.mockReturnValue(<div />);

const providerResolve = a => a;
const { rerender } = render(
<RestfulProvider base="https://my-awesome-api.fake" resolve={providerResolve}>
<Get path="">{children}</Get>
</RestfulProvider>,
);

const newResolve = a => a;
rerender(
<RestfulProvider base="https://my-awesome-api.fake" resolve={providerResolve}>
Expand All @@ -642,7 +682,6 @@ describe("Get", () => {
</Get>
</RestfulProvider>,
);

expect(apiCalls).toEqual(2);
});
});
Expand Down
7 changes: 5 additions & 2 deletions src/Get.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import * as React from "react";
import RestfulReactProvider, { InjectedProps, RestfulReactConsumer, RestfulReactProviderProps } from "./Context";
import { composePath, composeUrl } from "./util/composeUrl";
import { processResponse } from "./util/processResponse";
import { resolveData } from "./util/resolveData";

/**
* A function that resolves returned data from
* a fetch call.
*/
export type ResolveFunction<T> = (data: any) => T;
export type ResolveFunction<T> = ((data: any) => T) | ((data: any) => Promise<T>);

export interface GetDataError<TError> {
message: string;
Expand Down Expand Up @@ -237,7 +238,9 @@ class ContextlessGet<TData, TError> extends React.Component<
return null;
}

this.setState({ loading: false, data: resolve!(data) });
const resolved = await resolveData<TData, TError>({ data, resolve });

this.setState({ loading: false, data: resolved.data, error: resolved.error });
return data;
};

Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Shared types across exported components and utils
//
/**
* A function that resolves returned data from
* a fetch call.
*/
export type ResolveFunction<T> = ((data: any) => T) | ((data: any) => Promise<T>);

export interface GetDataError<TError> {
message: string;
data: TError | string;
}
32 changes: 32 additions & 0 deletions src/util/resolveData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { GetDataError, ResolveFunction } from "../types";

export const resolveData = async <TData, TError>({
data,
resolve,
}: {
data: any;
resolve?: ResolveFunction<TData>;
}): Promise<{ data: TData | null; error: GetDataError<TError> | null }> => {
let resolvedData: TData | null = null;
let resolveError: GetDataError<TError> | null = null;
try {
if (resolve) {
const resolvedDataOrPromise: TData | Promise<TData> = resolve(data);
resolvedData = (resolvedDataOrPromise as { then?: any }).then
? ((await resolvedDataOrPromise) as TData)
: (resolvedDataOrPromise as TData);
} else {
resolvedData = data;
}
} catch (err) {
resolvedData = null;
resolveError = {
message: "RESOLVE_ERROR",
data: JSON.stringify(err),
};
}
return {
data: resolvedData,
error: resolveError,
};
};