From 61b52bdb2483f55e35c2e5758cec60387fc508a1 Mon Sep 17 00:00:00 2001 From: Tejas Kumar Date: Mon, 9 Jul 2018 18:20:07 +0200 Subject: [PATCH] Separate Mutations into different component, clean up Get --- src/Context.tsx | 3 +- src/Get.tsx | 200 ++++++++++++++++++++++++++++++++++++ src/Mutate.tsx | 132 ++++++++++++++++++++++++ src/Poll.tsx | 6 +- src/index.tsx | 266 +----------------------------------------------- 5 files changed, 341 insertions(+), 266 deletions(-) create mode 100644 src/Get.tsx create mode 100644 src/Mutate.tsx diff --git a/src/Context.tsx b/src/Context.tsx index 7acc2b2a..9db247ea 100644 --- a/src/Context.tsx +++ b/src/Context.tsx @@ -1,6 +1,5 @@ import * as React from "react"; - -import { ResolveFunction } from "."; +import { ResolveFunction } from "./Get"; export interface RestfulReactProviderProps { /** The backend URL where the RESTful resources live. */ diff --git a/src/Get.tsx b/src/Get.tsx new file mode 100644 index 00000000..a54fa5a0 --- /dev/null +++ b/src/Get.tsx @@ -0,0 +1,200 @@ +import * as React from "react"; +import RestfulProvider, { RestfulReactConsumer, RestfulReactProviderProps } from "./Context"; + +/** + * A function that resolves returned data from + * a fetch call. + */ +export type ResolveFunction = (data: any) => T; + +/** + * An enumeration of states that a fetchable + * view could possibly have. + */ +export interface States { + /** Is our view currently loading? */ + loading: boolean; + /** Do we have an error in the view? */ + error?: string; +} + +/** + * An interface of actions that can be performed + * within Get + */ +export interface Actions { + /** Refetches the same path */ + refetch: () => Promise; +} + +/** + * Meta information returned to the fetchable + * view. + */ +export interface Meta { + /** The entire response object passed back from the request. */ + response: Response | null; + /** The absolute path of this request. */ + absolutePath: string; +} + +/** + * Props for the component. + */ +export interface GetComponentProps { + /** + * The path at which to request data, + * typically composed by parent Gets or the RestfulProvider. + */ + path: string; + /** + * A function that recieves the returned, resolved + * data. + * + * @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, actions: Actions, 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; + /** + * Should we wait until we have data before rendering? + */ + wait?: boolean; + /** + * Should we fetch data at a later stage? + */ + lazy?: boolean; + /** + * An escape hatch and an alternative to `path` when you'd like + * to fetch from an entirely different URL.. + * + */ + base?: string; +} + +/** + * State for the component. These + * are implementation details and should be + * hidden from any consumers. + */ +export interface GetComponentState { + data: T | null; + response: Response | null; + error: string; + loading: boolean; +} + +/** + * The component without Context. This + * is a named class because it is useful in + * debugging. + */ +class ContextlessGet extends React.Component, Readonly>> { + private shouldFetchImmediately = () => !this.props.wait && !this.props.lazy; + + readonly state: Readonly> = { + data: null, // Means we don't _yet_ have data. + response: null, + error: "", + loading: this.shouldFetchImmediately(), + }; + + componentDidMount() { + this.shouldFetchImmediately() && this.fetch(); + } + + componentDidUpdate(prevProps: GetComponentProps) { + // If the path or base prop changes, refetch! + const { path, base } = this.props; + if (prevProps.path !== path || prevProps.base !== base) { + this.shouldFetchImmediately() && this.fetch(); + } + } + + getRequestOptions = (extraOptions?: Partial, extraHeaders?: boolean | { [key: string]: string }) => { + const { requestOptions } = this.props; + + if (typeof requestOptions === "function") { + return { + ...extraOptions, + ...requestOptions(), + headers: new Headers({ + ...(typeof extraHeaders !== "boolean" ? extraHeaders : {}), + ...(extraOptions || {}).headers, + ...(requestOptions() || {}).headers, + }), + }; + } + + return { + ...extraOptions, + ...requestOptions, + headers: new Headers({ + ...(typeof extraHeaders !== "boolean" ? extraHeaders : {}), + ...(extraOptions || {}).headers, + ...(requestOptions || {}).headers, + }), + }; + }; + + fetch = async (requestPath?: string, thisRequestOptions?: RequestInit) => { + const { base, path } = this.props; + this.setState(() => ({ error: "", loading: true })); + + const { resolve } = this.props; + const foolProofResolve = resolve || (data => data); + const response = await fetch(`${base}${requestPath || path || ""}`, this.getRequestOptions(thisRequestOptions)); + + if (!response.ok) { + this.setState({ loading: false, error: `Failed to fetch: ${response.status} ${response.statusText}` }); + throw response; + } + + const data: T = + response.headers.get("content-type") === "application/json" ? await response.json() : await response.text(); + + this.setState({ loading: false, data: foolProofResolve(data) }); + return data; + }; + + render() { + const { children, wait, path, base } = this.props; + const { data, error, loading, response } = this.state; + + if (wait && data === null) { + return <>; // Show nothing until we have data. + } + + return children(data, { loading, error }, { refetch: this.fetch }, { response, absolutePath: `${base}${path}` }); + } +} + +/** + * The component _with_ context. + * Context is used to compose path props, + * and to maintain the base property against + * which all requests will be made. + * + * We compose Consumers immediately with providers + * in order to provide new `base` props that contain + * a segment of the path, creating composable URLs. + */ +function Get(props: GetComponentProps) { + return ( + + {contextProps => ( + + + + )} + + ); +} + +export default Get; diff --git a/src/Mutate.tsx b/src/Mutate.tsx new file mode 100644 index 00000000..cadb1813 --- /dev/null +++ b/src/Mutate.tsx @@ -0,0 +1,132 @@ +import * as React from "react"; +import RestfulProvider, { RestfulReactConsumer, RestfulReactProviderProps } from "./Context"; + +/** + * An enumeration of states that a fetchable + * view could possibly have. + */ +export interface States { + /** Is our view currently loading? */ + loading: boolean; + /** Do we have an error in the view? */ + error?: string; +} + +/** + * Meta information returned to the fetchable + * view. + */ +export interface Meta { + /** The entire response object passed back from the request. */ + response: Response | null; + /** The absolute path of this request. */ + absolutePath: string; +} + +/** + * Props for the component. + */ +export interface MutateComponentProps { + /** + * The path at which to request data, + * typically composed by parent Gets or the RestfulProvider. + */ + path: string; + /** + * What HTTP verb are we using? + */ + verb: "POST" | "PUT" | "PATCH" | "DELETE"; + /** + * A function that recieves the returned, resolved + * data. + * + * @param actions - a key/value map of HTTP verbs, aliasing destroy to DELETE. + */ + children: (mutate: (body: string | {}) => Promise, states: States, meta: Meta) => React.ReactNode; + /** + * An escape hatch and an alternative to `path` when you'd like + * to fetch from an entirely different URL.. + * + */ + base?: string; + /** Options passed into the fetch call. */ + requestOptions?: RestfulReactProviderProps["requestOptions"]; +} + +/** + * State for the component. These + * are implementation details and should be + * hidden from any consumers. + */ +export interface MutateComponentState { + response: Response | null; + error: string; + loading: boolean; +} + +/** + * The component without Context. This + * is a named class because it is useful in + * debugging. + */ +class ContextlessMutate extends React.Component { + readonly state: Readonly = { + response: null, + error: "", + loading: false, + }; + + mutate = async (body?: string | {}, mutateRequestOptions?: RequestInit) => { + const { base, path, verb, requestOptions: providerRequestOptions } = this.props; + this.setState(() => ({ error: "", loading: true })); + + const response = await fetch(`${base}${path || ""}`, { + method: verb, + body: typeof body === "object" ? JSON.stringify(body) : body, + ...(typeof providerRequestOptions === "function" ? providerRequestOptions() : providerRequestOptions), + ...mutateRequestOptions, + headers: { + "content-type": typeof body === "object" ? "application/json" : "text/plain", + ...(mutateRequestOptions ? mutateRequestOptions.headers : {}), + }, + }); + + if (!response.ok) { + this.setState({ loading: false, error: `Failed to fetch: ${response.status} ${response.statusText}` }); + throw response; + } + + return response; + }; + + render() { + const { children, path, base } = this.props; + const { error, loading, response } = this.state; + + return children(this.mutate, { loading, error }, { response, absolutePath: `${base}${path}` }); + } +} + +/** + * The component _with_ context. + * Context is used to compose path props, + * and to maintain the base property against + * which all requests will be made. + * + * We compose Consumers immediately with providers + * in order to provide new `base` props that contain + * a segment of the path, creating composable URLs. + */ +function Mutate(props: MutateComponentProps) { + return ( + + {contextProps => ( + + + + )} + + ); +} + +export default Mutate; diff --git a/src/Poll.tsx b/src/Poll.tsx index b7cf93b7..747bff2f 100644 --- a/src/Poll.tsx +++ b/src/Poll.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { RestfulReactConsumer, RestfulReactProviderProps } from "./Context"; -import { RestfulProvider, Meta as GetComponentMeta, GetComponentProps } from "."; -import { GetComponentState } from "."; +import { RestfulReactConsumer } from "./Context"; +import { RestfulProvider } from "."; +import { GetComponentState, Meta as GetComponentMeta, GetComponentProps } from "./Get"; /** * Meta information returned from the poll. diff --git a/src/index.tsx b/src/index.tsx index bf4003fb..379e9396 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,264 +1,8 @@ -import * as React from "react"; -import RestfulProvider, { RestfulReactConsumer, RestfulReactProviderProps } from "./Context"; +import Get from "./Get"; -/** - * A function that resolves returned data from - * a fetch call. - */ -export type ResolveFunction = (data: any) => T; - -/** - * HTTP Verbs: POST/GET/PUT/PATCH/DELETE. - */ -export type RequestMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - -/** - * A collection of actions that map to - * HTTP verbs that can be performed. - */ -export interface Verbs { - /** GET a resource */ - get: (path?: string, extraRequestOptions?: RequestInit) => Promise; - /** DELETE a resource */ - destroy: (id?: string, extraRequestOptions?: RequestInit) => Promise; - /** POST a resource */ - post: (data?: string, extraRequestOptions?: RequestInit) => Promise; - /** PUT a resource */ - put: (data?: string, extraRequestOptions?: RequestInit) => Promise; - /** PATCH a resource */ - patch: (data?: string, extraRequestOptions?: RequestInit) => Promise; -} - -/** - * An enumeration of states that a fetchable - * view could possibly have. - */ -export interface States { - /** Is our view currently loading? */ - loading: boolean; - /** Do we have an error in the view? */ - error?: string; -} - -/** - * Meta information returned to the fetchable - * view. - */ -export interface Meta { - /** The entire response object passed back from the request. */ - response: Response | null; - /** The absolute path of this request. */ - absolutePath: string; -} - -/** - * Props for the component. - */ -export interface GetComponentProps { - /** - * The path at which to request data, - * typically composed by parent Gets or the RestfulProvider. - */ - path: string; - /** - * A function that recieves the returned, resolved - * data. - * - * @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, actions: Verbs, 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; - /** - * Should we wait until we have data before rendering? - */ - wait?: boolean; - /** - * Should we fetch data at a later stage? - */ - lazy?: boolean; - /** - * An escape hatch and an alternative to `path` when you'd like - * to fetch from an entirely different URL.. - * - * @deprecated Deprecated in favor of a `base` prop (https://github.com/contiamo/restful-react/issues/4) - */ - base?: string; -} - -/** - * State for the component. These - * are implementation details and should be - * hidden from any consumers. - */ -export interface GetComponentState { - data: T | null; - response: Response | null; - error: string; - loading: boolean; -} - -/** - * The component without Context. This - * is a named class because it is useful in - * debugging. - */ -class ContextlessGet extends React.Component, Readonly>> { - private shouldFetchImmediately = () => !this.props.wait && !this.props.lazy; - - readonly state: Readonly> = { - data: null, // Means we don't _yet_ have data. - response: null, - error: "", - loading: this.shouldFetchImmediately(), - }; - - componentDidMount() { - this.shouldFetchImmediately() && this.fetch()(); - } - - componentDidUpdate(prevProps: GetComponentProps) { - // If the path or base prop changes, refetch! - const { path, base } = this.props; - if (prevProps.path !== path || prevProps.base !== base) { - this.shouldFetchImmediately() && this.fetch()(); - } - } - - getRequestOptions = (extraOptions?: Partial, extraHeaders?: boolean | { [key: string]: string }) => { - const { requestOptions } = this.props; - - if (typeof requestOptions === "function") { - return { - ...extraOptions, - ...requestOptions(), - headers: new Headers({ - ...(typeof extraHeaders !== "boolean" ? extraHeaders : {}), - ...(extraOptions || {}).headers, - ...(requestOptions() || {}).headers, - }), - }; - } - - return { - ...extraOptions, - ...requestOptions, - headers: new Headers({ - ...(typeof extraHeaders !== "boolean" ? extraHeaders : {}), - ...(extraOptions || {}).headers, - ...(requestOptions || {}).headers, - }), - }; - }; - - fetch = (method: RequestMethod = "GET") => { - const { base, path } = this.props; - - switch (method) { - case "POST": - case "PUT": - case "PATCH": - case "DELETE": - return async (body?: string, thisRequestOptions?: RequestInit) => { - this.setState(() => ({ error: "", loading: true })); - const isJSON = () => { - try { - return body ? Boolean(JSON.parse(body)) : false; - } catch (e) { - return false; - } - }; - - const response = await fetch(`${base}${path}`, { - ...this.getRequestOptions(thisRequestOptions, isJSON() && { "content-type": "application/json" }), - method, - body, - }); - - this.setState({ loading: false }); - - if (!response.ok) { - throw response; - } - - const responseData: Promise = - response.headers.get("content-type") === "application/json" ? response.json() : response.text(); - return responseData; - }; - - default: - return async (requestPath?: string, thisRequestOptions?: RequestInit) => { - this.setState(() => ({ error: "", loading: true })); - - const { resolve } = this.props; - const foolProofResolve = resolve || (data => data); - const response = await fetch( - `${base}${requestPath || path || ""}`, - this.getRequestOptions(thisRequestOptions), - ); - - if (!response.ok) { - this.setState({ loading: false, error: `Failed to fetch: ${response.status} ${response.statusText}` }); - throw response; - } - - const data: T = - response.headers.get("content-type") === "application/json" ? await response.json() : await response.text(); - - this.setState({ data: foolProofResolve(data) }); - return data; - }; - } - }; - - actions = { - get: this.fetch(), - post: this.fetch("POST"), - put: this.fetch("PUT"), - patch: this.fetch("PATCH"), - destroy: this.fetch("DELETE"), - }; - - render() { - const { children, wait, path, base } = this.props; - const { data, error, loading, response } = this.state; - - if (wait && data === null) { - return <>; // Show nothing until we have data. - } - - return children(data, { loading, error }, this.actions, { response, absolutePath: `${base}${path}` }); - } -} - -/** - * The component _with_ context. - * Context is used to compose path props, - * and to maintain the base property against - * which all requests will be made. - * - * We compose Consumers immediately with providers - * in order to provide new `base` props that contain - * a segment of the path, creating composable URLs. - */ -function Get(props: GetComponentProps) { - return ( - - {contextProps => ( - - - - )} - - ); -} - -export { RestfulProvider }; +export { default as RestfulProvider } from "./Context"; export { default as Poll } from "./Poll"; +export { default as Mutate } from "./Mutate"; + +export { Get }; export default Get;