Last active
February 21, 2025 05:14
-
-
Save balthild/1f23725059aef8b9231d6c346494b918 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
ActiveVisit, Errors, FormDataConvertible, Inertia, Method, Page, PendingVisit, Progress, | |
VisitOptions, | |
} from '@inertiajs/inertia'; | |
import { deepKeys, deleteProperty, getProperty, setProperty } from 'dot-prop'; | |
import { produce } from 'immer'; | |
import { identity, isEqual } from 'lodash-es'; | |
import moment, { Moment } from 'moment'; | |
import { SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
import { Path, PathValue } from 'util-types'; | |
interface CancelToken { | |
cancel: () => void; | |
} | |
type FormObject = { | |
[Key in string]?: FormValue; | |
}; | |
type FormValue = FormPrimitive | FormObject | FormArray; | |
type FormPrimitive = string | number | boolean | null | File; | |
type FormArray = FormValue[]; | |
interface FormHook<T> { | |
data: Readonly<T>; | |
setData(data: SetStateAction<T>): void; | |
getField<P extends Path<T>>(path: P): PathValue<T, P>; | |
setField<P extends Path<T>>(path: P, value: PathValue<T, P>): void; | |
isDirty: boolean; | |
errors: FormErrors<T>; | |
hasErrors: boolean; | |
processing: boolean; | |
progress: Progress | undefined; | |
wasSuccessful: boolean; | |
recentlySuccessful: boolean; | |
transform<U extends FormObject>(callback: (data: T) => U): void; | |
reset(...fields: Path<T>[]): void; | |
setError<P extends Path<T>>(path: P, value: string): void; | |
clearErrors(...fields: Path<T>[]): void; | |
submit(method: string, url: string, options?: VisitOptions): void; | |
get(url: string, options?: VisitOptions): void; | |
patch(url: string, options?: VisitOptions): void; | |
post(url: string, options?: VisitOptions): void; | |
put(url: string, options?: VisitOptions): void; | |
delete(url: string, options?: VisitOptions): void; | |
cancel(): void; | |
} | |
type FormErrors<T extends FormObject> = { | |
[K in keyof T]?: T[K] extends FormObject ? FormErrors<T[K]> : string; | |
}; | |
function convertErrors<T>(errors: Errors): FormErrors<T> { | |
const result = {}; | |
for (const [path, value] of Object.entries(errors)) { | |
setProperty(result, path, value); | |
} | |
return result as FormErrors<T>; | |
} | |
// Copied and modified from the `useEvent` RFC | |
// eslint-disable-next-line @typescript-eslint/no-explicit-any | |
export function useHandler<T extends any[], R>(fn: (...args: T) => R): typeof fn { | |
const ref = useRef(fn); | |
useLayoutEffect(() => { | |
ref.current = fn; | |
}); | |
return useCallback((...args) => ref.current(...args), []); | |
} | |
export function useInertiaForm<T extends FormObject>(defaults: T): FormHook<T> { | |
const isMounted = useRef(false); | |
useEffect(() => { | |
isMounted.current = true; | |
return () => { | |
isMounted.current = false; | |
}; | |
}, []); | |
const cancelToken = useRef<CancelToken>(); | |
const recentlySuccessfulTimeoutId = useRef(0); | |
const [data, setData] = useState(defaults); | |
const [errors, setErrors] = useState<FormErrors<T>>({}); | |
const hasErrors = useMemo(() => { | |
const paths = deepKeys(errors); | |
for (const path of paths) { | |
if (getProperty(errors, path)) { | |
return true; | |
} | |
} | |
return false; | |
}, [errors]); | |
const [processing, setProcessing] = useState(false); | |
const [progress, setProgress] = useState<Progress>(); | |
const [wasSuccessful, setWasSuccessful] = useState(false); | |
const [recentlySuccessful, setRecentlySuccessful] = useState(false); | |
const transform = useRef<(data: T) => FormObject>(identity); | |
const submit = useCallback( | |
(method: Method, url: string, options: VisitOptions = {}) => { | |
Inertia.visit(url, { | |
...options, | |
method, | |
data: transform.current(data) as Record<string, FormDataConvertible>, | |
// By default, Inertia will only preserve states for non-GET requests | |
// Overriding this behavior since here is submitting a form | |
// See also https://inertiajs.com/releases/inertia-0.8.7-2021-04-14 | |
preserveState: options.preserveScroll || 'errors', | |
onCancelToken: (token: CancelToken) => { | |
cancelToken.current = token; | |
return options.onCancelToken?.(token); | |
}, | |
onBefore: (visit: PendingVisit) => { | |
setWasSuccessful(false); | |
setRecentlySuccessful(false); | |
clearTimeout(recentlySuccessfulTimeoutId.current); | |
return options.onBefore?.(visit); | |
}, | |
onStart: (visit: PendingVisit) => { | |
setProcessing(true); | |
return options.onStart?.(visit); | |
}, | |
onProgress: (event?: Progress) => { | |
setProgress(event); | |
return options.onProgress?.(event); | |
}, | |
onSuccess: (page: Page) => { | |
if (isMounted.current) { | |
setProcessing(false); | |
setProgress(undefined); | |
setErrors({}); | |
setWasSuccessful(true); | |
setRecentlySuccessful(true); | |
recentlySuccessfulTimeoutId.current = setTimeout(() => { | |
if (isMounted.current) { | |
setRecentlySuccessful(false); | |
} | |
}, 2000); | |
} | |
return options.onSuccess?.(page); | |
}, | |
onError: (errors: Errors) => { | |
if (isMounted.current) { | |
setProcessing(false); | |
setProgress(undefined); | |
setErrors(convertErrors(errors)); | |
} | |
return options.onError?.(errors); | |
}, | |
onCancel: () => { | |
if (isMounted.current) { | |
setProcessing(false); | |
setProgress(undefined); | |
} | |
return options.onCancel?.(); | |
}, | |
onFinish: (visit: ActiveVisit) => { | |
if (isMounted.current) { | |
setProcessing(false); | |
setProgress(undefined); | |
} | |
cancelToken.current = undefined; | |
return options.onFinish?.(visit); | |
}, | |
}); | |
}, | |
[data], | |
); | |
return { | |
data, | |
setData(data) { | |
setData(data); | |
}, | |
getField(path) { | |
return getProperty(data, path) as PathValue<T, typeof path>; | |
}, | |
setField(path, value) { | |
setData((data) => { | |
return produce(data, (draft: T) => { | |
setProperty(draft, path, value); | |
}); | |
}); | |
}, | |
isDirty: !isEqual(data, defaults), | |
errors, | |
hasErrors, | |
processing, | |
progress, | |
wasSuccessful, | |
recentlySuccessful, | |
transform(callback) { | |
transform.current = callback; | |
}, | |
reset(...fields) { | |
if (fields.length === 0) { | |
setData(defaults); | |
} else { | |
setData((data) => { | |
return produce(data, (draft: T) => { | |
fields.forEach((path) => { | |
const value = getProperty(defaults, path); | |
setProperty(draft, path, value); | |
}); | |
}); | |
}); | |
} | |
}, | |
setError(path, value) { | |
setErrors((errors) => { | |
return produce(errors, (draft: Errors) => { | |
setProperty(draft, path, value); | |
}); | |
}); | |
}, | |
clearErrors(...fields) { | |
if (fields.length === 0) { | |
setErrors({}); | |
} else { | |
setErrors((errors) => { | |
return produce(errors, (draft: Errors) => { | |
fields.forEach((path) => { | |
deleteProperty(draft, path); | |
}); | |
}); | |
}); | |
} | |
}, | |
submit, | |
get(url, options) { | |
submit(Method.GET, url, options); | |
}, | |
post(url, options) { | |
submit(Method.POST, url, options); | |
}, | |
put(url, options) { | |
submit(Method.PUT, url, options); | |
}, | |
patch(url, options) { | |
submit(Method.PATCH, url, options); | |
}, | |
delete(url, options) { | |
submit(Method.DELETE, url, options); | |
}, | |
cancel() { | |
cancelToken.current?.cancel(); | |
}, | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* eslint-disable @typescript-eslint/no-explicit-any */ | |
declare module 'util-types' { | |
// Copied from https://twitter.com/diegohaz/status/1309489079378219009 | |
export type PathImpl<T, K extends keyof T> = | |
K extends string | |
? T[K] extends Record<string, any> | |
? T[K] extends ArrayLike<any> | |
? K | `${K}.${PathImpl<T[K], Exclude<keyof T[K], keyof any[]>>}` | |
: K | `${K}.${PathImpl<T[K], keyof T[K]>}` | |
: K | |
: never; | |
export type Path<T> = PathImpl<T, keyof T> | Extract<keyof T, string>; | |
export type PathValue<T, P extends Path<T>> = | |
P extends `${infer K}.${infer Rest}` | |
? K extends keyof T | |
? Rest extends Path<T[K]> | |
? PathValue<T[K], Rest> | |
: never | |
: never | |
: P extends keyof T | |
? T[P] | |
: never; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment