import { DebounceSettings } from "lodash"; import debounce from "lodash/debounce"; import isEqual from "lodash/isEqual"; 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"; import { constructUrl } from "./util/constructUrl"; import { IStringifyOptions } from "qs"; /** * A function that resolves returned data from * a fetch call. */ export type ResolveFunction = (data: any) => TData; export interface GetDataError { message: string; data: TError | string; status?: number; } /** * 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?: GetState["error"]; } export type GetMethod = () => Promise; /** * An interface of actions that can be performed * within Get */ export interface Actions { /** Refetches the same path */ refetch: GetMethod; } /** * 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 GetProps { /** * The path at which to request data, * typically composed by parent Gets or the RestfulProvider. */ path: string; /** * @private This is an internal implementation detail in restful-react, not meant to be used externally. * This helps restful-react correctly override `path`s when a new `base` property is provided. */ __internal_hasExplicitBase?: boolean; /** * 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: TData | null, states: States, actions: Actions, meta: Meta) => React.ReactNode; /** Options passed into the fetch call. */ requestOptions?: RestfulReactProviderProps["requestOptions"]; /** * Path parameters */ pathParams?: TPathParams; /** * Query parameters */ queryParams?: TQueryParams; /** * Query parameter stringify options */ queryParamStringifyOptions?: IStringifyOptions; /** * Don't send the error to the Provider */ localErrorOnly?: boolean; /** * 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? * This is useful in cases where data is available too quickly * to display a spinner or some type of loading state. */ 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; /** * The accumulated path from each level of parent GETs * taking the absolute and relative nature of each path into consideration */ parentPath?: string; /** * How long do we wait between subsequent requests? * Uses [lodash's debounce](https://lodash.com/docs/4.17.10#debounce) under the hood. */ debounce?: | { wait?: number; options: DebounceSettings; } | boolean | number; } /** * State for the component. These * are implementation details and should be * hidden from any consumers. */ export interface GetState { data: TData | null; response: Response | null; error: GetDataError | null; loading: boolean; } /** * The component without Context. This * is a named class because it is useful in * debugging. */ class ContextlessGet extends React.Component< GetProps & InjectedProps, Readonly> > { constructor(props: GetProps & InjectedProps) { super(props); if (typeof props.debounce === "object") { this.fetch = debounce(this.fetch, props.debounce.wait, props.debounce.options); } else if (typeof props.debounce === "number") { this.fetch = debounce(this.fetch, props.debounce); } else if (props.debounce) { this.fetch = debounce(this.fetch); } } /** * Abort controller to cancel the current fetch query */ private abortController = new AbortController(); private signal = this.abortController.signal; public readonly state: Readonly> = { data: null, // Means we don't _yet_ have data. response: null, loading: !this.props.lazy, error: null, }; public static defaultProps = { base: "", parentPath: "", resolve: (unresolvedData: any) => unresolvedData, queryParams: {}, }; public componentDidMount() { if (!this.props.lazy) { this.fetch(); } } public componentDidUpdate(prevProps: GetProps) { const { base, parentPath, path, resolve, queryParams, requestOptions } = prevProps; if ( base !== this.props.base || parentPath !== this.props.parentPath || path !== this.props.path || !isEqual(queryParams, this.props.queryParams) || // both `resolve` props need to _exist_ first, and then be equivalent. (resolve && this.props.resolve && resolve.toString() !== this.props.resolve.toString()) || (requestOptions && this.props.requestOptions && requestOptions.toString() !== this.props.requestOptions.toString()) ) { if (!this.props.lazy) { this.fetch(); } } } public componentWillUnmount() { this.abortController.abort(); } public getRequestOptions = async ( url: string, extraOptions?: Partial, extraHeaders?: boolean | { [key: string]: string }, ) => { const { requestOptions } = this.props; if (typeof requestOptions === "function") { const options = (await requestOptions(url, "GET")) || {}; return { ...extraOptions, ...options, headers: new Headers({ ...(typeof extraHeaders !== "boolean" ? extraHeaders : {}), ...(extraOptions || {}).headers, ...options.headers, }), }; } return { ...extraOptions, ...requestOptions, headers: new Headers({ ...(typeof extraHeaders !== "boolean" ? extraHeaders : {}), ...(extraOptions || {}).headers, ...(requestOptions || {}).headers, }), }; }; public fetch = async (requestPath?: string, thisRequestOptions?: RequestInit) => { const { base, __internal_hasExplicitBase, parentPath, path, resolve, onError, onRequest, onResponse } = this.props; if (this.state.error || !this.state.loading) { this.setState(() => ({ error: null, loading: true })); } const makeRequestPath = () => { const concatPath = __internal_hasExplicitBase ? path : composePath(parentPath, path); return constructUrl(base!, concatPath, this.props.queryParams, { stripTrailingSlash: true, queryParamOptions: this.props.queryParamStringifyOptions, }); }; const request = new Request(makeRequestPath(), await this.getRequestOptions(makeRequestPath(), thisRequestOptions)); if (onRequest) onRequest(request); try { const response = await fetch(request, { signal: this.signal }); const originalResponse = response.clone(); if (onResponse) onResponse(response.clone()); const { data, responseError } = await processResponse(response); // avoid state updates when component has been unmounted if (this.signal.aborted) { return; } if (!response.ok || responseError) { const error = { message: `Failed to fetch: ${response.status} ${response.statusText}${responseError ? " - " + data : ""}`, data, status: response.status, }; this.setState({ loading: false, error, data: null, response: originalResponse, }); if (!this.props.localErrorOnly && onError) { onError(error, () => this.fetch(requestPath, thisRequestOptions), response); } return null; } const resolved = await resolveData({ data, resolve }); this.setState({ loading: false, data: resolved.data, error: resolved.error, response: originalResponse }); return data; } catch (e) { // avoid state updates when component has been unmounted // and when fetch/processResponse threw an error if (this.signal.aborted) { return; } this.setState({ loading: false, data: null, error: { message: `Failed to fetch: ${e.message}`, data: e, }, }); } }; public render() { const { children, wait, path, base, parentPath } = this.props; const { data, error, loading, response } = this.state; if (wait && data === null && !error) { return <>; // Show nothing until we have data. } return children( data, { loading, error }, { refetch: this.fetch }, { response, absolutePath: composeUrl(base!, parentPath!, 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 `parentPath` props that contain * a segment of the path, creating composable URLs. */ function Get( props: GetProps, ) { return ( {contextProps => ( )} ); } export default Get;