Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use load data changes #11

Merged
merged 16 commits into from
Feb 1, 2024
8 changes: 8 additions & 0 deletions hooks/useLoadData/types/FetchData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {Promisable} from '../../types';

import {UnboxApiResponse} from './UnboxApiResponse';
import {NotUndefined} from './NotUndefined';

export type FetchData<T extends NotUndefined, Deps extends any[]> = (
...args: readonly [...UnboxApiResponse<Deps>]
) => Promisable<T>;
2 changes: 2 additions & 0 deletions hooks/useLoadData/types/NotUndefined.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// eslint-disable-next-line @typescript-eslint/ban-types
export type NotUndefined = {} | null;
5 changes: 5 additions & 0 deletions hooks/useLoadData/types/UnboxApiResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {ApiResponseBase} from '../../types';

export type UnboxApiResponse<F extends any[]> = {
[P in keyof F]: F[P] extends ApiResponseBase<any> ? Exclude<F[P]['result'], undefined> : F[P];
};
3 changes: 3 additions & 0 deletions hooks/useLoadData/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './NotUndefined';
export * from './UnboxApiResponse';
export * from './FetchData';
292 changes: 256 additions & 36 deletions hooks/useLoadData/useLoadData.test.ts

Large diffs are not rendered by default.

106 changes: 78 additions & 28 deletions hooks/useLoadData/useLoadData.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {useEffect, useState, useMemo} from 'react';
import {ApiResponse, ApiResponseBase, RetryResponse} from '../../types';
import {ApiResponse, RetryResponse, ApiResponseBase, OptionalDependency, DependencyBase} from '../../types';

import {FetchData, NotUndefined} from './types';

function isApiResponseBase(arg: any): arg is ApiResponseBase<unknown> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
Expand All @@ -8,9 +10,13 @@ function isApiResponseBase(arg: any): arg is ApiResponseBase<unknown> {
return keys.includes('isInProgress') && keys.includes('isError') && keys.includes('result') && keys.includes('error');
}

type UnboxApiResponse<F extends any[]> = {
[P in keyof F]: F[P] extends ApiResponseBase<any> ? NonNullable<F[P]['result']> : F[P];
};
function isDependencyBase(arg: any): arg is DependencyBase<unknown> {
return isApiResponseBase(arg);
}

function isRetryResponse(arg: any): arg is RetryResponse {
return isApiResponseBase(arg) && Object.keys(arg).includes('retry') && Object.keys(arg).includes('isMaxRetry');
}

