import * as E from 'fp-ts/lib/Either'; import { absurd, constVoid } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import * as TE from 'fp-ts/lib/TaskEither'; import * as t from 'io-ts'; import { useCallback } from 'react'; import { Api, ApiError, FetchError } from '../types'; import { Form, useForm } from './useForm'; // Basically, every HTTP request is a mutation. // The difference is the usage on the client. // That's why we have useMutation and useQuery. export const useMutation = < Name extends keyof Api, Endpoint extends typeof Api['props'][Name] >( name: Name, initialState: t.OutputOf, { handleError, handleSuccess, }: { handleError?: ( error: t.TypeOf, form: Form, ) => void; handleSuccess?: ( payload: t.TypeOf, form: Form, ) => void; }, ): Form => { const endpoint = Api.props[name]; const submit = useCallback( (form: Form) => ( data: t.TypeOf, ) => { if (form.isDisabled) return; form.disable(); // Generics within functions suck. We have to retype decode. // https://github.com/gcanti/fp-ts/issues/904#issuecomment-558528906 const decode: ( response: unknown, ) => E.Either< t.Errors | FetchError, E.Either< t.TypeOf, t.TypeOf > > = endpoint.props.output.decode; const handleClientServerMismatch = () => { if (confirm('App is outdated. Confirm to auto update.')) { // Never do this automatically. location.reload(true); } }; const handleFetchError = (error: t.Errors | FetchError) => { if (FetchError.is(error)) { alert(`Please check network connection. Error: ${error.message}`); return; } handleClientServerMismatch(); }; const handleApiError = (error: ApiError) => { switch (error.status) { case 'badRequest': handleClientServerMismatch(); break; case 'forbidden': case 'internalServerError': case 'notFound': case 'unauthorized': alert(error.status + ' ' + error.message); break; default: absurd(error.status); } }; const handleFetchResponse = ( output: E.Either< t.TypeOf, t.TypeOf >, ) => { pipe( output, E.fold( error => { if (handleError) handleError(error, form); if (ApiError.is(error)) { handleApiError(error); } else if (endpoint.props.formError.is(error)) { // as any, because I don't know form.setAsyncErrors(error.errors as any); } }, payload => { if (handleSuccess) handleSuccess(payload, form); }, ), ); }; TE.tryCatch( () => fetch(`/api/${name}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }).then(response => response.json()), error => ({ type: 'fetchError', message: String(error) }), )().then(response => { form.enable(); pipe( response, E.chain(decode), E.fold(handleFetchError, handleFetchResponse), ); }); }, [ handleError, handleSuccess, endpoint.props.formError, endpoint.props.output.decode, name, ], ); const handleSubmit = useCallback( (form: Form) => { pipe(form.validated, E.fold(constVoid, submit(form))); }, [endpoint, submit], ); // as any, because I don't know const form = useForm(endpoint.props.input as any, initialState, { handleSubmit, }); return form; };