Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TS migration] Migrate 'Form' component to TypeScript #32992

Merged
merged 57 commits into from
Jan 25, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
4eeca48
Start migrating new form
blazejkustra Dec 13, 2023
8359837
Merge branch 'main' into ts/NewForm
blazejkustra Dec 14, 2023
0d3e243
Merge branch 'main' into ts/NewForm
blazejkustra Dec 17, 2023
22b5aaa
Type FormProvider
blazejkustra Dec 17, 2023
2cba56d
Revert FAB changes
kowczarz Dec 18, 2023
f74be36
Fix FormActions types
kowczarz Dec 18, 2023
6b93011
Fix FormWrapper types
kowczarz Dec 18, 2023
91239d5
Review changes
kowczarz Dec 19, 2023
b0d589c
Update onyx keys
kowczarz Dec 19, 2023
010c221
Merge remote-tracking branch 'expensify/main' into ts/NewForm
kowczarz Dec 20, 2023
e4136e9
Update temporary types
kowczarz Dec 21, 2023
2fb8f0e
WIP
kowczarz Jan 2, 2024
c353d74
Cleanup types and comments
kowczarz Jan 3, 2024
2496e97
Merge remote-tracking branch 'expensify/main' into ts/NewForm
kowczarz Jan 3, 2024
91aca42
Cleanup
kowczarz Jan 3, 2024
592efa8
Fix TS errors
kowczarz Jan 3, 2024
f32eb83
Fix lint
kowczarz Jan 3, 2024
078b577
Remove redundant code
kowczarz Jan 3, 2024
1f3d8dd
Merge branch 'main' into ts/NewForm
blazejkustra Jan 4, 2024
c68ff38
Fix type imports
blazejkustra Jan 4, 2024
c09b5d8
WIP: Improve Form types
blazejkustra Jan 4, 2024
216aad4
Improve TextInput ref types
blazejkustra Jan 5, 2024
77ef442
Register input types tweaks
kowczarz Jan 5, 2024
6895f61
Merge remote-tracking branch 'expensify/main' into ts/NewForm
kowczarz Jan 10, 2024
32d5cc9
Modify Onyx typings
kowczarz Jan 10, 2024
0294932
Code review changes
kowczarz Jan 10, 2024
81d347a
Code review changes
kowczarz Jan 10, 2024
b0bb914
Merge remote-tracking branch 'expensify/main' into ts/NewForm
kowczarz Jan 12, 2024
256b9ae
Update types
kowczarz Jan 12, 2024
f470ff9
WIP
kowczarz Jan 15, 2024
8f34f5b
Merge remote-tracking branch 'expensify/main' into ts/NewForm
kowczarz Jan 16, 2024
9298809
WIP
kowczarz Jan 17, 2024
64ead2b
Rename REIMBURSEMENT_ACCOUNT_FORM_DRAFT
blazejkustra Jan 17, 2024
da57ba4
Clean rest of form PR
blazejkustra Jan 17, 2024
989c850
Merge branch 'main' into ts/NewForm
blazejkustra Jan 17, 2024
e744f15
Move onFixTheErrorsLinkPressed to a function
blazejkustra Jan 18, 2024
bba2f1a
Bring back DisplayNamePage
blazejkustra Jan 18, 2024
48b0867
Adjust the PR after CK review
blazejkustra Jan 18, 2024
defe9f5
Merge branch 'main' into ts/NewForm
blazejkustra Jan 18, 2024
e8bbd93
Add AddressSearch to valid inputs
blazejkustra Jan 18, 2024
5d9ffa9
Improve comments and InputWrapper props
blazejkustra Jan 18, 2024
eb8bb85
Final touches to InputWrapper
blazejkustra Jan 18, 2024
bc997b3
Fix reimbursment form bug
blazejkustra Jan 18, 2024
dc046ad
Merge branch 'main' into ts/NewForm
blazejkustra Jan 18, 2024
e1aa792
Fix prettier
blazejkustra Jan 18, 2024
e9d8f4d
Add clear errors utils
blazejkustra Jan 22, 2024
91a1125
Change the order of deps back to original
blazejkustra Jan 22, 2024
673e83a
Merge branch 'main' into ts/NewForm
blazejkustra Jan 22, 2024
34633dd
Merge branch 'main' into ts/NewForm
blazejkustra Jan 23, 2024
acdd24b
Fix typecheck after merging main
blazejkustra Jan 23, 2024
4bc5494
Use Onyx key instead of plain string
blazejkustra Jan 23, 2024
d4cc5be
Merge branch 'main' into ts/NewForm
blazejkustra Jan 24, 2024
18a10b9
Fix that user wasn't able to focus errors in the form
blazejkustra Jan 24, 2024
8181d2b
Merge branch 'main' into ts/NewForm
blazejkustra Jan 25, 2024
2f91117
Remove ts-expect-error after merging main
blazejkustra Jan 25, 2024
197cbef
Merge branch 'main' into ts/NewForm
blazejkustra Jan 25, 2024
2fa4582
Merge branch 'main' into ts/NewForm
blazejkustra Jan 25, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ type OnyxValues = {
[ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form | undefined;
[ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form;
};

type OnyxKeyValue<TOnyxKey extends (OnyxKey | OnyxCollectionKey) & keyof OnyxValues> = OnyxEntry<OnyxValues[TOnyxKey]>;
Expand Down
4 changes: 0 additions & 4 deletions src/components/Form/FormContext.js

This file was deleted.

12 changes: 12 additions & 0 deletions src/components/Form/FormContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {createContext} from 'react';
import {RegisterInput} from './types';

type FormContext = {
registerInput: RegisterInput;
};

export default createContext<FormContext>({
registerInput: () => {
throw new Error('Registered input should be wrapped with FormWrapper');
},
});
404 changes: 0 additions & 404 deletions src/components/Form/FormProvider.js

This file was deleted.

355 changes: 355 additions & 0 deletions src/components/Form/FormProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,355 @@
import lodashIsEqual from 'lodash/isEqual';
import React, {createRef, ForwardedRef, forwardRef, ReactNode, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {OnyxEntry, withOnyx} from 'react-native-onyx';
import * as ValidationUtils from '@libs/ValidationUtils';
import Visibility from '@libs/Visibility';
import * as FormActions from '@userActions/FormActions';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {Form, Network} from '@src/types/onyx';
import {Errors} from '@src/types/onyx/OnyxCommon';
import {isEmptyObject, isNotEmptyObject} from '@src/types/utils/EmptyObject';
import FormContext from './FormContext';
import FormWrapper from './FormWrapper';
import {FormProps, FormValuesFields, InputRef, InputRefs, OnyxFormKeyWithoutDraft, RegisterInput, ValueType} from './types';

// In order to prevent Checkbox focus loss when the user are focusing a TextInput and proceeds to toggle a CheckBox in web and mobile web.
// 200ms delay was chosen as a result of empirical testing.
// More details: https://github.com/Expensify/App/pull/16444#issuecomment-1482983426
const VALIDATE_DELAY = 200;

type InitialDefaultValue = false | Date | '';

function getInitialValueByType(valueType?: ValueType): InitialDefaultValue {
switch (valueType) {
case 'string':
return '';
case 'boolean':
return false;
case 'date':
return new Date();
default:
return '';
}
}

type FormProviderOnyxProps<TForm extends Form> = {
/** Contains the form state that must be accessed outside the component */
formState: OnyxEntry<TForm>;

/** Contains draft values for each input in the form */
draftValues: OnyxEntry<TForm>;

/** Information about the network */
network: OnyxEntry<Network>;
};

type FormProviderProps<TForm extends Form> = FormProviderOnyxProps<TForm> &
FormProps & {
/** Children to render. */
children: ((props: {inputValues: TForm}) => ReactNode) | ReactNode;

/** Callback to validate the form */
validate?: (values: FormValuesFields<TForm>) => Errors;

/** Should validate function be called when input loose focus */
shouldValidateOnBlur?: boolean;

/** Should validate function be called when the value of the input is changed */
shouldValidateOnChange?: boolean;
};

type FormRef<TForm extends Form> = {
resetForm: (optionalValue: TForm) => void;
};

function FormProvider<TForm extends Form>(
{
formID,
validate,
shouldValidateOnBlur = true,
shouldValidateOnChange = true,
children,
formState,
network,
enabledWhenOffline = false,
draftValues,
onSubmit,
...rest
}: FormProviderProps<Form & Record<string, unknown>>,
forwardedRef: ForwardedRef<FormRef<TForm>>,
) {
const inputRefs = useRef<InputRefs>({});
const touchedInputs = useRef<Record<string, boolean>>({});
const [inputValues, setInputValues] = useState<Record<string, unknown>>(() => ({...draftValues}));
const [errors, setErrors] = useState<Errors>({});
const hasServerError = useMemo(() => !!formState && !isEmptyObject(formState?.errors), [formState]);

const onValidate = useCallback(
(values: FormValuesFields<Record<string, unknown>>, shouldClearServerError = true) => {
const trimmedStringValues = ValidationUtils.prepareValues(values) as FormValuesFields<Record<string, unknown>>;

if (shouldClearServerError) {
FormActions.setErrors(formID, null);
}
FormActions.setErrorFields(formID, null);

const validateErrors = validate?.(trimmedStringValues) ?? {};

// Validate the input for html tags. It should supersede any other error
Object.entries(trimmedStringValues).forEach(([inputID, inputValue]) => {
// If the input value is empty OR is non-string, we don't need to validate it for HTML tags
if (!inputValue || typeof inputValue !== 'string') {
return;
}
const foundHtmlTagIndex = inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX);
const leadingSpaceIndex = inputValue.search(CONST.VALIDATE_FOR_LEADINGSPACES_HTML_TAG_REGEX);

// Return early if there are no HTML characters
if (leadingSpaceIndex === -1 && foundHtmlTagIndex === -1) {
return;
}

const matchedHtmlTags = inputValue.match(CONST.VALIDATE_FOR_HTML_TAG_REGEX);
let isMatch = CONST.WHITELISTED_TAGS.some((regex) => regex.test(inputValue));
// Check for any matches that the original regex (foundHtmlTagIndex) matched
if (matchedHtmlTags) {
// Check if any matched inputs does not match in WHITELISTED_TAGS list and return early if needed.
for (const htmlTag of matchedHtmlTags) {
isMatch = CONST.WHITELISTED_TAGS.some((regex) => regex.test(htmlTag));
if (!isMatch) {
break;
}
}
}

if (isMatch && leadingSpaceIndex === -1) {
return;
}

// Add a validation error here because it is a string value that contains HTML characters
validateErrors[inputID] = 'common.error.invalidCharacter';
});

if (typeof validateErrors !== 'object') {
throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}');
}

const touchedInputErrors = Object.fromEntries(Object.entries(validateErrors).filter(([inputID]) => touchedInputs.current[inputID]));

if (!lodashIsEqual(errors, touchedInputErrors)) {
setErrors(touchedInputErrors);
}

return touchedInputErrors;
},
[errors, formID, validate],
);

/** @param inputID - The inputID of the input being touched */
const setTouchedInput = useCallback(
(inputID: string) => {
touchedInputs.current[inputID] = true;
},
[touchedInputs],
);

const submit = useCallback(() => {
// Return early if the form is already submitting to avoid duplicate submission
if (formState?.isLoading) {
return;
}

// Prepare values before submitting
const trimmedStringValues = ValidationUtils.prepareValues(inputValues) as FormValuesFields<TForm>;

// Touches all form inputs, so we can validate the entire form
Object.keys(inputRefs.current).forEach((inputID) => (touchedInputs.current[inputID] = true));

// Validate form and return early if any errors are found
if (isNotEmptyObject(onValidate(trimmedStringValues))) {
return;
}

// Do not submit form if network is offline and the form is not enabled when offline
if (network?.isOffline && !enabledWhenOffline) {
return;
}

onSubmit(trimmedStringValues);
}, [enabledWhenOffline, formState?.isLoading, inputValues, network?.isOffline, onSubmit, onValidate]);

const resetForm = useCallback(
(optionalValue: FormValuesFields<Record<string, unknown>>) => {
Object.keys(inputValues).forEach((inputID) => {
setInputValues((prevState) => {
const copyPrevState = {...prevState};

touchedInputs.current[inputID] = false;
copyPrevState[inputID] = optionalValue[inputID] || '';

return copyPrevState;
});
});
setErrors({});
},
[inputValues],
);
useImperativeHandle(forwardedRef, () => ({
resetForm,
}));

const registerInput: RegisterInput = useCallback(
(inputID, inputProps) => {
const newRef: InputRef = inputRefs.current[inputID] ?? inputProps.ref ?? createRef();
if (inputRefs.current[inputID] !== newRef) {
inputRefs.current[inputID] = newRef;
}

if (inputProps.value !== undefined) {
inputValues[inputID] = inputProps.value;
} else if (inputProps.shouldSaveDraft && draftValues?.[inputID] !== undefined && inputValues[inputID] === undefined) {
inputValues[inputID] = draftValues[inputID];
} else if (inputProps.shouldUseDefaultValue && inputValues[inputID] === undefined) {
// We force the form to set the input value from the defaultValue props if there is a saved valid value
inputValues[inputID] = inputProps.defaultValue;
} else if (inputValues[inputID] === undefined) {
// We want to initialize the input value if it's undefined
inputValues[inputID] = inputProps.defaultValue === undefined ? getInitialValueByType(inputProps.valueType) : inputProps.defaultValue;
}

const errorFields = formState?.errorFields?.[inputID] ?? {};
const fieldErrorMessage =
Object.keys(errorFields)
.sort()
.map((key) => errorFields[key])
.at(-1) ?? '';

const inputRef = inputProps.ref;
return {
...inputProps,
ref:
typeof inputRef === 'function'
? (node) => {
inputRef(node);
if (typeof newRef !== 'function') {
newRef.current = node;
}
}
: newRef,
inputID,
key: inputProps.key ?? inputID,
errorText: errors[inputID] ?? fieldErrorMessage,
value: inputValues[inputID],
// As the text input is controlled, we never set the defaultValue prop
// as this is already happening by the value prop.
defaultValue: undefined,
onTouched: (event) => {
if (!inputProps.shouldSetTouchedOnBlurOnly) {
setTimeout(() => {
setTouchedInput(inputID);
}, VALIDATE_DELAY);
}
inputProps.onTouched?.(event);
},
onPress: (event) => {
if (!inputProps.shouldSetTouchedOnBlurOnly) {
setTimeout(() => {
setTouchedInput(inputID);
}, VALIDATE_DELAY);
}
inputProps.onPress?.(event);
},
onPressOut: (event) => {
// To prevent validating just pressed inputs, we need to set the touched input right after
// onValidate and to do so, we need to delay setTouchedInput of the same amount of time
// as the onValidate is delayed
if (!inputProps.shouldSetTouchedOnBlurOnly) {
setTimeout(() => {
setTouchedInput(inputID);
}, VALIDATE_DELAY);
}
inputProps.onPressOut?.(event);
},
onBlur: (event) => {
// Only run validation when user proactively blurs the input.
if (Visibility.isVisible() && Visibility.hasFocus()) {
const relatedTarget = 'nativeEvent' in event ? event?.nativeEvent?.relatedTarget : undefined;
const relatedTargetId = relatedTarget && 'id' in relatedTarget && typeof relatedTarget.id === 'string' && relatedTarget.id;
// We delay the validation in order to prevent Checkbox loss of focus when
// the user is focusing a TextInput and proceeds to toggle a CheckBox in
// web and mobile web platforms.

setTimeout(() => {
if (
relatedTargetId === CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID ||
relatedTargetId === CONST.OVERLAY.TOP_BUTTON_NATIVE_ID ||
relatedTargetId === CONST.BACK_BUTTON_NATIVE_ID
) {
return;
}
setTouchedInput(inputID);
if (shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
}
}, VALIDATE_DELAY);
}
inputProps.onBlur?.(event);
},
onInputChange: (value, key) => {
const inputKey = key ?? inputID;
setInputValues((prevState) => {
const newState = {
...prevState,
[inputKey]: value,
};

if (shouldValidateOnChange) {
onValidate(newState);
}
return newState;
});

if (inputProps.shouldSaveDraft && !(formID as string).includes('Draft')) {
FormActions.setDraftValues(formID as OnyxFormKeyWithoutDraft, {[inputKey]: value});
}
inputProps.onValueChange?.(value, inputKey);
},
};
},
[draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
);
const value = useMemo(() => ({registerInput}), [registerInput]);

return (
<FormContext.Provider value={value}>
{/* eslint-disable react/jsx-props-no-spreading */}
<FormWrapper
{...rest}
formID={formID}
onSubmit={submit}
inputRefs={inputRefs}
errors={errors}
enabledWhenOffline={enabledWhenOffline}
>
{typeof children === 'function' ? children({inputValues}) : children}
</FormWrapper>
</FormContext.Provider>
);
}

FormProvider.displayName = 'Form';

export default withOnyx<FormProviderProps<Form>, FormProviderOnyxProps<Form>>({
network: {
key: ONYXKEYS.NETWORK,
},
formState: {
key: ({formID}) => formID as typeof ONYXKEYS.FORMS.EDIT_TASK_FORM,
},
draftValues: {
key: (props) => `${props.formID}Draft` as typeof ONYXKEYS.FORMS.EDIT_TASK_FORM_DRAFT,
},
})(forwardRef(FormProvider)) as unknown as <TForm extends Form>(
component: React.ComponentType<FormProviderProps<TForm>>,
) => React.ComponentType<Omit<FormProviderProps<TForm>, keyof FormProviderOnyxProps<TForm>>>;
Loading
Loading