function unboxApiResponse<T>(arg: ApiResponse<T> | T): T {
if (isApiResponseBase(arg)) {
Expand All @@ -23,14 +29,13 @@ function unboxApiResponse<T>(arg: ApiResponse<T> | T): T {
return arg;
}
}
type FetchData<T, Deps extends any[]> = (...args: readonly [...UnboxApiResponse<Deps>]) => Promise<T>;

interface LoadDataConfig {
export interface LoadDataConfig {
fetchWhenDepsChange?: boolean;
maxRetryCount?: number;
}

interface NormalizedLoadDataArgs<T, Deps extends any[]> {
interface NormalizedLoadDataArgs<T extends NotUndefined, Deps extends any[]> {
config?: LoadDataConfig;
fetchDataArgs?: readonly [...Deps];
data?: T;
Expand All @@ -44,7 +49,36 @@ function isConfig(arg: any): arg is LoadDataConfig {
return keys.includes('fetchWhenDepsChange') || keys.includes('maxRetryCount') || keys.includes('data');
}

function normalizeArgumentOverloads<T, Deps extends any[]>(
function isOptionalDependency(arg: any): arg is OptionalDependency {
return isDependencyBase(arg) && !!arg.optional;
}

function correctOptionalDependencies<Deps extends any[]>(args?: readonly [...Deps]) {
return (args || []).map((arg: unknown) => {
if (isOptionalDependency(arg) && arg.isError) {
return {
isInProgress: false,
isError: false,
error: undefined,
result: null
};
}
return arg;
});
}

function checkArgsAreLoaded<Deps extends any[]>(args?: readonly [...Deps]) {
return (args || [])
.map((arg: unknown) => {
if (isApiResponseBase(arg)) {
return !(arg.isInProgress || arg.isError);
}
return true;
})
.reduce((prev, curr) => prev && curr, true);
}

function normalizeArgumentOverloads<T extends NotUndefined, Deps extends any[]>(
arg2?: unknown,
arg3?: unknown,
arg4?: unknown,
Expand Down Expand Up @@ -79,40 +113,40 @@ function normalizeArgumentOverloads<T, Deps extends any[]>(
return args;
}

export function useLoadData<T, Deps extends any[]>(
export function useLoadData<T extends NotUndefined, Deps extends any[]>(
fetchData: FetchData<T, Deps>,
config?: LoadDataConfig
): RetryResponse<T>;

export function useLoadData<T, Deps extends any[]>(
export function useLoadData<T extends NotUndefined, Deps extends any[]>(
fetchData: FetchData<T, Deps>,
fetchDataArgs: readonly [...Deps],
config?: LoadDataConfig
): RetryResponse<T>;

export function useLoadData<T, Deps extends any[]>(
export function useLoadData<T extends NotUndefined, Deps extends any[]>(
fetchData: FetchData<T, Deps>,
fetchDataArgs: readonly [...Deps],
onComplete: (err: unknown, res?: T) => void,
config?: LoadDataConfig
): RetryResponse<T>;

export function useLoadData<T, Deps extends any[]>(
export function useLoadData<T extends NotUndefined, Deps extends any[]>(
fetchData: FetchData<T, Deps>,
fetchDataArgs: readonly [...Deps],
data?: T,
config?: LoadDataConfig
): RetryResponse<T>;

export function useLoadData<T, Deps extends any[]>(
export function useLoadData<T extends NotUndefined, Deps extends any[]>(
fetchData: FetchData<T, Deps>,
fetchDataArgs: readonly [...Deps],
onComplete: (err: unknown, res?: T) => void,
data?: T,
config?: LoadDataConfig
): RetryResponse<T>;

export function useLoadData<T, Deps extends any[]>(
export function useLoadData<T extends NotUndefined, Deps extends any[]>(
fetchData: FetchData<T, Deps>,
arg2?: unknown,
arg3?: unknown,
Expand All @@ -125,12 +159,25 @@ export function useLoadData<T, Deps extends any[]>(
const [counter, setCounter] = useState(0);
const [localFetchWhenDepsChange, setLocalFetchWhenDepsChange] = useState(false);

// eslint-disable-next-line @typescript-eslint/promise-function-async
const initialPromise = useMemo(() => {
const correctedArgs = correctOptionalDependencies(fetchDataArgs);
if (!data && counter < 1 && checkArgsAreLoaded(correctedArgs)) {
return fetchData(...((correctedArgs.map(unboxApiResponse) || []) as Parameters<typeof fetchData>));
} else {
return undefined;
}
}, [counter]);

const nonPromiseResult = initialPromise instanceof Promise ? undefined : initialPromise;
const initialData = data || nonPromiseResult;

const [pendingData, setPendingData] = useState<ApiResponse<T>>(
data
initialData
? {
isInProgress: false,
isError: false,
result: data,
result: initialData,
error: undefined
}
: {
Expand All @@ -151,6 +198,11 @@ export function useLoadData<T, Deps extends any[]>(
});
setCounter((prevCount) => prevCount + 1);
}
fetchDataArgs?.forEach((arg: unknown) => {
if (isRetryResponse(arg) && arg.isError && !arg.isMaxRetry) {
arg.retry();
}
});
}

useEffect(() => {
Expand All @@ -168,8 +220,13 @@ export function useLoadData<T, Deps extends any[]>(
result: undefined
});
try {
const unboxedArgs = fetchDataArgs?.map(unboxApiResponse);
const fetchedData = await fetchData(...((unboxedArgs || []) as Parameters<typeof fetchData>));
const correctedArgs = correctOptionalDependencies(fetchDataArgs);
const unboxedArgs = correctedArgs.map(unboxApiResponse);

const fetchedData =
initialPromise === undefined
? await fetchData(...((unboxedArgs || []) as Parameters<typeof fetchData>))
: await initialPromise;

setPendingData({
isInProgress: false,
Expand All @@ -189,17 +246,10 @@ export function useLoadData<T, Deps extends any[]>(
onComplete?.(error);
}
}
const correctedArgs = correctOptionalDependencies(fetchDataArgs);
const argsAreLoaded = checkArgsAreLoaded(correctedArgs);

const argsAreLoaded = (fetchDataArgs || [])
.map((arg: unknown) => {
if (isApiResponseBase(arg)) {
return !(arg.isInProgress || arg.isError);
}
return true;
})
.reduce((prev, curr) => prev && curr, true);

const argsHaveErrors = (fetchDataArgs || [])
const argsHaveErrors = (correctedArgs || [])
.map((arg: unknown) => {
if (isApiResponseBase(arg)) {
return arg.isError;
Expand Down
39 changes: 39 additions & 0 deletions hooks/useOptionalDependency/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# `useOptionalDependecy`

A React hook that safely optionalizes retry responses.

## Usage
`useOptionalDependency` is designed to be used in tandem with [useLoadData](../useLoadData/). This hook will safely optionalize a [retry response objects](../..//RetryResponse.ts), meaning an optional dependency that returns type `T` will be typed as `T | null` when passed into `fetchData`, and will always be treated as _null_ by `useLoadData` if the dependency errors. The advantage here being that `useLoadData` will still wait upon the optional dependency to resolve (successfully or unsucessfully), and more significantly, does not hold up a dependency chain while still exposing an error.

```Typescript
import React from 'react';
import {useLoadData} from '@Optum/react-hooks';

export const MyComponent = (props) => {
const loadedUserProfile = useLoadData(fetchUserProfile); // loadedUserProfile will be of type RetryResponse<Profile>

const optionalizedUserProfile = useOptionalDependency(loadedUserProfile);
const loadedResults = useLoadData(
async (userProfile) => { // userProfile will be of type Profile | null due to being optionalized
if(!userProfile) {
return fetchResultsWithoutProfile()
} else {
return fetchResults(userProfile)
}
},
[optionalizedUserProfile]
);

}
```

## API

`useOptionalDependency` takes the following arguments:

| Name | Type | Description |
|-|-|-|
| `dependency` | `RetryResponse<T>` | return value from `useLoadData` to be optionalized |


The return value of `useOptionalDependency` is `OptionalDependency<T>`, where `T` is the type of the result.
6 changes: 6 additions & 0 deletions hooks/useOptionalDependency/useOptionalDependency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {useMemo} from 'react';
import {ApiResponse, OptionalDependency} from '../../types';

export function useOptionalDependency<T>(dep: ApiResponse<T>): OptionalDependency<T> {
return useMemo(() => ({...dep, optional: true}), [dep]);
}
51 changes: 47 additions & 4 deletions hooks/useRetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,54 @@
A React hook that simplifies retrying failed service calls.

## Usage
`useRetry` is designed to be used in tandem with [useLoadData](../useLoadData/). This hook will trigger `retry` from errored [retry response objects](../../types/RetryResponse.ts), and only those responses.
`useRetry` exposes a wrapped version of [useLoadData](../useLoadData/) that retries any and all errored [retry response objects](../../types/RetryResponse), and only those responses created, from that exposed version of _useLoadData_.



```Typescript
import React from 'react';
import {useLoadData, useRetry} from 'react-hooks';
import {useRetry} from '@optum/react-hooks';

export const MyComponent = (props) => {
const {useLoadData, retry} = useRetry();
const loadedContent = useLoadData(fetchContent);
const loadedUserProfile = useLoadData(fetchUserProfile);
const loadedResults = useLoadData(fetchResults);
if(
loadedContent.isInProgress ||
loadedUserProfile.isInProgress ||
loadedResults.IsInProgress
) {
return <div>Loading your page</div>
} else if(
loadedContent.isError ||
loadedUserProfile.isError ||
loadedResults.isError
) {
return <button onClick={retry}>Retry</button>
}
else {
return ...
}
}
```

In addition, a list of any `useLoadData` responses can be passed as arguments (not just responses returned by the wrapped version of `useLoadData`) and when the `retry` function is called, any failed responses will be retried.

```Typescript
import React from 'react';
import {useLoadData, useRetry} from '@optum/react-hooks';

export const MyComponent = (props) => {
const loadedContent = useLoadData(fetchContent);
const loadedUserProfile = useLoadData(fetchUserProfile);
const loadedResults = useLoadData(fetchResults);

const retry = useRetry(loadedContent, loadedUserProfile, loadedResults);
const {retry} = useRetry(
loadedContent,
loadedUserProfile,
loadedResults
);

if(
loadedContent.isInProgress ||
Expand All @@ -37,6 +71,7 @@ export const MyComponent = (props) => {
}
```


## API

`useRetry` takes the following arguments:
Expand All @@ -46,4 +81,12 @@ export const MyComponent = (props) => {
| `...apis` | `RetryResponse[]` | any number of retry responses returned from instances of `useLoadData` |


The return value of `useRetry` is a function `() => void` that triggers the **retry** property of errored RetryResponses.
The return value from `useLoadData` contains the following properties:

| Name | Type | Description |
| -------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `useLoadData` | `typeof useLoadData` | Modified version of [useLoadData]('../useLoadData'). |
| `retry` | `() => void` | Retries any retry response created by the _useLoadData_ exposed with this hook in addition to retrying errored retry responses passsed into this hook. |
| `isMaxRetry` | `boolean` | True if any retry response either passed or created via the useLoadData exposed in this hook has reached it's _isMaxRetry_ |


Loading
Loading