@graphcommerce/react-hook-form 2.102.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +99 -0
- package/README.md +157 -0
- package/__mocks__/TestShippingAddressForm.gql.ts +14 -0
- package/__mocks__/TestShippingAddressForm.graphql +16 -0
- package/__tests__/useFormGqlMutation.tsx +41 -0
- package/__tests__/useGqlDocumentHandler.tsx +50 -0
- package/index.ts +11 -0
- package/package.json +34 -0
- package/src/ComposedForm/ComposedForm.tsx +32 -0
- package/src/ComposedForm/ComposedSubmit.tsx +84 -0
- package/src/ComposedForm/context.ts +6 -0
- package/src/ComposedForm/index.ts +8 -0
- package/src/ComposedForm/reducer.ts +78 -0
- package/src/ComposedForm/types.ts +85 -0
- package/src/ComposedForm/useFormCompose.ts +36 -0
- package/src/diff.ts +39 -0
- package/src/useFormAutoSubmit.tsx +82 -0
- package/src/useFormGql.tsx +73 -0
- package/src/useFormGqlMutation.tsx +39 -0
- package/src/useFormGqlQuery.tsx +23 -0
- package/src/useFormMuiRegister.tsx +22 -0
- package/src/useFormPersist.tsx +71 -0
- package/src/useFormValidFields.tsx +23 -0
- package/src/useGqlDocumentHandler.tsx +151 -0
- package/src/useLazyQueryPromise.tsx +41 -0
- package/src/validationPatterns.tsx +4 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ApolloError } from '@apollo/client'
|
|
2
|
+
import { FieldValues, FormState, UseFormReturn } from 'react-hook-form'
|
|
3
|
+
import { SetOptional } from 'type-fest'
|
|
4
|
+
|
|
5
|
+
export type UseFormComposeOptions<V extends FieldValues = FieldValues> = {
|
|
6
|
+
/** The form that is used to submit */
|
|
7
|
+
form: UseFormReturn<V>
|
|
8
|
+
/** Method to submit the form */
|
|
9
|
+
submit: ReturnType<UseFormReturn<V>['handleSubmit']>
|
|
10
|
+
|
|
11
|
+
/** Identifier of the specific */
|
|
12
|
+
key: string
|
|
13
|
+
/**
|
|
14
|
+
* To submit multiple forms we need to define a sequence how the forms are structured so they can
|
|
15
|
+
* be submitted in that sequence.
|
|
16
|
+
*
|
|
17
|
+
* One form might depend on another form, so we first submit the first form, then the second, etc.
|
|
18
|
+
*/
|
|
19
|
+
step: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type FormStateAll = Pick<
|
|
23
|
+
FormState<FieldValues>,
|
|
24
|
+
'isSubmitting' | 'isSubmitted' | 'isSubmitSuccessful' | 'isValid'
|
|
25
|
+
>
|
|
26
|
+
export type ButtonState = {
|
|
27
|
+
/** When the submit is called, isSubmit will be set to true */
|
|
28
|
+
isSubmitting: boolean
|
|
29
|
+
/** After the submission is done, isSubmitted is set to true */
|
|
30
|
+
isSubmitted: boolean
|
|
31
|
+
/** After the submission is successful, isSubmitSuccessful will be true */
|
|
32
|
+
isSubmitSuccessful: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ComposedSubmitRenderComponentProps = {
|
|
36
|
+
submit: () => Promise<void>
|
|
37
|
+
buttonState: ButtonState
|
|
38
|
+
error?: ApolloError
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ComposedFormState = {
|
|
42
|
+
forms: {
|
|
43
|
+
[step: number]: UseFormComposeOptions | SetOptional<UseFormComposeOptions, 'form' | 'submit'>
|
|
44
|
+
}
|
|
45
|
+
isCompleting: boolean
|
|
46
|
+
buttonState: ButtonState
|
|
47
|
+
formState: FormStateAll
|
|
48
|
+
submitted: boolean
|
|
49
|
+
error?: ApolloError
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Register a new form with the useFormCompose hook */
|
|
53
|
+
export type RegisterAction = {
|
|
54
|
+
type: 'REGISTER'
|
|
55
|
+
} & Pick<UseFormComposeOptions, 'key' | 'step'>
|
|
56
|
+
|
|
57
|
+
/** Assign the current state to the form */
|
|
58
|
+
export type AssignAction = { type: 'ASSIGN' } & Omit<UseFormComposeOptions, 'step'>
|
|
59
|
+
|
|
60
|
+
/** Cleanup the form if the useFromCompose hook changes */
|
|
61
|
+
export type UnregisterAction = {
|
|
62
|
+
type: 'UNREGISTER'
|
|
63
|
+
} & Pick<UseFormComposeOptions, 'key'>
|
|
64
|
+
/** Recalculate the combined formstate */
|
|
65
|
+
export type FormStateAction = { type: 'FORMSTATE' }
|
|
66
|
+
/** Submit all forms and call onSubmitComplete?.() when done */
|
|
67
|
+
export type SubmitAction = { type: 'SUBMIT' }
|
|
68
|
+
export type SubmittingAction = { type: 'SUBMITTING' }
|
|
69
|
+
export type SubmittedAction = { type: 'SUBMITTED'; isSubmitSuccessful: boolean }
|
|
70
|
+
|
|
71
|
+
export type Actions =
|
|
72
|
+
| AssignAction
|
|
73
|
+
| RegisterAction
|
|
74
|
+
| UnregisterAction
|
|
75
|
+
| FormStateAction
|
|
76
|
+
| SubmitAction
|
|
77
|
+
| SubmittingAction
|
|
78
|
+
| SubmittedAction
|
|
79
|
+
|
|
80
|
+
export type ComposedFormReducer = React.Reducer<ComposedFormState, Actions>
|
|
81
|
+
|
|
82
|
+
export type ComposedFormContext = [
|
|
83
|
+
React.ReducerState<ComposedFormReducer>,
|
|
84
|
+
React.Dispatch<React.ReducerAction<ComposedFormReducer>>,
|
|
85
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useContext, useEffect } from 'react'
|
|
2
|
+
import { FieldValues, UseFormReturn } from 'react-hook-form'
|
|
3
|
+
import { isFormGqlOperation } from '../useFormGqlMutation'
|
|
4
|
+
import { composedFormContext } from './context'
|
|
5
|
+
import { UseFormComposeOptions } from './types'
|
|
6
|
+
|
|
7
|
+
export function useFormCompose<V>(fields: UseFormComposeOptions<V>) {
|
|
8
|
+
const [state, dispatch] = useContext(composedFormContext)
|
|
9
|
+
const { form, key, step, submit } = fields
|
|
10
|
+
|
|
11
|
+
const { formState } = form
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
dispatch({ type: 'REGISTER', key, step })
|
|
15
|
+
return () => dispatch({ type: 'UNREGISTER', key })
|
|
16
|
+
}, [dispatch, key, step])
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
dispatch({ type: 'ASSIGN', key, form: form as UseFormReturn<FieldValues>, submit })
|
|
20
|
+
}, [dispatch, fields, form, key, submit])
|
|
21
|
+
|
|
22
|
+
const error = isFormGqlOperation(form) ? form.error : undefined
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
dispatch({ type: 'FORMSTATE' })
|
|
26
|
+
}, [
|
|
27
|
+
dispatch,
|
|
28
|
+
formState.isSubmitSuccessful,
|
|
29
|
+
formState.isSubmitted,
|
|
30
|
+
formState.isSubmitting,
|
|
31
|
+
formState.isValid,
|
|
32
|
+
error,
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
return state.formState
|
|
36
|
+
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
function isObject(val: unknown): val is Record<string, unknown> {
|
|
2
|
+
return typeof val === 'object' && val !== null
|
|
3
|
+
}
|
|
4
|
+
function isArray(val: unknown): val is unknown[] {
|
|
5
|
+
return Array.isArray(val)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Simple diff function, retuns the values of the second object. */
|
|
9
|
+
export default function diff(item1: unknown, item2: unknown) {
|
|
10
|
+
const item1Type = typeof item2
|
|
11
|
+
const item2Type = typeof item2
|
|
12
|
+
const isSame = item1Type === item2Type
|
|
13
|
+
|
|
14
|
+
// If the types aren't the same we always have a diff
|
|
15
|
+
if (!isSame) return item2
|
|
16
|
+
|
|
17
|
+
if (isArray(item1) && isArray(item2)) {
|
|
18
|
+
const res = item1.map((val, idx) => diff(val, item2[idx])).filter((val) => !!val)
|
|
19
|
+
return res.length ? res : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (isObject(item1) && isObject(item2)) {
|
|
23
|
+
const entriesRight = Object.fromEntries(
|
|
24
|
+
Object.entries(item1)
|
|
25
|
+
.map(([key, val]) => [key, diff(val, item2[key])])
|
|
26
|
+
.filter((entry) => !!entry[1]),
|
|
27
|
+
)
|
|
28
|
+
const entriesLeft = Object.fromEntries(
|
|
29
|
+
Object.entries(item2)
|
|
30
|
+
.map(([key, val]) => [key, diff(item1[key], val)])
|
|
31
|
+
.filter((entry) => !!entry[1]),
|
|
32
|
+
)
|
|
33
|
+
const entries = { ...entriesRight, ...entriesLeft }
|
|
34
|
+
|
|
35
|
+
return Object.keys(entries).length ? entries : undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return item2 === item1 ? undefined : item2
|
|
39
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { debounce } from '@material-ui/core'
|
|
2
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
3
|
+
import { FieldPath, FieldValues, UseFormReturn } from 'react-hook-form'
|
|
4
|
+
|
|
5
|
+
export type UseFormAutoSubmitOptions<TForm extends UseFormReturn<V>, V extends FieldValues> = {
|
|
6
|
+
/** Instance of current form */
|
|
7
|
+
form: Omit<TForm, 'handleSubmit'>
|
|
8
|
+
/** SubmitHandler */
|
|
9
|
+
submit: ReturnType<TForm['handleSubmit']>
|
|
10
|
+
/** Milliseconds to wait before updating */
|
|
11
|
+
wait?: number
|
|
12
|
+
/** Autosubmit only when these field names update */
|
|
13
|
+
fields?: FieldPath<V>[]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Forces the form to submit directly when it is valid, whithout user interaction. Please be aware
|
|
17
|
+
* that this may cause extra requests
|
|
18
|
+
*/
|
|
19
|
+
forceInitialSubmit?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Make sure the form is set to { mode: 'onChange' }
|
|
24
|
+
*
|
|
25
|
+
* The form will automatically submit when:
|
|
26
|
+
*
|
|
27
|
+
* - The form is dirty (has modifications)
|
|
28
|
+
* - The form is valid (has no errors)
|
|
29
|
+
* - The form is not already submitting
|
|
30
|
+
* - The form is not currently validating
|
|
31
|
+
*
|
|
32
|
+
* Q: The form keeps submitting in loops: A: formState.isDirty should be false after submission Make
|
|
33
|
+
* sure that you call `reset(getValues())` after submission.
|
|
34
|
+
*
|
|
35
|
+
* @see useFormGqlMutation.tsx for an example implementation
|
|
36
|
+
*
|
|
37
|
+
* Q: How to I resubmit if the form is modified during the request?
|
|
38
|
+
* formState.isDirty should be true after the submission
|
|
39
|
+
* @see useFormGqlMutation.tsx for an example implementation
|
|
40
|
+
*/
|
|
41
|
+
export function useFormAutoSubmit<Form extends UseFormReturn<V>, V = FieldValues>(
|
|
42
|
+
options: UseFormAutoSubmitOptions<Form, V>,
|
|
43
|
+
) {
|
|
44
|
+
const { form, submit, wait = 500, fields, forceInitialSubmit } = options
|
|
45
|
+
const { formState } = form
|
|
46
|
+
|
|
47
|
+
const [submitting, setSubmitting] = useState(false)
|
|
48
|
+
const values = JSON.stringify(fields ? form.watch(fields) : form.watch())
|
|
49
|
+
const [oldValues, setOldValues] = useState<string>(values)
|
|
50
|
+
|
|
51
|
+
const canSubmit = formState.isValid && !formState.isSubmitting && !formState.isValidating
|
|
52
|
+
const force = formState.submitCount === 0 && forceInitialSubmit
|
|
53
|
+
const shouldSubmit = formState.isDirty && values !== oldValues
|
|
54
|
+
|
|
55
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
|
+
const submitDebounced = useCallback(
|
|
57
|
+
debounce(async () => {
|
|
58
|
+
setSubmitting(true)
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await submit()
|
|
62
|
+
} catch (e) {
|
|
63
|
+
// We're not interested if the submission actually succeeds, that should be handled by the form itself.
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setOldValues(values)
|
|
67
|
+
setSubmitting(false)
|
|
68
|
+
}, wait),
|
|
69
|
+
[submit],
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (canSubmit && (force || shouldSubmit)) {
|
|
74
|
+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
75
|
+
submitDebounced()
|
|
76
|
+
return submitDebounced.clear
|
|
77
|
+
}
|
|
78
|
+
return () => {}
|
|
79
|
+
}, [canSubmit, force, shouldSubmit, submitDebounced])
|
|
80
|
+
|
|
81
|
+
return submitting
|
|
82
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FetchResult,
|
|
3
|
+
ApolloClient,
|
|
4
|
+
TypedDocumentNode,
|
|
5
|
+
useApolloClient,
|
|
6
|
+
MutationTuple,
|
|
7
|
+
ApolloError,
|
|
8
|
+
} from '@apollo/client'
|
|
9
|
+
import { UseFormProps, UseFormReturn, UnpackNestedValue, DeepPartial } from 'react-hook-form'
|
|
10
|
+
import diff from './diff'
|
|
11
|
+
import { useGqlDocumentHandler, UseGqlDocumentHandler } from './useGqlDocumentHandler'
|
|
12
|
+
import { LazyQueryTuple } from './useLazyQueryPromise'
|
|
13
|
+
|
|
14
|
+
export type OnCompleteFn<Q> = (
|
|
15
|
+
data: FetchResult<Q>,
|
|
16
|
+
client: ApolloClient<unknown>,
|
|
17
|
+
) => void | Promise<void>
|
|
18
|
+
|
|
19
|
+
type UseFormGraphQLCallbacks<Q, V> = {
|
|
20
|
+
onBeforeSubmit?: (variables: V) => V | Promise<V>
|
|
21
|
+
onComplete?: OnCompleteFn<Q>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type UseFormGraphQlOptions<Q, V> = UseFormProps<V> & UseFormGraphQLCallbacks<Q, V>
|
|
25
|
+
|
|
26
|
+
export type UseFormGqlMethods<Q, V> = Omit<UseGqlDocumentHandler<V>, 'encode' | 'type'> &
|
|
27
|
+
Pick<UseFormReturn<V>, 'handleSubmit'> & { data?: Q | null; error?: ApolloError }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Combines useMutation/useLazyQueryPromise with react-hook-form's useForm:
|
|
31
|
+
*
|
|
32
|
+
* - Automatically extracts all required arguments for a query
|
|
33
|
+
* - Casts Float/Int mutation input variables to a Number
|
|
34
|
+
* - Updates the form when the query updates
|
|
35
|
+
* - Resets the form after submitting the form when no modifications are found
|
|
36
|
+
*/
|
|
37
|
+
export function useFormGql<Q, V>(
|
|
38
|
+
options: {
|
|
39
|
+
document: TypedDocumentNode<Q, V>
|
|
40
|
+
form: UseFormReturn<V>
|
|
41
|
+
tuple: MutationTuple<Q, V> | LazyQueryTuple<Q, V>
|
|
42
|
+
defaultValues?: UseFormProps<V>['defaultValues']
|
|
43
|
+
} & UseFormGraphQLCallbacks<Q, V>,
|
|
44
|
+
): UseFormGqlMethods<Q, V> {
|
|
45
|
+
const { onComplete, onBeforeSubmit, document, form, tuple, defaultValues } = options
|
|
46
|
+
const { encode, type, ...gqlDocumentHandler } = useGqlDocumentHandler<Q, V>(document)
|
|
47
|
+
const [execute, { data, error }] = tuple
|
|
48
|
+
const client = useApolloClient()
|
|
49
|
+
|
|
50
|
+
const handleSubmit: UseFormReturn<V>['handleSubmit'] = (onValid, onInvalid) =>
|
|
51
|
+
form.handleSubmit(async (formValues, event) => {
|
|
52
|
+
// Combine defaults with the formValues and encode
|
|
53
|
+
let variables = encode({
|
|
54
|
+
...defaultValues,
|
|
55
|
+
...(formValues as Record<string, unknown>),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Wait for the onBeforeSubmit to complete
|
|
59
|
+
if (onBeforeSubmit) variables = await onBeforeSubmit(variables)
|
|
60
|
+
|
|
61
|
+
const result = await execute({ variables })
|
|
62
|
+
if (onComplete && result.data) await onComplete(result, client)
|
|
63
|
+
|
|
64
|
+
// Reset the state of the form if it is unmodified afterwards
|
|
65
|
+
if (typeof diff(form.getValues(), formValues) === 'undefined')
|
|
66
|
+
form.reset(formValues as UnpackNestedValue<DeepPartial<V>>)
|
|
67
|
+
|
|
68
|
+
// @ts-expect-error For some reason it is not accepting the value here
|
|
69
|
+
await onValid(formValues, event)
|
|
70
|
+
}, onInvalid)
|
|
71
|
+
|
|
72
|
+
return { ...gqlDocumentHandler, handleSubmit, data, error }
|
|
73
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { MutationHookOptions, TypedDocumentNode, useMutation } from '@apollo/client'
|
|
2
|
+
import { useForm, UseFormReturn } from 'react-hook-form'
|
|
3
|
+
import { useFormGql, UseFormGqlMethods, UseFormGraphQlOptions } from './useFormGql'
|
|
4
|
+
import { useFormMuiRegister, UseMuiFormRegister } from './useFormMuiRegister'
|
|
5
|
+
import { useFormValidFields, UseFormValidReturn } from './useFormValidFields'
|
|
6
|
+
|
|
7
|
+
export type UseFormGqlMutationReturn<
|
|
8
|
+
Q extends Record<string, any> = Record<string, any>,
|
|
9
|
+
V extends Record<string, any> = Record<string, any>
|
|
10
|
+
> = UseFormGqlMethods<Q, V> &
|
|
11
|
+
UseFormReturn<V> & { muiRegister: UseMuiFormRegister<V>; valid: UseFormValidReturn<V> }
|
|
12
|
+
|
|
13
|
+
export function isFormGqlOperation<V, Q = Record<string, unknown>>(
|
|
14
|
+
form: UseFormReturn<V>,
|
|
15
|
+
): form is UseFormGqlMutationReturn<Q, V> {
|
|
16
|
+
return typeof (form as UseFormGqlMutationReturn<Q, V>).muiRegister === 'function'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function assertFormGqlOperation<V, Q = Record<string, unknown>>(
|
|
20
|
+
form: UseFormReturn<V>,
|
|
21
|
+
): asserts form is UseFormGqlMutationReturn<Q, V> {
|
|
22
|
+
if (typeof (form as UseFormGqlMutationReturn<Q, V>).muiRegister !== 'function') {
|
|
23
|
+
throw Error(`form must be one of 'useFromGqlMutation' or 'useFormGqlQuery'`)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useFormGqlMutation<Q, V>(
|
|
28
|
+
document: TypedDocumentNode<Q, V>,
|
|
29
|
+
options: UseFormGraphQlOptions<Q, V> = {},
|
|
30
|
+
operationOptions?: MutationHookOptions<Q, V>,
|
|
31
|
+
): UseFormGqlMutationReturn<Q, V> {
|
|
32
|
+
const form = useForm<V>(options)
|
|
33
|
+
const tuple = useMutation(document, operationOptions)
|
|
34
|
+
const operation = useFormGql({ document, form, tuple, ...options })
|
|
35
|
+
const muiRegister = useFormMuiRegister(form)
|
|
36
|
+
const valid = useFormValidFields(form, operation.required)
|
|
37
|
+
|
|
38
|
+
return { ...form, ...operation, valid, muiRegister }
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { LazyQueryHookOptions, TypedDocumentNode } from '@apollo/client'
|
|
2
|
+
import { useForm } from 'react-hook-form'
|
|
3
|
+
import { useFormGql, UseFormGraphQlOptions } from './useFormGql'
|
|
4
|
+
import { UseFormGqlMutationReturn } from './useFormGqlMutation'
|
|
5
|
+
import { useFormMuiRegister } from './useFormMuiRegister'
|
|
6
|
+
import { useFormValidFields } from './useFormValidFields'
|
|
7
|
+
import { useLazyQueryPromise } from './useLazyQueryPromise'
|
|
8
|
+
|
|
9
|
+
export type UseFormGqlQueryReturn<Q, V> = UseFormGqlMutationReturn<Q, V>
|
|
10
|
+
|
|
11
|
+
export function useFormGqlQuery<Q, V>(
|
|
12
|
+
document: TypedDocumentNode<Q, V>,
|
|
13
|
+
options: UseFormGraphQlOptions<Q, V> = {},
|
|
14
|
+
operationOptions?: LazyQueryHookOptions<Q, V>,
|
|
15
|
+
): UseFormGqlQueryReturn<Q, V> {
|
|
16
|
+
const form = useForm<V>(options)
|
|
17
|
+
const tuple = useLazyQueryPromise(document, operationOptions)
|
|
18
|
+
const operation = useFormGql({ document, form, tuple, ...options })
|
|
19
|
+
const muiRegister = useFormMuiRegister(form)
|
|
20
|
+
const valid = useFormValidFields(form, operation.required)
|
|
21
|
+
|
|
22
|
+
return { ...form, ...operation, valid, muiRegister }
|
|
23
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FieldValues,
|
|
3
|
+
FieldPath,
|
|
4
|
+
RegisterOptions,
|
|
5
|
+
UseFormRegisterReturn,
|
|
6
|
+
UseFormReturn,
|
|
7
|
+
} from 'react-hook-form'
|
|
8
|
+
|
|
9
|
+
export type UseMuiFormRegister<TFieldValues extends FieldValues> = <
|
|
10
|
+
TFieldName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
11
|
+
>(
|
|
12
|
+
name: TFieldName,
|
|
13
|
+
options?: RegisterOptions<TFieldValues, TFieldName>,
|
|
14
|
+
) => Omit<UseFormRegisterReturn, 'ref'> & { inputRef: UseFormRegisterReturn['ref'] }
|
|
15
|
+
|
|
16
|
+
export function useFormMuiRegister<V>({ register }: Pick<UseFormReturn<V>, 'register'>) {
|
|
17
|
+
const muiRegister: UseMuiFormRegister<V> = (name, opts) => {
|
|
18
|
+
const { ref: inputRef, ...fields } = register(name, opts)
|
|
19
|
+
return { ...fields, inputRef }
|
|
20
|
+
}
|
|
21
|
+
return muiRegister
|
|
22
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
FieldValues,
|
|
4
|
+
UseFormReturn,
|
|
5
|
+
Path,
|
|
6
|
+
FieldPathValue,
|
|
7
|
+
UnpackNestedValue,
|
|
8
|
+
} from 'react-hook-form'
|
|
9
|
+
|
|
10
|
+
export type UseFormPersistOptions<V> = {
|
|
11
|
+
/** Instance of current form */
|
|
12
|
+
form: UseFormReturn<V>
|
|
13
|
+
|
|
14
|
+
/** Name of the key how it will be stored in the storage. */
|
|
15
|
+
name: string
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* SessionStorage: Will not be avaiable when the user returns later (recommended). localStorage:
|
|
19
|
+
* Will be available when the user returns later.
|
|
20
|
+
*/
|
|
21
|
+
storage?: 'sessionStorage' | 'localStorage'
|
|
22
|
+
|
|
23
|
+
exclude?: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Will persist any dirty fields and store it in the sessionStorage/localStorage Will restory any
|
|
28
|
+
* dirty fields when the form is initialized
|
|
29
|
+
*/
|
|
30
|
+
export function useFormPersist<V>(options: UseFormPersistOptions<V>) {
|
|
31
|
+
const { form, name, storage = 'sessionStorage', exclude = [] } = options
|
|
32
|
+
const { setValue, watch, formState } = form
|
|
33
|
+
|
|
34
|
+
const dirtyFieldKeys = Object.keys(formState.dirtyFields) as Path<V>[]
|
|
35
|
+
const valuesJson = JSON.stringify(
|
|
36
|
+
Object.fromEntries(
|
|
37
|
+
dirtyFieldKeys.filter((f) => !exclude.includes(f)).map((field) => [field, watch(field)]),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// Restore changes
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
try {
|
|
44
|
+
if (typeof window === 'undefined') return
|
|
45
|
+
const storedFormStr = window[storage][name]
|
|
46
|
+
if (!storedFormStr) return
|
|
47
|
+
|
|
48
|
+
const storedValues = JSON.parse(storedFormStr) as FieldValues
|
|
49
|
+
if (storedValues) {
|
|
50
|
+
const entries = Object.entries(storedValues) as [
|
|
51
|
+
Path<V>,
|
|
52
|
+
UnpackNestedValue<FieldPathValue<V, Path<V>>>,
|
|
53
|
+
][]
|
|
54
|
+
entries.forEach((entry) => setValue(...entry, { shouldDirty: true, shouldValidate: true }))
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
//
|
|
58
|
+
}
|
|
59
|
+
}, [name, setValue, storage])
|
|
60
|
+
|
|
61
|
+
// Watch for changes
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
try {
|
|
64
|
+
if (typeof window === 'undefined') return
|
|
65
|
+
if (valuesJson !== '{}') window[storage][name] = valuesJson
|
|
66
|
+
else delete window[storage][name]
|
|
67
|
+
} catch {
|
|
68
|
+
//
|
|
69
|
+
}
|
|
70
|
+
}, [name, storage, valuesJson])
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { FieldValues, Path, UseFormReturn } from 'react-hook-form'
|
|
2
|
+
import { IsRequired } from './useGqlDocumentHandler'
|
|
3
|
+
|
|
4
|
+
export type UseFormValidReturn<TFieldValues> = Partial<Record<Path<TFieldValues>, boolean>>
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ### useFormValidFields
|
|
8
|
+
*
|
|
9
|
+
* Record field names as key and boolean as value indicating whether the field is valid
|
|
10
|
+
*/
|
|
11
|
+
export function useFormValidFields<TFieldValues extends FieldValues>(
|
|
12
|
+
form: Pick<UseFormReturn<TFieldValues>, 'watch' | 'formState'>,
|
|
13
|
+
required: IsRequired<TFieldValues>,
|
|
14
|
+
): UseFormValidReturn<TFieldValues> {
|
|
15
|
+
const { watch, formState } = form
|
|
16
|
+
const fields: Partial<Record<Path<TFieldValues>, boolean>> = {}
|
|
17
|
+
|
|
18
|
+
Object.keys(required).forEach((key) => {
|
|
19
|
+
fields[key] = !formState.errors[key] && watch(key as Path<TFieldValues>)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return fields
|
|
23
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { TypedDocumentNode } from '@apollo/client'
|
|
2
|
+
import {
|
|
3
|
+
DefinitionNode,
|
|
4
|
+
OperationDefinitionNode,
|
|
5
|
+
ValueNode,
|
|
6
|
+
NullValueNode,
|
|
7
|
+
ObjectValueNode,
|
|
8
|
+
ListValueNode,
|
|
9
|
+
VariableNode,
|
|
10
|
+
VariableDefinitionNode,
|
|
11
|
+
TypeNode,
|
|
12
|
+
ListTypeNode,
|
|
13
|
+
NamedTypeNode,
|
|
14
|
+
NonNullTypeNode,
|
|
15
|
+
OperationTypeNode,
|
|
16
|
+
} from 'graphql'
|
|
17
|
+
import { useMemo } from 'react'
|
|
18
|
+
import { FieldValues } from 'react-hook-form'
|
|
19
|
+
import { LiteralUnion } from 'type-fest'
|
|
20
|
+
|
|
21
|
+
type Scalars = {
|
|
22
|
+
ID: string
|
|
23
|
+
String: string
|
|
24
|
+
Boolean: boolean
|
|
25
|
+
Int: number
|
|
26
|
+
Float: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isOperationDefinition(
|
|
30
|
+
node: DefinitionNode | OperationDefinitionNode,
|
|
31
|
+
): node is OperationDefinitionNode {
|
|
32
|
+
return (node as OperationDefinitionNode).variableDefinitions !== undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type WithValueNode = Exclude<
|
|
36
|
+
ValueNode,
|
|
37
|
+
NullValueNode | ObjectValueNode | ListValueNode | VariableNode
|
|
38
|
+
>
|
|
39
|
+
|
|
40
|
+
function isWithValueNode(value: ValueNode | WithValueNode): value is WithValueNode {
|
|
41
|
+
return (value as WithValueNode).value !== undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type OptionalKeys<T> = { [k in keyof T]-?: undefined extends T[k] ? never : k }[keyof T]
|
|
45
|
+
|
|
46
|
+
export type IsRequired<V> = {
|
|
47
|
+
[k in keyof V]-?: undefined extends V[k] ? false : true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type DeepIsRequired<V> = {
|
|
51
|
+
[k in keyof V]-?: undefined extends V[k]
|
|
52
|
+
? false
|
|
53
|
+
: V[k] extends Record<string, unknown>
|
|
54
|
+
? DeepIsRequired<V[k]>
|
|
55
|
+
: true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type DeepStringify<V> = {
|
|
59
|
+
[k in keyof V]?: V[k] extends (infer U)[]
|
|
60
|
+
? string[]
|
|
61
|
+
: V[k] extends Record<string, unknown>
|
|
62
|
+
? DeepStringify<V[k]>
|
|
63
|
+
: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type FieldTypes = LiteralUnion<keyof Scalars, string> | FieldTypes[]
|
|
67
|
+
|
|
68
|
+
function variableType<T extends TypeNode>(type: T): FieldTypes {
|
|
69
|
+
if (type.kind === 'ListType') {
|
|
70
|
+
return [variableType(type.type)]
|
|
71
|
+
}
|
|
72
|
+
if (type.kind === 'NonNullType') {
|
|
73
|
+
return variableType(type.type)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return type.name.value as keyof Scalars
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type UseGqlDocumentHandler<V extends FieldValues> = {
|
|
80
|
+
type: OperationTypeNode | undefined
|
|
81
|
+
required: IsRequired<V>
|
|
82
|
+
defaultVariables: Partial<Pick<V, OptionalKeys<V>>>
|
|
83
|
+
encode: (
|
|
84
|
+
variables: { [k in keyof V]?: DeepStringify<V[k]> },
|
|
85
|
+
enc?: { [k in keyof V]: FieldTypes },
|
|
86
|
+
) => V
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function handlerFactory<Q, V>(document: TypedDocumentNode<Q, V>): UseGqlDocumentHandler<V> {
|
|
90
|
+
type Defaults = Partial<Pick<V, OptionalKeys<V>>>
|
|
91
|
+
type Encoding = { [k in keyof V]: FieldTypes }
|
|
92
|
+
type Required = IsRequired<V>
|
|
93
|
+
let requiredPartial: Partial<Required> = {}
|
|
94
|
+
let encodingPartial: Partial<Encoding> = {}
|
|
95
|
+
let defaultVariables: Defaults = {}
|
|
96
|
+
let type: OperationTypeNode | undefined
|
|
97
|
+
|
|
98
|
+
document.definitions.forEach((definition) => {
|
|
99
|
+
if (!isOperationDefinition(definition)) return
|
|
100
|
+
if (!definition.variableDefinitions) return
|
|
101
|
+
|
|
102
|
+
type = definition.operation
|
|
103
|
+
definition.variableDefinitions.forEach((variable: VariableDefinitionNode) => {
|
|
104
|
+
const name = variable.variable.name.value as keyof V
|
|
105
|
+
|
|
106
|
+
requiredPartial = { ...requiredPartial, [name]: variable.type.kind === 'NonNullType' }
|
|
107
|
+
encodingPartial = { ...encodingPartial, [name]: variableType(variable.type) }
|
|
108
|
+
|
|
109
|
+
if (variable.defaultValue && isWithValueNode(variable.defaultValue)) {
|
|
110
|
+
defaultVariables = {
|
|
111
|
+
...defaultVariables,
|
|
112
|
+
[name]: variable.defaultValue.value as unknown as Defaults[keyof Defaults],
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const required = requiredPartial as Required
|
|
119
|
+
const encoding = encodingPartial as Encoding
|
|
120
|
+
|
|
121
|
+
function heuristicEncode(val: string) {
|
|
122
|
+
if (Number(val).toString() === val) return Number(val)
|
|
123
|
+
if (val === 'true') return true
|
|
124
|
+
if (val === 'false') return false
|
|
125
|
+
return val
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function encodeItem(enc: FieldTypes, val: unknown) {
|
|
129
|
+
if (Array.isArray(enc)) return [encodeItem(enc[0], val)]
|
|
130
|
+
if (val && typeof val === 'object') {
|
|
131
|
+
return Object.fromEntries(Object.entries(val).map(([key, v]) => [key, heuristicEncode(v)]))
|
|
132
|
+
}
|
|
133
|
+
if (enc === 'Boolean') return Boolean(val)
|
|
134
|
+
if (enc === 'Float' || enc === 'Int') return Number(val)
|
|
135
|
+
return val
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function encode(variables: { [k in keyof V]?: DeepStringify<V[k]> }, enc = encoding) {
|
|
139
|
+
return Object.fromEntries(
|
|
140
|
+
Object.entries(variables).map(([key, val]) => [key, encodeItem(enc[key], val)]),
|
|
141
|
+
) as V
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { type, required, defaultVariables, encode }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function useGqlDocumentHandler<Q, V>(
|
|
148
|
+
document: TypedDocumentNode<Q, V>,
|
|
149
|
+
): UseGqlDocumentHandler<V> {
|
|
150
|
+
return useMemo(() => handlerFactory<Q, V>(document), [document])
|
|
151
|
+
}
|