Merge branch 'sso-paths' into 'main'
[ProtonMail-WebClient.git] / packages / pass / hooks / useItemDraft.ts
blob2faa617a12952a079eebad71dc23f3807fc6cbf5
1 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2 import { useDispatch, useSelector } from 'react-redux';
3 import { useHistory } from 'react-router-dom';
5 import type { FormikTouched } from 'formik';
6 import { type FormikContextType } from 'formik';
8 import { itemEq } from '@proton/pass/lib/items/item.predicates';
9 import { draftDiscard, draftSave } from '@proton/pass/store/actions';
10 import type { Draft, DraftBase } from '@proton/pass/store/reducers';
11 import { selectItemDrafts } from '@proton/pass/store/selectors';
12 import type { MaybeNull } from '@proton/pass/types';
13 import { first } from '@proton/pass/utils/array/first';
14 import debounce from '@proton/utils/debounce';
16 const SAVE_DRAFT_TIMEOUT = 500;
17 const DRAFT_HASH = '#draft';
19 export const useMatchDraftHash = (): boolean => {
20     const { location } = useHistory();
21     return useMemo(() => location.hash === DRAFT_HASH, []);
24 type UseItemDraftOptions<V extends {}> = DraftBase & {
25     /** Apply sanitization over the draft values */
26     sanitizeHydration?: (formData: Draft<V>['formData']) => Draft<V>['formData'];
27     /** Callback called right before saving the draft to state if you
28      * need to run some extra sanity checks */
29     sanitizeSave?: (formData: Draft<V>['formData']) => Draft<V>['formData'];
30     /** Retrieve the sanitized draft values after form hydration.
31      * This may be useful when chaining multiple form `setValue`
32      * calls inside the same render cycle to avoid `form.values`
33      * containing stale values (ie: see `Alias.new`) */
34     onHydrated?: (hydration: MaybeNull<Draft<V>['formData']>) => void;
37 /** Everytime the passed values change, this hook triggers a debounced
38  * dispatch with the form data. The `itemDraft` action throttles caching
39  * to avoid swarming the service worker with encryption requests */
40 export const useItemDraft = <V extends {}>(form: FormikContextType<V>, options: UseItemDraftOptions<V>) => {
41     const history = useHistory();
42     const isDraft = useMatchDraftHash();
43     const drafts = useSelector(selectItemDrafts, () => true);
44     const draft = useMemo(() => (isDraft ? first(drafts) : undefined), []);
46     const shouldInvalidate = useRef(
47         (() => {
48             const isEdit = draft === undefined && options.mode === 'edit';
49             return isEdit && drafts.some((entry) => entry.mode === 'edit' && itemEq(options)(entry));
50         })()
51     );
53     const { onHydrated, sanitizeHydration, sanitizeSave, ...draftOptions } = options;
54     const { values, dirty } = form;
55     const [ready, setReady] = useState<boolean>(false);
57     const dispatch = useDispatch();
59     const saveDraft = useCallback(
60         debounce(
61             (formData: V) => dispatch(draftSave({ ...draftOptions, formData: sanitizeSave?.(formData) ?? formData })),
62             SAVE_DRAFT_TIMEOUT
63         ),
64         []
65     );
67     useEffect(() => {
68         if (ready) {
69             const { location } = history;
70             const { hash } = location;
72             if (dirty) {
73                 saveDraft(values);
74                 if (hash !== DRAFT_HASH) history.replace({ ...location, hash: 'draft' });
75             } else {
76                 saveDraft.cancel();
77                 if (shouldInvalidate.current) {
78                     dispatch(draftDiscard(draftOptions));
79                     shouldInvalidate.current = false;
80                     history.replace({ ...location, hash: '' });
81                 }
82             }
83         }
85         return () => saveDraft.cancel();
86     }, [ready, values, dirty]);
88     useEffect(() => {
89         void (async () => {
90             if (draft) {
91                 const formValues = sanitizeHydration?.(draft.formData) ?? draft.formData;
93                 await form.setTouched(
94                     Object.keys(formValues).reduce<FormikTouched<any>>((touched, field) => {
95                         touched[field] = true;
96                         return touched;
97                     }, {}),
98                     false
99                 );
101                 await form.setValues(formValues, true);
103                 form.setErrors(await form.validateForm(draft.formData));
104                 onHydrated?.(formValues);
105             } else onHydrated?.(null);
107             setReady(true);
108         })();
110         return () => {
111             /* discard the draft if the component is unmounted :
112              * this either means the item was successfully saved or
113              * that the edit/creation discarded */
114             dispatch(draftDiscard(draftOptions));
115         };
116     }, []);
118     return draft;