From a073db367a401a1afcd1ca908679d1cf107478a5 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 12 Aug 2024 10:08:16 -0400 Subject: [PATCH 1/4] chore(elements): Clean up form hooks [SDKI-587] --- .../src/react/common/form/hooks/index.ts | 9 + .../common/form/hooks/use-field-context.ts | 6 + .../common/form/hooks/use-field-feedback.ts | 9 + .../common/form/hooks/use-field-state.ts | 43 ++ .../src/react/common/form/hooks/use-field.ts | 19 + .../src/react/common/form/hooks/use-form.ts | 30 ++ .../common/form/hooks/use-global-errors.ts | 9 + .../src/react/common/form/hooks/use-input.tsx | 225 ++++++++++ .../react/common/form/hooks/use-previous.ts | 11 + .../form/hooks/use-validity-state-context.ts | 4 + .../elements/src/react/common/form/index.tsx | 386 +----------------- .../utils/determine-input-type-from-name.ts | 31 ++ .../common/form/utils/enrich-field-state.ts | 11 + .../src/react/common/form/utils/index.ts | 2 + 14 files changed, 425 insertions(+), 370 deletions(-) create mode 100644 packages/elements/src/react/common/form/hooks/index.ts create mode 100644 packages/elements/src/react/common/form/hooks/use-field-context.ts create mode 100644 packages/elements/src/react/common/form/hooks/use-field-feedback.ts create mode 100644 packages/elements/src/react/common/form/hooks/use-field-state.ts create mode 100644 packages/elements/src/react/common/form/hooks/use-field.ts create mode 100644 packages/elements/src/react/common/form/hooks/use-form.ts create mode 100644 packages/elements/src/react/common/form/hooks/use-global-errors.ts create mode 100644 packages/elements/src/react/common/form/hooks/use-input.tsx create mode 100644 packages/elements/src/react/common/form/hooks/use-previous.ts create mode 100644 packages/elements/src/react/common/form/hooks/use-validity-state-context.ts create mode 100644 packages/elements/src/react/common/form/utils/determine-input-type-from-name.ts create mode 100644 packages/elements/src/react/common/form/utils/enrich-field-state.ts create mode 100644 packages/elements/src/react/common/form/utils/index.ts diff --git a/packages/elements/src/react/common/form/hooks/index.ts b/packages/elements/src/react/common/form/hooks/index.ts new file mode 100644 index 00000000000..3086f41f44b --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/index.ts @@ -0,0 +1,9 @@ +export { useField } from './use-field'; +export { useFieldContext, FieldContext } from './use-field-context'; +export { useFieldFeedback } from './use-field-feedback'; +export { useFieldState } from './use-field-state'; +export { useForm } from './use-form'; +export { useGlobalErrors } from './use-global-errors'; +export { useInput } from './use-input'; +export { usePrevious } from './use-previous'; +export { useValidityStateContext, ValidityStateContext } from './use-validity-state-context'; diff --git a/packages/elements/src/react/common/form/hooks/use-field-context.ts b/packages/elements/src/react/common/form/hooks/use-field-context.ts new file mode 100644 index 00000000000..7ffb519dc69 --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/use-field-context.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; + +import type { FieldDetails } from '~/internals/machines/form'; + +export const FieldContext = React.createContext | null>(null); +export const useFieldContext = () => React.useContext(FieldContext); diff --git a/packages/elements/src/react/common/form/hooks/use-field-feedback.ts b/packages/elements/src/react/common/form/hooks/use-field-feedback.ts new file mode 100644 index 00000000000..86b9e49f830 --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/use-field-feedback.ts @@ -0,0 +1,9 @@ +import { type FieldDetails, fieldFeedbackSelector, useFormSelector } from '~/internals/machines/form'; + +export function useFieldFeedback({ name }: Partial>) { + const feedback = useFormSelector(fieldFeedbackSelector(name)); + + return { + feedback, + }; +} diff --git a/packages/elements/src/react/common/form/hooks/use-field-state.ts b/packages/elements/src/react/common/form/hooks/use-field-state.ts new file mode 100644 index 00000000000..a30b8cf44b4 --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/use-field-state.ts @@ -0,0 +1,43 @@ +import { type FieldDetails, fieldHasValueSelector, useFormSelector } from '~/internals/machines/form'; + +import { FIELD_STATES, type FieldStates } from '../types'; +import { useFieldFeedback } from './use-field-feedback'; + +/** + * Given a field name, determine the current state of the field + */ +export function useFieldState({ name }: Partial>) { + const { feedback } = useFieldFeedback({ name }); + const hasValue = useFormSelector(fieldHasValueSelector(name)); + + /** + * If hasValue is false, the state should be idle + * The rest depends on the feedback type + */ + let state: FieldStates = FIELD_STATES.idle; + + if (!hasValue) { + state = FIELD_STATES.idle; + } + + switch (feedback?.type) { + case 'error': + state = FIELD_STATES.error; + break; + case 'warning': + state = FIELD_STATES.warning; + break; + case 'info': + state = FIELD_STATES.info; + break; + case 'success': + state = FIELD_STATES.success; + break; + default: + break; + } + + return { + state, + }; +} diff --git a/packages/elements/src/react/common/form/hooks/use-field.ts b/packages/elements/src/react/common/form/hooks/use-field.ts new file mode 100644 index 00000000000..e12f93123e1 --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/use-field.ts @@ -0,0 +1,19 @@ +import { type FieldDetails, fieldHasValueSelector, useFormSelector } from '~/internals/machines/form'; + +import { useFieldFeedback } from './use-field-feedback'; + +export function useField({ name }: Partial>) { + const hasValue = useFormSelector(fieldHasValueSelector(name)); + const { feedback } = useFieldFeedback({ name }); + + const shouldBeHidden = false; // TODO: Implement clerk-js utils + const hasError = feedback ? feedback.type === 'error' : false; + + return { + hasValue, + props: { + 'data-hidden': shouldBeHidden ? true : undefined, + serverInvalid: hasError, + }, + }; +} diff --git a/packages/elements/src/react/common/form/hooks/use-form.ts b/packages/elements/src/react/common/form/hooks/use-form.ts new file mode 100644 index 00000000000..357817baf7b --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/use-form.ts @@ -0,0 +1,30 @@ +import { useCallback } from 'react'; +import type { BaseActorRef } from 'xstate'; + +import { useGlobalErrors } from './use-global-errors'; + +/** + * Provides the form submission handler along with the form's validity via a data attribute + */ +export function useForm({ flowActor }: { flowActor?: BaseActorRef<{ type: 'SUBMIT' }> }) { + const { errors } = useGlobalErrors(); + + // Register the onSubmit handler for form submission + // TODO: merge user-provided submit handler + const onSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + if (flowActor) { + flowActor.send({ type: 'SUBMIT' }); + } + }, + [flowActor], + ); + + return { + props: { + ...(errors.length > 0 ? { 'data-global-error': true } : {}), + onSubmit, + }, + }; +} diff --git a/packages/elements/src/react/common/form/hooks/use-global-errors.ts b/packages/elements/src/react/common/form/hooks/use-global-errors.ts new file mode 100644 index 00000000000..3b28a13784a --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/use-global-errors.ts @@ -0,0 +1,9 @@ +import { globalErrorsSelector, useFormSelector } from '~/internals/machines/form'; + +export function useGlobalErrors() { + const errors = useFormSelector(globalErrorsSelector); + + return { + errors, + }; +} diff --git a/packages/elements/src/react/common/form/hooks/use-input.tsx b/packages/elements/src/react/common/form/hooks/use-input.tsx new file mode 100644 index 00000000000..3b17e340dba --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/use-input.tsx @@ -0,0 +1,225 @@ +import { Control as RadixControl, type FormControlProps } from '@radix-ui/react-form'; +import * as React from 'react'; + +import { ClerkElementsFieldError } from '~/internals/errors'; +import { fieldValueSelector, useFormSelector, useFormStore } from '~/internals/machines/form'; +import { usePassword } from '~/react/hooks/use-password.hook'; + +import type { FormInputProps } from '../index'; +import { OTP_LENGTH_DEFAULT, OTPInput, type OTPInputProps } from '../otp'; +import { determineInputTypeFromName, enrichFieldState } from '../utils'; +import { useFieldContext } from './use-field-context'; +import { useFieldState } from './use-field-state'; +import { usePrevious } from './use-previous'; +import { useValidityStateContext } from './use-validity-state-context'; + +// TODO: DRY +type PasswordInputProps = Exclude & { + validatePassword?: boolean; +}; + +export function useInput({ + name: inputName, + value: providedValue, + checked: providedChecked, + onChange: onChangeProp, + onBlur: onBlurProp, + onFocus: onFocusProp, + type: inputType, + ...passthroughProps +}: FormInputProps) { + // Inputs can be used outside a wrapper if desired, so safely destructure here + const fieldContext = useFieldContext(); + const rawName = inputName || fieldContext?.name; + const name = rawName === 'backup_code' ? 'code' : rawName; // `backup_code` is a special case of `code` + const { state: fieldState } = useFieldState({ name }); + const validity = useValidityStateContext(); + + if (!rawName || !name) { + throw new Error('Clerk: must be wrapped in a component or have a name prop.'); + } + + const ref = useFormStore(); + const [hasPassedValiation, setHasPassedValidation] = React.useState(false); + + const { validatePassword } = usePassword({ + onValidationComplexity: hasPassed => setHasPassedValidation(hasPassed), + onValidationSuccess: () => { + ref.send({ + type: 'FIELD.FEEDBACK.SET', + field: { name, feedback: { type: 'success', message: 'Your password meets all the necessary requirements.' } }, + }); + }, + onValidationError: (error, codes) => { + if (error) { + ref.send({ + type: 'FIELD.FEEDBACK.SET', + field: { + name, + feedback: { + type: 'error', + message: new ClerkElementsFieldError('password-validation-error', error), + codes, + }, + }, + }); + } + }, + onValidationWarning: (warning, codes) => + ref.send({ + type: 'FIELD.FEEDBACK.SET', + field: { name, feedback: { type: 'warning', message: warning, codes } }, + }), + onValidationInfo: (info, codes) => { + // TODO: If input is not focused, make this info an error + ref.send({ + type: 'FIELD.FEEDBACK.SET', + field: { + name, + feedback: { + type: 'info', + message: info, + codes, + }, + }, + }); + }, + }); + + const value = useFormSelector(fieldValueSelector(name)); + const prevValue = usePrevious(value); + const hasValue = Boolean(value); + const type = inputType ?? determineInputTypeFromName(rawName); + let nativeFieldType = type; + let shouldValidatePassword = false; + + if (type === 'password' || type === 'text') { + shouldValidatePassword = Boolean((passthroughProps as PasswordInputProps).validatePassword); + } + + if (nativeFieldType === 'otp' || nativeFieldType === 'backup_code') { + nativeFieldType = 'text'; + } + + // Register the field in the machine context + React.useEffect(() => { + if (!name) { + return; + } + + ref.send({ + type: 'FIELD.ADD', + field: { name, type: nativeFieldType, value: providedValue, checked: providedChecked }, + }); + + return () => ref.send({ type: 'FIELD.REMOVE', field: { name } }); + }, [ref]); // eslint-disable-line react-hooks/exhaustive-deps + + React.useEffect(() => { + if (!name) { + return; + } + + if ( + (type === 'checkbox' && providedChecked !== undefined) || + (type !== 'checkbox' && providedValue !== undefined) + ) { + ref.send({ + type: 'FIELD.UPDATE', + field: { name, value: providedValue, checked: providedChecked }, + }); + } + }, [name, type, ref, providedValue, providedChecked]); + + // Register the onChange handler for field updates to persist to the machine context + const onChange = React.useCallback( + (event: React.ChangeEvent) => { + onChangeProp?.(event); + if (!name) { + return; + } + ref.send({ type: 'FIELD.UPDATE', field: { name, value: event.target.value, checked: event.target.checked } }); + if (shouldValidatePassword) { + validatePassword(event.target.value); + } + }, + [ref, name, onChangeProp, shouldValidatePassword, validatePassword], + ); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + onBlurProp?.(event); + if (shouldValidatePassword && event.target.value !== prevValue) { + validatePassword(event.target.value); + } + }, + [onBlurProp, shouldValidatePassword, validatePassword, prevValue], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusProp?.(event); + if (shouldValidatePassword && event.target.value !== prevValue) { + validatePassword(event.target.value); + } + }, + [onFocusProp, shouldValidatePassword, validatePassword, prevValue], + ); + + // TODO: Implement clerk-js utils + const shouldBeHidden = false; + + const Element = type === 'otp' ? OTPInput : RadixControl; + + let props = {}; + if (type === 'otp') { + const p = passthroughProps as Omit; + const length = p.length || OTP_LENGTH_DEFAULT; + + props = { + 'data-otp-input': true, + autoComplete: 'one-time-code', + inputMode: 'numeric', + pattern: `[0-9]{${length}}`, + minLength: length, + maxLength: length, + onChange: (event: React.ChangeEvent) => { + // Only accept numbers + event.currentTarget.value = event.currentTarget.value.replace(/\D+/g, ''); + onChange(event); + }, + type: 'text', + spellCheck: false, + }; + } else if (type === 'backup_code') { + props = { + autoComplete: 'off', + type: 'text', + spellCheck: false, + }; + } else if (type === 'password' && shouldValidatePassword) { + props = { + 'data-has-passed-validation': hasPassedValiation ? true : undefined, + }; + } + + // Filter out invalid props that should not be passed through + // @ts-expect-error - Doesn't know about type narrowing by type here + const { validatePassword: _1, ...rest } = passthroughProps; + + return { + Element, + props: { + type, + value: value ?? '', + onChange, + onBlur, + onFocus, + 'data-hidden': shouldBeHidden ? true : undefined, + 'data-has-value': hasValue ? true : undefined, + 'data-state': enrichFieldState(validity, fieldState), + ...props, + ...rest, + }, + }; +} diff --git a/packages/elements/src/react/common/form/hooks/use-previous.ts b/packages/elements/src/react/common/form/hooks/use-previous.ts new file mode 100644 index 00000000000..e1bb334fbbc --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/use-previous.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; + +export function usePrevious(value: T): T | undefined { + const ref = React.useRef(); + + React.useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +} diff --git a/packages/elements/src/react/common/form/hooks/use-validity-state-context.ts b/packages/elements/src/react/common/form/hooks/use-validity-state-context.ts new file mode 100644 index 00000000000..f95cb49f2ac --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/use-validity-state-context.ts @@ -0,0 +1,4 @@ +import * as React from 'react'; + +export const ValidityStateContext = React.createContext(undefined); +export const useValidityStateContext = () => React.useContext(ValidityStateContext); diff --git a/packages/elements/src/react/common/form/index.tsx b/packages/elements/src/react/common/form/index.tsx index a8b5aa12df4..19c13909e59 100644 --- a/packages/elements/src/react/common/form/index.tsx +++ b/packages/elements/src/react/common/form/index.tsx @@ -4,6 +4,7 @@ import { eventComponentMounted } from '@clerk/shared/telemetry'; import type { Autocomplete } from '@clerk/types'; import { composeEventHandlers } from '@radix-ui/primitive'; import type { + Control as RadixControl, FormControlProps, FormControlProps as RadixFormControlProps, FormFieldProps as RadixFormFieldProps, @@ -12,7 +13,6 @@ import type { FormSubmitProps as RadixFormSubmitProps, } from '@radix-ui/react-form'; import { - Control as RadixControl, Field as RadixField, Form as RadixForm, FormMessage as RadixFormMessage, @@ -27,381 +27,27 @@ import type { BaseActorRef } from 'xstate'; import type { ClerkElementsError } from '~/internals/errors'; import { ClerkElementsFieldError, ClerkElementsRuntimeError } from '~/internals/errors'; -import type { FieldDetails } from '~/internals/machines/form'; -import { - fieldFeedbackSelector, - fieldHasValueSelector, - fieldValueSelector, - globalErrorsSelector, - useFormSelector, - useFormStore, -} from '~/internals/machines/form/form.context'; -import { usePassword } from '~/react/hooks/use-password.hook'; +import { useFormStore } from '~/internals/machines/form/form.context'; import { SignInRouterCtx } from '~/react/sign-in/context'; import { useSignInPasskeyAutofill } from '~/react/sign-in/context/router.context'; import type { ErrorCodeOrTuple } from '~/react/utils/generate-password-error-text'; import { isReactFragment } from '~/react/utils/is-react-fragment'; +import { + FieldContext, + useField, + useFieldContext, + useFieldFeedback, + useFieldState, + useForm, + useGlobalErrors, + useInput, + useValidityStateContext, + ValidityStateContext, +} from './hooks'; import type { OTPInputProps } from './otp'; -import { OTP_LENGTH_DEFAULT, OTPInput } from './otp'; -import { type ClerkFieldId, FIELD_STATES, type FieldStates } from './types'; - -/* ------------------------------------------------------------------------------------------------- - * Context - * -----------------------------------------------------------------------------------------------*/ - -const FieldContext = React.createContext | null>(null); -const useFieldContext = () => React.useContext(FieldContext); - -const ValidityStateContext = React.createContext(undefined); -const useValidityStateContext = () => React.useContext(ValidityStateContext); - -/* ------------------------------------------------------------------------------------------------- - * Utils - * -----------------------------------------------------------------------------------------------*/ - -const determineInputTypeFromName = (name: FormFieldProps['name']) => { - if (name === 'password' || name === 'confirmPassword' || name === 'currentPassword' || name === 'newPassword') { - return 'password' as const; - } - if (name === 'emailAddress') { - return 'email' as const; - } - if (name === 'phoneNumber') { - return 'tel' as const; - } - if (name === 'code') { - return 'otp' as const; - } - if (name === 'backup_code') { - return 'backup_code' as const; - } - - return 'text' as const; -}; - -/** - * Radix can return the ValidityState object, which contains the validity of the field. We need to merge this with our existing fieldState. - * When the ValidityState is valid: false, the fieldState should be overriden. Otherwise, it shouldn't change at all. - * @see https://www.radix-ui.com/primitives/docs/components/form#validitystate - * @see https://developer.mozilla.org/en-US/docs/Web/API/ValidityState - */ -const enrichFieldState = (validity: ValidityState | undefined, fieldState: FieldStates) => { - return validity?.valid === false ? FIELD_STATES.error : fieldState; -}; - -/* ------------------------------------------------------------------------------------------------- - * Hooks - * -----------------------------------------------------------------------------------------------*/ - -function usePrevious(value: T): T | undefined { - const ref = React.useRef(); - - React.useEffect(() => { - ref.current = value; - }, [value]); - - return ref.current; -} - -const useGlobalErrors = () => { - const errors = useFormSelector(globalErrorsSelector); - - return { - errors, - }; -}; - -const useFieldFeedback = ({ name }: Partial>) => { - const feedback = useFormSelector(fieldFeedbackSelector(name)); - - return { - feedback, - }; -}; - -/** - * Given a field name, determine the current state of the field - */ -const useFieldState = ({ name }: Partial>) => { - const { feedback } = useFieldFeedback({ name }); - const hasValue = useFormSelector(fieldHasValueSelector(name)); - - /** - * If hasValue is false, the state should be idle - * The rest depends on the feedback type - */ - let state: FieldStates = FIELD_STATES.idle; - - if (!hasValue) { - state = FIELD_STATES.idle; - } - - switch (feedback?.type) { - case 'error': - state = FIELD_STATES.error; - break; - case 'warning': - state = FIELD_STATES.warning; - break; - case 'info': - state = FIELD_STATES.info; - break; - case 'success': - state = FIELD_STATES.success; - break; - default: - break; - } - - return { - state, - }; -}; - -/** - * Provides the form submission handler along with the form's validity via a data attribute - */ -const useForm = ({ flowActor }: { flowActor?: BaseActorRef<{ type: 'SUBMIT' }> }) => { - const { errors } = useGlobalErrors(); - - // Register the onSubmit handler for form submission - // TODO: merge user-provided submit handler - const onSubmit = React.useCallback( - (event: React.FormEvent) => { - event.preventDefault(); - if (flowActor) { - flowActor.send({ type: 'SUBMIT' }); - } - }, - [flowActor], - ); - - return { - props: { - ...(errors.length > 0 ? { 'data-global-error': true } : {}), - onSubmit, - }, - }; -}; - -const useField = ({ name }: Partial>) => { - const hasValue = useFormSelector(fieldHasValueSelector(name)); - const { feedback } = useFieldFeedback({ name }); - - const shouldBeHidden = false; // TODO: Implement clerk-js utils - const hasError = feedback ? feedback.type === 'error' : false; - - return { - hasValue, - props: { - 'data-hidden': shouldBeHidden ? true : undefined, - serverInvalid: hasError, - }, - }; -}; - -const useInput = ({ - name: inputName, - value: providedValue, - checked: providedChecked, - type: inputType, - onChange: onChangeProp, - onBlur: onBlurProp, - onFocus: onFocusProp, - ...passthroughProps -}: FormInputProps) => { - // Inputs can be used outside a wrapper if desired, so safely destructure here - const fieldContext = useFieldContext(); - const rawName = inputName || fieldContext?.name; - const name = rawName === 'backup_code' ? 'code' : rawName; // `backup_code` is a special case of `code` - const { state: fieldState } = useFieldState({ name }); - const validity = useValidityStateContext(); - - if (!rawName || !name) { - throw new Error('Clerk: must be wrapped in a component or have a name prop.'); - } - - const ref = useFormStore(); - const [hasPassedValiation, setHasPassedValidation] = React.useState(false); - - const { validatePassword } = usePassword({ - onValidationComplexity: hasPassed => setHasPassedValidation(hasPassed), - onValidationSuccess: () => { - ref.send({ - type: 'FIELD.FEEDBACK.SET', - field: { name, feedback: { type: 'success', message: 'Your password meets all the necessary requirements.' } }, - }); - }, - onValidationError: (error, codes) => { - if (error) { - ref.send({ - type: 'FIELD.FEEDBACK.SET', - field: { - name, - feedback: { - type: 'error', - message: new ClerkElementsFieldError('password-validation-error', error), - codes, - }, - }, - }); - } - }, - onValidationWarning: (warning, codes) => - ref.send({ - type: 'FIELD.FEEDBACK.SET', - field: { name, feedback: { type: 'warning', message: warning, codes } }, - }), - onValidationInfo: (info, codes) => { - // TODO: If input is not focused, make this info an error - ref.send({ - type: 'FIELD.FEEDBACK.SET', - field: { - name, - feedback: { - type: 'info', - message: info, - codes, - }, - }, - }); - }, - }); - const value = useFormSelector(fieldValueSelector(name)); - const prevValue = usePrevious(value); - const hasValue = Boolean(value); - const type = inputType ?? determineInputTypeFromName(rawName); - let nativeFieldType = type; - let shouldValidatePassword = false; - - if (type === 'password' || type === 'text') { - shouldValidatePassword = Boolean((passthroughProps as PasswordInputProps).validatePassword); - } - - if (nativeFieldType === 'otp' || nativeFieldType === 'backup_code') { - nativeFieldType = 'text'; - } - - // Register the field in the machine context - React.useEffect(() => { - if (!name) { - return; - } - - ref.send({ - type: 'FIELD.ADD', - field: { name, type: nativeFieldType, value: providedValue, checked: providedChecked }, - }); - - return () => ref.send({ type: 'FIELD.REMOVE', field: { name } }); - }, [ref]); // eslint-disable-line react-hooks/exhaustive-deps - - // Register the onChange handler for field updates to persist to the machine context - const onChange = React.useCallback( - (event: React.ChangeEvent) => { - onChangeProp?.(event); - if (!name) { - return; - } - ref.send({ type: 'FIELD.UPDATE', field: { name, value: event.target.value, checked: event.target.checked } }); - if (shouldValidatePassword) { - validatePassword(event.target.value); - } - }, - [ref, name, onChangeProp, shouldValidatePassword, validatePassword], - ); - - const onBlur = React.useCallback( - (event: React.FocusEvent) => { - onBlurProp?.(event); - if (shouldValidatePassword && event.target.value !== prevValue) { - validatePassword(event.target.value); - } - }, - [onBlurProp, shouldValidatePassword, validatePassword, prevValue], - ); - - const onFocus = React.useCallback( - (event: React.FocusEvent) => { - onFocusProp?.(event); - if (shouldValidatePassword && event.target.value !== prevValue) { - validatePassword(event.target.value); - } - }, - [onFocusProp, shouldValidatePassword, validatePassword, prevValue], - ); - - React.useEffect(() => { - if (!name) { - return; - } - - if ( - (type === 'checkbox' && providedChecked !== undefined) || - (type !== 'checkbox' && providedValue !== undefined) - ) { - ref.send({ type: 'FIELD.UPDATE', field: { name, value: providedValue, checked: providedChecked } }); - } - }, [name, type, ref, providedValue, providedChecked]); - - // TODO: Implement clerk-js utils - const shouldBeHidden = false; - - const Element = type === 'otp' ? OTPInput : RadixControl; - - let props = {}; - if (type === 'otp') { - const p = passthroughProps as Omit; - const length = p.length || OTP_LENGTH_DEFAULT; - - props = { - 'data-otp-input': true, - autoComplete: 'one-time-code', - inputMode: 'numeric', - pattern: `[0-9]{${length}}`, - minLength: length, - maxLength: length, - onChange: (event: React.ChangeEvent) => { - // Only accept numbers - event.currentTarget.value = event.currentTarget.value.replace(/\D+/g, ''); - onChange(event); - }, - type: 'text', - spellCheck: false, - }; - } else if (type === 'backup_code') { - props = { - autoComplete: 'off', - type: 'text', - spellCheck: false, - }; - } else if (type === 'password' && shouldValidatePassword) { - props = { - 'data-has-passed-validation': hasPassedValiation ? true : undefined, - }; - } - - // Filter out invalid props that should not be passed through - // @ts-expect-error - Doesn't know about type narrowing by type here - const { validatePassword: _1, ...rest } = passthroughProps; - - return { - Element, - props: { - type, - value: value ?? '', - onChange, - onBlur, - onFocus, - 'data-hidden': shouldBeHidden ? true : undefined, - 'data-has-value': hasValue ? true : undefined, - 'data-state': enrichFieldState(validity, fieldState), - ...props, - ...rest, - }, - }; -}; +import type { ClerkFieldId, FieldStates } from './types'; +import { enrichFieldState } from './utils'; /* ------------------------------------------------------------------------------------------------- * Form diff --git a/packages/elements/src/react/common/form/utils/determine-input-type-from-name.ts b/packages/elements/src/react/common/form/utils/determine-input-type-from-name.ts new file mode 100644 index 00000000000..9e8d5654aa0 --- /dev/null +++ b/packages/elements/src/react/common/form/utils/determine-input-type-from-name.ts @@ -0,0 +1,31 @@ +import type { Autocomplete } from '@clerk/types'; +import type { FormFieldProps as RadixFormFieldProps } from '@radix-ui/react-form'; + +import type { ClerkFieldId, FieldStates } from '../types'; + +// TODO: Move this to a shared location +type FormFieldProps = Omit & { + name: Autocomplete; + alwaysShow?: boolean; + children: React.ReactNode | ((state: FieldStates) => React.ReactNode); +}; + +export function determineInputTypeFromName(name: FormFieldProps['name']) { + if (name === 'password' || name === 'confirmPassword' || name === 'currentPassword' || name === 'newPassword') { + return 'password' as const; + } + if (name === 'emailAddress') { + return 'email' as const; + } + if (name === 'phoneNumber') { + return 'tel' as const; + } + if (name === 'code') { + return 'otp' as const; + } + if (name === 'backup_code') { + return 'backup_code' as const; + } + + return 'text' as const; +} diff --git a/packages/elements/src/react/common/form/utils/enrich-field-state.ts b/packages/elements/src/react/common/form/utils/enrich-field-state.ts new file mode 100644 index 00000000000..a868875e62d --- /dev/null +++ b/packages/elements/src/react/common/form/utils/enrich-field-state.ts @@ -0,0 +1,11 @@ +import { FIELD_STATES, type FieldStates } from '../types'; + +/** + * Radix can return the ValidityState object, which contains the validity of the field. We need to merge this with our existing fieldState. + * When the ValidityState is valid: false, the fieldState should be overriden. Otherwise, it shouldn't change at all. + * @see https://www.radix-ui.com/primitives/docs/components/form#validitystate + * @see https://developer.mozilla.org/en-US/docs/Web/API/ValidityState + */ +export function enrichFieldState(validity: ValidityState | undefined, fieldState: FieldStates) { + return validity?.valid === false ? FIELD_STATES.error : fieldState; +} diff --git a/packages/elements/src/react/common/form/utils/index.ts b/packages/elements/src/react/common/form/utils/index.ts new file mode 100644 index 00000000000..6cc00720e0c --- /dev/null +++ b/packages/elements/src/react/common/form/utils/index.ts @@ -0,0 +1,2 @@ +export { enrichFieldState } from './enrich-field-state'; +export { determineInputTypeFromName } from './determine-input-type-from-name'; From 080fecb6991ae22fec2921f56c949e7cac38d608 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 12 Aug 2024 10:11:50 -0400 Subject: [PATCH 2/4] chore: Add changeset --- .changeset/bright-peaches-change.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bright-peaches-change.md diff --git a/.changeset/bright-peaches-change.md b/.changeset/bright-peaches-change.md new file mode 100644 index 00000000000..d02e67390a6 --- /dev/null +++ b/.changeset/bright-peaches-change.md @@ -0,0 +1,5 @@ +--- +"@clerk/elements": patch +--- + +Refactor form hooks and utils into separate files From 1247ec6374fca0bcb56ef3bccaf1f46628f579a6 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 12 Aug 2024 11:21:54 -0400 Subject: [PATCH 3/4] test(elements): Add basic tests for form hooks --- packages/elements/jest.config.js | 1 + .../__tests__/use-field-feedback.test.ts | 20 +++++ .../form/hooks/__tests__/use-form.test.tsx | 73 +++++++++++++++++++ .../hooks/__tests__/use-global-errors.test.ts | 28 +++++++ .../form/hooks/__tests__/use-previous.test.ts | 16 ++++ 5 files changed, 138 insertions(+) create mode 100644 packages/elements/src/react/common/form/hooks/__tests__/use-field-feedback.test.ts create mode 100644 packages/elements/src/react/common/form/hooks/__tests__/use-form.test.tsx create mode 100644 packages/elements/src/react/common/form/hooks/__tests__/use-global-errors.test.ts create mode 100644 packages/elements/src/react/common/form/hooks/__tests__/use-previous.test.ts diff --git a/packages/elements/jest.config.js b/packages/elements/jest.config.js index dbad9f5ad86..42f0580aa8f 100644 --- a/packages/elements/jest.config.js +++ b/packages/elements/jest.config.js @@ -7,6 +7,7 @@ module.exports = { globals: { PACKAGE_NAME: '@clerk/elements', PACKAGE_VERSION: '0.0.0-test', + __DEV__: false, }, displayName: name.replace('@clerk', ''), injectGlobals: true, diff --git a/packages/elements/src/react/common/form/hooks/__tests__/use-field-feedback.test.ts b/packages/elements/src/react/common/form/hooks/__tests__/use-field-feedback.test.ts new file mode 100644 index 00000000000..312a2e65257 --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/__tests__/use-field-feedback.test.ts @@ -0,0 +1,20 @@ +import { renderHook } from '@testing-library/react'; + +import * as internalFormHooks from '~/internals/machines/form/form.context'; + +import { useFieldFeedback } from '../use-field-feedback'; + +type Props = Parameters[0]; + +describe('useFieldFeedback', () => { + it('should correctly output feedback', () => { + const initialProps = { name: 'foo' }; + const returnValue = { codes: 'bar', message: 'baz', type: 'error' }; + + jest.spyOn(internalFormHooks, 'useFormSelector').mockReturnValue(returnValue); + + const { result } = renderHook((props: Props) => useFieldFeedback(props), { initialProps }); + + expect(result.current).toEqual({ feedback: returnValue }); + }); +}); diff --git a/packages/elements/src/react/common/form/hooks/__tests__/use-form.test.tsx b/packages/elements/src/react/common/form/hooks/__tests__/use-form.test.tsx new file mode 100644 index 00000000000..889c330fce1 --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/__tests__/use-form.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, renderHook } from '@testing-library/react'; +import { createActor, createMachine } from 'xstate'; + +import { ClerkElementsError } from '~/internals/errors'; + +import { useForm } from '../use-form'; +import * as errorHooks from '../use-global-errors'; + +describe('useForm', () => { + const machine = createMachine({ + on: { + RESET: '.idle', + }, + initial: 'idle', + states: { + idle: { + on: { + SUBMIT: 'success', + }, + }, + success: {}, + }, + }); + + const actor = createActor(machine).start(); + + beforeEach(() => { + actor.send({ type: 'RESET' }); + }); + + it('should correctly output props (no errors)', () => { + jest.spyOn(errorHooks, 'useGlobalErrors').mockReturnValue({ errors: [] }); + + const { result } = renderHook(() => useForm({ flowActor: actor })); + + expect(result.current).toEqual({ + props: { + onSubmit: expect.any(Function), + }, + }); + }); + + it('should correctly output props (has errors)', () => { + jest.spyOn(errorHooks, 'useGlobalErrors').mockReturnValue({ + errors: [new ClerkElementsError('email-link-verification-failed', 'Email verification failed')], + }); + + const { result } = renderHook(() => useForm({ flowActor: actor })); + + expect(result.current).toEqual({ + props: { + 'data-global-error': true, + onSubmit: expect.any(Function), + }, + }); + }); + + it('should create an onSubmit handler', () => { + jest.spyOn(errorHooks, 'useGlobalErrors').mockReturnValue({ errors: [] }); + + const { result } = renderHook(() => useForm({ flowActor: actor })); + const { getByTestId } = render( +
, + ); + + fireEvent.submit(getByTestId('form')); + + expect(actor.getSnapshot().value).toEqual('success'); + }); +}); diff --git a/packages/elements/src/react/common/form/hooks/__tests__/use-global-errors.test.ts b/packages/elements/src/react/common/form/hooks/__tests__/use-global-errors.test.ts new file mode 100644 index 00000000000..c322f1e2d8a --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/__tests__/use-global-errors.test.ts @@ -0,0 +1,28 @@ +import { renderHook } from '@testing-library/react'; + +import { ClerkElementsError } from '~/internals/errors'; +import * as internalFormHooks from '~/internals/machines/form/form.context'; + +import { useGlobalErrors } from '../use-global-errors'; + +describe('useGlobalErrors', () => { + it('should correctly output errors (no errors)', () => { + const returnValue: ClerkElementsError[] = []; + + jest.spyOn(internalFormHooks, 'useFormSelector').mockReturnValue([]); + + const { result } = renderHook(() => useGlobalErrors()); + + expect(result.current).toEqual({ errors: returnValue }); + }); + + it('should correctly output errors (has errors)', () => { + const returnValue = [new ClerkElementsError('email-link-verification-failed', 'Email verification failed')]; + + jest.spyOn(internalFormHooks, 'useFormSelector').mockReturnValue(returnValue); + + const { result } = renderHook(() => useGlobalErrors()); + + expect(result.current).toEqual({ errors: returnValue }); + }); +}); diff --git a/packages/elements/src/react/common/form/hooks/__tests__/use-previous.test.ts b/packages/elements/src/react/common/form/hooks/__tests__/use-previous.test.ts new file mode 100644 index 00000000000..50cec13a168 --- /dev/null +++ b/packages/elements/src/react/common/form/hooks/__tests__/use-previous.test.ts @@ -0,0 +1,16 @@ +import { renderHook } from '@testing-library/react'; + +import { usePrevious } from '../use-previous'; + +describe('usePrevious', () => { + it('should retain the previous value', () => { + const { result, rerender } = renderHook((props: string) => usePrevious(props), { initialProps: 'foo' }); + expect(result.current).toBeUndefined(); + + rerender('bar'); + expect(result.current).toBe('foo'); + + rerender('baz'); + expect(result.current).toBe('bar'); + }); +}); From a82b8b5ee6dda5e136abc55f949183d457b7db43 Mon Sep 17 00:00:00 2001 From: Tom Milewski Date: Mon, 12 Aug 2024 11:52:57 -0400 Subject: [PATCH 4/4] test(elements): Add utils tests --- .../determine-input-type-from-name.test.ts | 33 +++++++++++++++++++ .../__tests__/enrich-field-state.test.ts | 24 ++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 packages/elements/src/react/common/form/utils/__tests__/determine-input-type-from-name.test.ts create mode 100644 packages/elements/src/react/common/form/utils/__tests__/enrich-field-state.test.ts diff --git a/packages/elements/src/react/common/form/utils/__tests__/determine-input-type-from-name.test.ts b/packages/elements/src/react/common/form/utils/__tests__/determine-input-type-from-name.test.ts new file mode 100644 index 00000000000..f27db8f67a4 --- /dev/null +++ b/packages/elements/src/react/common/form/utils/__tests__/determine-input-type-from-name.test.ts @@ -0,0 +1,33 @@ +import { determineInputTypeFromName } from '../determine-input-type-from-name'; + +// Jest tests for determineInputTypeFromName function +describe('determineInputTypeFromName', () => { + it('should return "password" for password-related names', () => { + expect(determineInputTypeFromName('password')).toBe('password'); + expect(determineInputTypeFromName('confirmPassword')).toBe('password'); + expect(determineInputTypeFromName('currentPassword')).toBe('password'); + expect(determineInputTypeFromName('newPassword')).toBe('password'); + }); + + it('should return "email" for emailAddress', () => { + expect(determineInputTypeFromName('emailAddress')).toBe('email'); + }); + + it('should return "tel" for phoneNumber', () => { + expect(determineInputTypeFromName('phoneNumber')).toBe('tel'); + }); + + it('should return "otp" for code', () => { + expect(determineInputTypeFromName('code')).toBe('otp'); + }); + + it('should return "backup_code" for backup_code', () => { + expect(determineInputTypeFromName('backup_code')).toBe('backup_code'); + }); + + it('should return "text" for any other name', () => { + expect(determineInputTypeFromName('username')).toBe('text'); + expect(determineInputTypeFromName('firstName')).toBe('text'); + expect(determineInputTypeFromName('lastName')).toBe('text'); + }); +}); diff --git a/packages/elements/src/react/common/form/utils/__tests__/enrich-field-state.test.ts b/packages/elements/src/react/common/form/utils/__tests__/enrich-field-state.test.ts new file mode 100644 index 00000000000..1a90f9f7f75 --- /dev/null +++ b/packages/elements/src/react/common/form/utils/__tests__/enrich-field-state.test.ts @@ -0,0 +1,24 @@ +import { FIELD_STATES } from '../../types'; +import { enrichFieldState } from '../enrich-field-state'; + +describe('enrichFieldState', () => { + it('should return FIELD_STATES.error when validity is false', () => { + const validity = { valid: false } as ValidityState; + const fieldState = FIELD_STATES.success; + const result = enrichFieldState(validity, fieldState); + expect(result).toBe(FIELD_STATES.error); + }); + + it('should return the original fieldState when validity is true', () => { + const validity = { valid: true } as ValidityState; + const fieldState = FIELD_STATES.success; + const result = enrichFieldState(validity, fieldState); + expect(result).toBe(fieldState); + }); + + it('should return the original fieldState when validity is undefined', () => { + const fieldState = FIELD_STATES.success; + const result = enrichFieldState(undefined, fieldState); + expect(result).toBe(fieldState); + }); +});