From 60578e63914348fd8b5c1f8dfb53a85c879534c8 Mon Sep 17 00:00:00 2001 From: Exidex <16986685+Exidex@users.noreply.github.com> Date: Fri, 13 Sep 2024 19:27:10 +0200 Subject: [PATCH] Add useFetch hook. Refine types in other hooks --- dev_plugin/gauntlet.toml | 2 +- dev_plugin/src/hooks-view.tsx | 64 ++++++++++- js/api/src/hooks.ts | 198 +++++++++++++++++++++++----------- 3 files changed, 197 insertions(+), 67 deletions(-) diff --git a/dev_plugin/gauntlet.toml b/dev_plugin/gauntlet.toml index 6741b0c..9d36add 100644 --- a/dev_plugin/gauntlet.toml +++ b/dev_plugin/gauntlet.toml @@ -147,4 +147,4 @@ os = 'windows' [permissions] environment = ["RUST_LOG"] system = ["systemMemoryInfo"] -network = ["upload.wikimedia.org"] \ No newline at end of file +network = ["upload.wikimedia.org", "api.github.com"] \ No newline at end of file diff --git a/dev_plugin/src/hooks-view.tsx b/dev_plugin/src/hooks-view.tsx index 19dd1e4..babc285 100644 --- a/dev_plugin/src/hooks-view.tsx +++ b/dev_plugin/src/hooks-view.tsx @@ -1,6 +1,6 @@ import { Icons, List } from "@project-gauntlet/api/components"; import React, { ReactElement, useRef } from "react"; -import { useCachedPromise, useNavigation, usePromise } from "@project-gauntlet/api/hooks"; +import { useCachedPromise, useFetch, useNavigation, usePromise } from "@project-gauntlet/api/hooks"; export default function ListView(): ReactElement { const { pushView } = useNavigation(); @@ -53,6 +53,14 @@ export default function ListView(): ReactElement { pushView() break; } + case "UseFetchBasic": { + pushView() + break; + } + case "UseFetchMap": { + pushView() + break; + } } }} > @@ -67,6 +75,8 @@ export default function ListView(): ReactElement { + + ) } @@ -355,6 +365,58 @@ function UsePromiseTestThrow(): ReactElement { ) } +function UseFetchBasic(): ReactElement { + const { popView } = useNavigation(); + + interface GithubLatestRelease { + + } + + const { data, error, isLoading } = useFetch( + "https://api.github.com/repos/project-gauntlet/gauntlet/releases/latest" + ); + + printState(data, error, isLoading) + + return ( + + + + + + ) +} + +function UseFetchMap(): ReactElement { + interface GithubLatestRelease { + url: string + } + + const { popView } = useNavigation(); + const { data, error, isLoading } = useFetch( + "https://api.github.com/repos/project-gauntlet/gauntlet/releases/latest", + { + map: result => result.url + } + ); + + printState(data, error, isLoading) + + return ( + + + + + + ) +} + function onSelectionChangeHandler(popView: () => void, handler?: () => void): (id: string) => void { return (id) => { switch (id) { diff --git a/js/api/src/hooks.ts b/js/api/src/hooks.ts index 1073c9d..535ec31 100644 --- a/js/api/src/hooks.ts +++ b/js/api/src/hooks.ts @@ -15,28 +15,11 @@ export function useNavigation(): { popView: () => void, pushView: (component: Re } } -export type AsyncStateInitial = { - isLoading: boolean; // false if options.execute is false, otherwise true - error?: undefined; - data?: undefined; -}; -export type AsyncStateLoading = { - isLoading: true; - error?: unknown | undefined; +export type AsyncState = { + isLoading: boolean; + error?: unknown; data?: T; }; -export type AsyncStateError = { - isLoading: false; - error: unknown; - data?: undefined; -}; -export type AsyncStateSuccess = { - isLoading: false; - error?: undefined; - data: T; -}; - -export type AsyncState = AsyncStateInitial | AsyncStateLoading | AsyncStateError | AsyncStateSuccess; export type MutatePromiseFn = ( asyncUpdate: Promise, @@ -47,25 +30,23 @@ export type MutatePromiseFn = ( }, ) => Promise; -export type UsePromiseOptions Promise> = { - abortable?: MutableRefObject; - execute?: boolean; - onError?: (error: unknown) => void; - onData?: (data: Awaited>) => void; - onWillExecute?: (...args: Parameters) => void; -} - -export function usePromise Promise, R>( - fn: T, - args?: Parameters, - options?: UsePromiseOptions, -): AsyncState>> & { +export function usePromise( + fn: (...args: Args) => Promise, + args?: Args, + options?: { + abortable?: MutableRefObject; + execute?: boolean; + onError?: (error: unknown) => void; + onData?: (data: Return) => void; + onWillExecute?: (...args: Args) => void; + }, +): AsyncState & { revalidate: () => void; - mutate: MutatePromiseFn>, R>; + mutate: MutatePromiseFn; } { const execute = options?.execute !== false; // execute by default - const [state, setState] = useState>>>({ isLoading: execute }); + const [state, setState] = useState>({ isLoading: execute }); return usePromiseInternal( fn, @@ -80,13 +61,20 @@ export function usePromise Promise, R>( ) } -export function useCachedPromise Promise, R>( - fn: T, - args?: Parameters, - options?: UsePromiseOptions & { initialState?: Awaited> | (() => Awaited>) }, -): AsyncState>> & { +export function useCachedPromise( + fn: (...args: Args) => Promise, + args?: Args, + options?: { + initialState?: Return | (() => Return), + abortable?: MutableRefObject; + execute?: boolean; + onError?: (error: unknown) => void; + onData?: (data: Return) => void; + onWillExecute?: (...args: Args) => void; + }, +): AsyncState & { revalidate: () => void; - mutate: MutatePromiseFn>, R>; + mutate: MutatePromiseFn; } { const execute = options?.execute !== false; // execute by default @@ -95,7 +83,7 @@ export function useCachedPromise Promise, R>( const { entrypointId }: { entrypointId: () => string } = useGauntletContext(); // same store is fetched and updated between command runs - const [state, setState] = useCache>>>("useCachedPromise" + entrypointId() + id, () => { + const [state, setState] = useCache>("useCachedPromise" + entrypointId() + id, (): AsyncState => { const initialState = options?.initialState; if (initialState) { if (initialState instanceof Function) { @@ -121,19 +109,19 @@ export function useCachedPromise Promise, R>( ) } -function usePromiseInternal Promise, R>( - fn: T, - state: AsyncState>>, - setState: Dispatch>>>>, - args: Parameters, +function usePromiseInternal( + fn: (...args: Args) => Promise, + state: AsyncState, + setState: Dispatch>>, + args: Args, execute: boolean, abortable?: MutableRefObject, onError?: (error: unknown) => void, - onData?: (data: Awaited>) => void, - onWillExecute?: (...args: Parameters) => void, -): AsyncState>> & { + onData?: (data: Return) => void, + onWillExecute?: (...args: Args) => void, +): AsyncState & { revalidate: () => void; // will execute even if options.execute is false - mutate: MutatePromiseFn>, R>; // will execute even if options.execute is false + mutate: MutatePromiseFn; // will execute even if options.execute is false } { const promiseRef = useRef>(); @@ -144,7 +132,7 @@ function usePromiseInternal Promise, R>( }; }, [abortable]); - const callback = useCallback(async (...args: Parameters): Promise => { + const callback = useCallback(async (...args: Args): Promise => { if (abortable) { abortable.current?.abort(); abortable.current = new AbortController() @@ -158,7 +146,7 @@ function usePromiseInternal Promise, R>( promiseRef.current = promise; - let promiseResult: Awaited>; + let promiseResult: Return; try { promiseResult = await promise; } catch (error) { @@ -202,8 +190,8 @@ function usePromiseInternal Promise, R>( mutate: async ( asyncUpdate: Promise, options?: { - optimisticUpdate?: (data: Awaited> | undefined) => Awaited>; - rollbackOnError?: boolean | ((data: Awaited> | undefined) => Awaited>); + optimisticUpdate?: (data: Return | undefined) => Return; + rollbackOnError?: boolean | ((data: Return | undefined) => Return); shouldRevalidateAfter?: boolean; }, ): Promise => { @@ -231,20 +219,12 @@ function usePromiseInternal Promise, R>( } catch (e) { switch (typeof rollbackOnError) { case "undefined": { - if (prevData === undefined) { - setState({ data: prevData, isLoading: false }) - } else { - setState({ data: prevData, isLoading: false }) - } + setState({ data: prevData, isLoading: false }) break; } case "boolean": { if (rollbackOnError) { - if (prevData === undefined) { - setState({ data: prevData, isLoading: false }) - } else { - setState({ data: prevData, isLoading: false }) - } + setState({ data: prevData, isLoading: false }) } break; } @@ -317,3 +297,91 @@ function useWebStorage( return [value, setValue] } + +export function useFetch( + url: RequestInfo | URL, + options?: { + request?: RequestInit, + parse?: (response: Response) => T | Promise; + initialState?: T | (() => T), + execute?: boolean; + onError?: (error: unknown) => void; + onData?: (data: T) => void; + onWillExecute?: (input: RequestInfo | URL, init?: RequestInit) => void; + }, +): AsyncState & { + revalidate: () => void; + mutate: MutatePromiseFn; +}; +export function useFetch( + url: RequestInfo | URL, + options: { + request?: RequestInit, + parse?: (response: Response) => V | Promise; + map: (result: V) => T | Promise; + initialState?: T | (() => T), + execute?: boolean; + onError?: (error: unknown) => void; + onData?: (data: T) => void; + onWillExecute?: (input: RequestInfo | URL, init?: RequestInit) => void; + }, +): AsyncState & { + revalidate: () => void; + mutate: MutatePromiseFn; +}; +export function useFetch( + url: RequestInfo | URL, + options?: { + request?: RequestInit, + parse?: (response: Response) => V | Promise; + map?: (result: V) => T | Promise; + initialState?: T | V | (() => T | V), + execute?: boolean; + onError?: (error: unknown) => void; + onData?: (data: T | V) => void; + onWillExecute?: (input: RequestInfo | URL, init?: RequestInit) => void; + }, +): AsyncState & { + revalidate: () => void; + mutate: MutatePromiseFn; +} { + const abortable = useRef(); + + return useCachedPromise( + async (inputParam: RequestInfo | URL): Promise => { + const response = await fetch(inputParam, { ...options?.request, signal: abortable.current?.signal }); + + if (options?.parse) { + const parsed: V = await options?.parse(response) + + if (options?.map) { + return options?.map(parsed) + } else { + return parsed + } + } else { + const content = response.headers.get("content-type"); + if (!content || !content.includes("application/json")) { + throw new Error("Content-Type is not 'application/json', please specify custom options.parse") + } + + const parsed: V = await response.json() + + if (options?.map) { + return options?.map(parsed) + } else { + return parsed + } + } + }, + [url], + { + initialState: options?.initialState, + abortable, + execute: options?.execute, + onError: options?.onError, + onData: options?.onData, + onWillExecute: options?.onWillExecute, + } + ) +} \ No newline at end of file