Skip to content

Instantly share code, notes, and snippets.

@balthild
Last active January 9, 2024 14:45
Show Gist options
  • Save balthild/1f23725059aef8b9231d6c346494b918 to your computer and use it in GitHub Desktop.
Save balthild/1f23725059aef8b9231d6c346494b918 to your computer and use it in GitHub Desktop.
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();
},
};
}
/* 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