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

Unit tests - Get component #18

Merged
merged 6 commits into from
Aug 6, 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
128 changes: 128 additions & 0 deletions src/Get.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,30 @@ describe("Get", () => {
});
});

describe("with error", () => {
it("should set the `error` object properly", async () => {
nock("https://my-awesome-api.fake")
.get("/")
.reply(401, { message: "You shall not pass!" });

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

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

await wait(() => expect(children.mock.calls.length).toBe(2));
expect(children.mock.calls[1][0]).toEqual(null);
expect(children.mock.calls[1][1].error).toEqual({
data: { message: "You shall not pass!" },
message: "Failed to fetch: 401 Unauthorized",
});
});
});

describe("with custom resolver", () => {
it("should transform data", async () => {
nock("https://my-awesome-api.fake")
Expand Down Expand Up @@ -142,4 +166,108 @@ describe("Get", () => {
expect(children.mock.calls[0][0]).toBe(null);
});
});

describe("with base", () => {
it("should override the base url", async () => {
nock("https://my-awesome-api.fake")
.get("/")
.reply(200, { id: 1 });

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

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

await wait(() => expect(children.mock.calls.length).toBe(2));
expect(children.mock.calls[1][1].loading).toEqual(false);
expect(children.mock.calls[1][0]).toEqual({ id: 1 });
});

it("should override the base url and compose with the path", async () => {
nock("https://my-awesome-api.fake")
.get("/plop")
.reply(200, { id: 1 });

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

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

await wait(() => expect(children.mock.calls.length).toBe(2));
expect(children.mock.calls[1][1].loading).toEqual(false);
expect(children.mock.calls[1][0]).toEqual({ id: 1 });
});
});

describe("with custom request options", () => {
it("should add a custom header", async () => {
nock("https://my-awesome-api.fake", { reqheaders: { foo: "bar" } })
.get("/")
.reply(200, { id: 1 });

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

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

await wait(() => expect(children.mock.calls.length).toBe(2));
expect(children.mock.calls[1][1].loading).toEqual(false);
expect(children.mock.calls[1][0]).toEqual({ id: 1 });
});
});

describe("actions", () => {
it("should refetch", async () => {
nock("https://my-awesome-api.fake")
.get("/")
.reply(200, { id: 1 });

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

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

await wait(() => expect(children.mock.calls.length).toBe(2));
expect(children.mock.calls[1][1].loading).toEqual(false);
expect(children.mock.calls[1][0]).toEqual({ id: 1 });

// refetch
nock("https://my-awesome-api.fake")
.get("/")
.reply(200, { id: 2 });
children.mock.calls[1][2].refetch();
await wait(() => expect(children.mock.calls.length).toBe(4));

// transition state
expect(children.mock.calls[2][1].loading).toEqual(true);
expect(children.mock.calls[2][0]).toEqual({ id: 1 });

// after refetch state
expect(children.mock.calls[3][1].loading).toEqual(false);
expect(children.mock.calls[3][0]).toEqual({ id: 2 });
});
});
});
37 changes: 20 additions & 17 deletions src/Get.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,29 @@ import RestfulReactProvider, { RestfulReactConsumer, RestfulReactProviderProps }
*/
export type ResolveFunction<T> = (data: any) => T;

export interface GetDataError<S> {
export interface GetDataError<TError> {
message: string;
data: S;
data: TError;
}

/**
* An enumeration of states that a fetchable
* view could possibly have.
*/
export interface States<S> {
export interface States<TData, TError> {
/** Is our view currently loading? */
loading: boolean;
/** Do we have an error in the view? */
error?: GetComponentState<S>["error"];
error?: GetComponentState<TData, TError>["error"];
}

/**
* An interface of actions that can be performed
* within Get
*/
export interface Actions<T> {
export interface Actions<TData> {
/** Refetches the same path */
refetch: () => Promise<T>;
refetch: () => Promise<TData | null>;
}

/**
Expand All @@ -46,7 +46,7 @@ export interface Meta {
/**
* Props for the <Get /> component.
*/
export interface GetComponentProps<T = {}, S = {}> {
export interface GetComponentProps<TData, TError> {
/**
* The path at which to request data,
* typically composed by parent Gets or the RestfulProvider.
Expand All @@ -59,14 +59,14 @@ export interface GetComponentProps<T = {}, S = {}> {
* @param data - data returned from the request.
* @param actions - a key/value map of HTTP verbs, aliasing destroy to DELETE.
*/
children: (data: T | null, states: States<S>, actions: Actions<T>, meta: Meta) => React.ReactNode;
children: (data: TData | null, states: States<TData, TError>, actions: Actions<TData>, meta: Meta) => React.ReactNode;
/** Options passed into the fetch call. */
requestOptions?: RestfulReactProviderProps["requestOptions"];
/**
* A function to resolve data return from the backend, most typically
* used when the backend response needs to be adapted in some way.
*/
resolve?: ResolveFunction<T>;
resolve?: ResolveFunction<TData>;
/**
* Should we wait until we have data before rendering?
* This is useful in cases where data is available too quickly
Expand All @@ -90,7 +90,7 @@ export interface GetComponentProps<T = {}, S = {}> {
* are implementation details and should be
* hidden from any consumers.
*/
export interface GetComponentState<T, S = {}> {
export interface GetComponentState<T, S> {
data: T | null;
response: Response | null;
error: GetDataError<S> | null;
Expand All @@ -102,15 +102,18 @@ export interface GetComponentState<T, S = {}> {
* is a named class because it is useful in
* debugging.
*/
class ContextlessGet<T> extends React.Component<GetComponentProps<T>, Readonly<GetComponentState<T>>> {
public readonly state: Readonly<GetComponentState<T>> = {
class ContextlessGet<TData, TError> extends React.Component<
GetComponentProps<TData, TError>,
Readonly<GetComponentState<TData, TError>>
> {
public readonly state: Readonly<GetComponentState<TData, TError>> = {
data: null, // Means we don't _yet_ have data.
response: null,
loading: !this.props.lazy,
error: null,
};

public static defaultProps: Partial<GetComponentProps<{}>> = {
public static defaultProps = {
resolve: (unresolvedData: any) => unresolvedData,
};

Expand All @@ -120,7 +123,7 @@ class ContextlessGet<T> extends React.Component<GetComponentProps<T>, Readonly<G
}
}

public componentDidUpdate(prevProps: GetComponentProps<T>) {
public componentDidUpdate(prevProps: GetComponentProps<TData, TError>) {
// If the path or base prop changes, refetch!
const { path, base } = this.props;
if (prevProps.path !== path || prevProps.base !== base) {
Expand Down Expand Up @@ -168,15 +171,15 @@ class ContextlessGet<T> extends React.Component<GetComponentProps<T>, Readonly<G
const request = new Request(`${base}${requestPath || path || ""}`, this.getRequestOptions(thisRequestOptions));
const response = await fetch(request);

const data: T =
const data =
response.headers.get("content-type") === "application/json" ? await response.json() : await response.text();

if (!response.ok) {
this.setState({
loading: false,
error: { message: `Failed to fetch: ${response.status} ${response.statusText}`, data },
});
throw response;
return null;
}

this.setState({ loading: false, data: resolve!(data) });
Expand Down Expand Up @@ -205,7 +208,7 @@ class ContextlessGet<T> extends React.Component<GetComponentProps<T>, Readonly<G
* in order to provide new `base` props that contain
* a segment of the path, creating composable URLs.
*/
function Get<T>(props: GetComponentProps<T>) {
function Get<TData = {}, TError = {}>(props: GetComponentProps<TData, TError>) {
return (
<RestfulReactConsumer>
{contextProps => (
Expand Down
39 changes: 26 additions & 13 deletions src/Mutate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import { GetComponentState } from "./Get";
* An enumeration of states that a fetchable
* view could possibly have.
*/
export interface States<T = {}> {
export interface States<TData, TError> {
/** Is our view currently loading? */
loading: boolean;
/** Do we have an error in the view? */
error?: GetComponentState<T>["error"];
error?: GetComponentState<TData, TError>["error"];
}

/**
Expand Down Expand Up @@ -47,38 +47,48 @@ export interface MutateComponentCommonProps {
requestOptions?: RestfulReactProviderProps["requestOptions"];
}

export interface MutateComponentWithDelete extends MutateComponentCommonProps {
export interface MutateComponentWithDelete<TData, TError> extends MutateComponentCommonProps {
verb: "DELETE";
/**
* A function that recieves a mutation function, along with
* some metadata.
*
* @param actions - a key/value map of HTTP verbs, aliasing destroy to DELETE.
*/
children: (mutate: (resourceId?: string | {}) => Promise<Response>, states: States, meta: Meta) => React.ReactNode;
children: (
mutate: (resourceId?: string | {}) => Promise<Response>,
states: States<TData, TError>,
meta: Meta,
) => React.ReactNode;
}

export interface MutateComponentWithOtherVerb extends MutateComponentCommonProps {
export interface MutateComponentWithOtherVerb<TData, TError> extends MutateComponentCommonProps {
verb: "POST" | "PUT" | "PATCH";
/**
* A function that recieves a mutation function, along with
* some metadata.
*
* @param actions - a key/value map of HTTP verbs, aliasing destroy to DELETE.
*/
children: (mutate: (body?: string | {}) => Promise<Response>, states: States, meta: Meta) => React.ReactNode;
children: (
mutate: (body?: string | {}) => Promise<Response>,
states: States<TData, TError>,
meta: Meta,
) => React.ReactNode;
}

export type MutateComponentProps = MutateComponentWithDelete | MutateComponentWithOtherVerb;
export type MutateComponentProps<TData, TError> =
| MutateComponentWithDelete<TData, TError>
| MutateComponentWithOtherVerb<TData, TError>;

/**
* State for the <Mutate /> component. These
* are implementation details and should be
* hidden from any consumers.
*/
export interface MutateComponentState<S = {}> {
export interface MutateComponentState<TData, TError> {
response: Response | null;
error: GetComponentState<S>["error"];
error: GetComponentState<TData, TError>["error"];
loading: boolean;
}

Expand All @@ -87,8 +97,11 @@ export interface MutateComponentState<S = {}> {
* is a named class because it is useful in
* debugging.
*/
class ContextlessMutate extends React.Component<MutateComponentProps, MutateComponentState> {
public readonly state: Readonly<MutateComponentState> = {
class ContextlessMutate<TData, TError> extends React.Component<
MutateComponentProps<TData, TError>,
MutateComponentState<TData, TError>
> {
public readonly state: Readonly<MutateComponentState<TData, TError>> = {
response: null,
loading: false,
error: null,
Expand Down Expand Up @@ -146,12 +159,12 @@ class ContextlessMutate extends React.Component<MutateComponentProps, MutateComp
* in order to provide new `base` props that contain
* a segment of the path, creating composable URLs.
*/
function Mutate(props: MutateComponentProps) {
function Mutate<TError = {}, TData = {}>(props: MutateComponentProps<TData, TError>) {
return (
<RestfulReactConsumer>
{contextProps => (
<RestfulReactProvider {...contextProps} base={`${contextProps.base}${props.path}`}>
<ContextlessMutate {...contextProps} {...props} />
<ContextlessMutate<TData, TError> {...contextProps} {...props} />
</RestfulReactProvider>
)}
</RestfulReactConsumer>
Expand Down
Loading