diff --git a/CHANGELOG.md b/CHANGELOG.md index ce869efbd..91ab49348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +## [0.36.0] - 2023-10-30 + +### Added + +- Introduced the capability to utilize custom components in the Email-Password based recipes' signup form fields by exposing inputComponent types. +- Implemented the functionality to assign default values to the form fields in the Email-Password based recipes. +- Enhanced the onChange function to operate independently without requiring an id field. + +Following is an example of how to use above features. + +```tsx +EmailPassword.init({ + signInAndUpFeature: { + signUpForm: { + formFields: [ + { + id: "select-dropdown", + label: "Select Option", + getDefaultValue: () => "option 2", + inputComponent: ({ value, name, onChange }) => ( + + ) + } + ] + } + } +}); +... +``` + ## [0.35.6] - 2023-10-16 ### Test changes diff --git a/examples/for-tests/src/App.js b/examples/for-tests/src/App.js index f65dac002..505beeb61 100644 --- a/examples/for-tests/src/App.js +++ b/examples/for-tests/src/App.js @@ -168,6 +168,159 @@ const formFields = [ }, ]; +const formFieldsWithDefault = [ + { + id: "country", + label: "Your Country", + placeholder: "Where do you live?", + optional: true, + getDefaultValue: () => "India", + }, + { + id: "select-dropdown", + label: "Select Option", + getDefaultValue: () => "option 2", + inputComponent: ({ value, name, onChange }) => ( + + ), + optional: true, + }, + { + id: "terms", + label: "", + optional: false, + getDefaultValue: () => "true", + inputComponent: ({ name, onChange, value }) => ( +
+ onChange(e.target.checked.toString())}> + I agree to the terms and conditions +
+ ), + validate: async (value) => { + if (value === "true") { + return undefined; + } + return "Please check Terms and conditions"; + }, + }, + { + id: "email", + label: "Email", + getDefaultValue: () => "test@one.com", + }, + { + id: "password", + label: "Password", + getDefaultValue: () => "fakepassword123", + }, +]; + +const incorrectFormFields = [ + { + id: "country", + label: "Your Country", + placeholder: "Where do you live?", + optional: true, + getDefaultValue: () => 23, // return should be a string + }, + { + id: "select-dropdown", + label: "Select Dropdown", + getDefaultValue: "option 2", // should be function + inputComponent: ({ value, name, onChange }) => ( + + ), + optional: true, + }, + { + // onChange accepts only string value, here we pass boolean + id: "terms", + label: "", + optional: false, + inputComponent: ({ name, onChange }) => ( +
+ onChange(e.target.checked)}> + I agree to the terms and conditions +
+ ), + validate: async (value) => { + if (value === "true") { + return undefined; + } + return "Please check Terms and conditions"; + }, + }, +]; + +const customFields = [ + { + id: "select-dropdown", + label: "Select Dropdown", + inputComponent: ({ value, name, onChange }) => ( + + ), + optional: true, + }, + { + id: "terms", + label: "", + optional: false, + inputComponent: ({ name, onChange }) => ( +
+ onChange(e.target.checked.toString())}> + I agree to the terms and conditions +
+ ), + validate: async (value) => { + if (value === "true") { + return undefined; + } + return "Please check Terms and conditions"; + }, + }, +]; + const testContext = getTestContext(); let recipeList = [ @@ -552,6 +705,41 @@ function getEmailVerificationConfigs({ disableDefaultUI }) { }); } +function getFormFields() { + if (localStorage.getItem("SHOW_INCORRECT_FIELDS") === "YES") { + if (localStorage.getItem("INCORRECT_ONCHANGE") === "YES") { + // since page-error blocks all the other errors + // use this filter to test specific error + return incorrectFormFields.filter(({ id }) => id === "terms"); + } + return incorrectFormFields; + } else if (localStorage.getItem("SHOW_CUSTOM_FIELDS_WITH_DEFAULT_VALUES") === "YES") { + return formFieldsWithDefault; + } else if (localStorage.getItem("SHOW_CUSTOM_FIELDS") === "YES") { + return customFields; + } + return formFields; +} + +function getSignInFormFields() { + let showDefaultFields = localStorage.getItem("SHOW_SIGNIN_DEFAULT_FIELDS"); + if (showDefaultFields === "YES") { + return { + formFields: [ + { + id: "email", + getDefaultValue: () => "abc@xyz.com", + }, + { + id: "password", + getDefaultValue: () => "fakepassword123", + }, + ], + }; + } + return {}; +} + function getEmailPasswordConfigs({ disableDefaultUI }) { return EmailPassword.init({ style: ` @@ -632,12 +820,13 @@ function getEmailPasswordConfigs({ disableDefaultUI }) { defaultToSignUp, signInForm: { style: theme, + ...getSignInFormFields(), }, signUpForm: { style: theme, privacyPolicyLink: "https://supertokens.com/legal/privacy-policy", termsOfServiceLink: "https://supertokens.com/legal/terms-and-conditions", - formFields, + formFields: getFormFields(), }, }, }); diff --git a/lib/build/emailpassword-shared7.js b/lib/build/emailpassword-shared7.js index 6415dc169..0abdcd070 100644 --- a/lib/build/emailpassword-shared7.js +++ b/lib/build/emailpassword-shared7.js @@ -320,26 +320,17 @@ var Input = function (_a) { */ function handleFocus() { if (onInputFocus !== undefined) { - onInputFocus({ - id: name, - value: value, - }); + onInputFocus(value); } } function handleBlur() { if (onInputBlur !== undefined) { - onInputBlur({ - id: name, - value: value, - }); + onInputBlur(value); } } function handleChange(event) { if (onChange) { - onChange({ - id: name, - value: event.target.value, - }); + onChange(event.target.value); } } if (autoComplete === undefined) { @@ -439,6 +430,79 @@ function Label(_a) { ); } +var fetchDefaultValue = function (field) { + if (field.getDefaultValue !== undefined) { + var defaultValue = field.getDefaultValue(); + if (typeof defaultValue !== "string") { + throw new Error("getDefaultValue for ".concat(field.id, " must return a string")); + } else { + return defaultValue; + } + } + return ""; +}; +function InputComponentWrapper(props) { + var field = props.field, + type = props.type, + fstate = props.fstate, + onInputFocus = props.onInputFocus, + onInputBlur = props.onInputBlur, + onInputChange = props.onInputChange; + var useCallbackOnInputFocus = React.useCallback( + function (value) { + onInputFocus({ + id: field.id, + value: value, + }); + }, + [onInputFocus, field] + ); + var useCallbackOnInputBlur = React.useCallback( + function (value) { + onInputBlur({ + id: field.id, + value: value, + }); + }, + [onInputBlur, field] + ); + var useCallbackOnInputChange = React.useCallback( + function (value) { + onInputChange({ + id: field.id, + value: value, + }); + }, + [onInputChange, field] + ); + return field.inputComponent !== undefined + ? jsxRuntime.jsx(field.inputComponent, { + type: type, + name: field.id, + validated: fstate.validated === true, + placeholder: field.placeholder, + value: fstate.value, + autoComplete: field.autoComplete, + autofocus: field.autofocus, + onInputFocus: useCallbackOnInputFocus, + onInputBlur: useCallbackOnInputBlur, + onChange: useCallbackOnInputChange, + hasError: fstate.error !== undefined, + }) + : jsxRuntime.jsx(Input, { + type: type, + name: field.id, + validated: fstate.validated === true, + placeholder: field.placeholder, + value: fstate.value, + autoComplete: field.autoComplete, + onInputFocus: useCallbackOnInputFocus, + onInputBlur: useCallbackOnInputBlur, + onChange: useCallbackOnInputChange, + autofocus: field.autofocus, + hasError: fstate.error !== undefined, + }); +} var FormBase = function (props) { var footer = props.footer, buttonLabel = props.buttonLabel, @@ -458,7 +522,7 @@ var FormBase = function (props) { ); var _a = React.useState( props.formFields.map(function (f) { - return { id: f.id, value: "" }; + return { id: f.id, value: fetchDefaultValue(f) }; }) ), fieldStates = _a[0], @@ -536,6 +600,9 @@ var FormBase = function (props) { ); var onInputChange = React.useCallback( function (field) { + if (typeof field.value !== "string") { + throw new Error("".concat(field.id, " value must be a string")); + } updateFieldState(field.id, function (os) { return genericComponentOverrideContext.__assign(genericComponentOverrideContext.__assign({}, os), { value: field.value, @@ -690,12 +757,10 @@ var FormBase = function (props) { } var fstate = fieldStates.find(function (s) { return s.id === field.id; - }) || { - id: field.id, - validated: false, - error: undefined, - value: "", - }; + }); + if (fstate === undefined) { + throw new Error("Should never come here"); + } return jsxRuntime.jsx( FormRow, genericComponentOverrideContext.__assign( @@ -710,33 +775,14 @@ var FormBase = function (props) { value: field.label, showIsRequired: field.showIsRequired, })), - field.inputComponent !== undefined - ? jsxRuntime.jsx(field.inputComponent, { - type: type, - name: field.id, - validated: fstate.validated === true, - placeholder: field.placeholder, - value: fstate.value, - autoComplete: field.autoComplete, - autofocus: field.autofocus, - onInputFocus: onInputFocus, - onInputBlur: onInputBlur, - onChange: onInputChange, - hasError: fstate.error !== undefined, - }) - : jsxRuntime.jsx(Input, { - type: type, - name: field.id, - validated: fstate.validated === true, - placeholder: field.placeholder, - value: fstate.value, - autoComplete: field.autoComplete, - onInputFocus: onInputFocus, - onInputBlur: onInputBlur, - onChange: onInputChange, - autofocus: field.autofocus, - hasError: fstate.error !== undefined, - }), + jsxRuntime.jsx(InputComponentWrapper, { + type: type, + field: field, + fstate: fstate, + onInputFocus: onInputFocus, + onInputBlur: onInputBlur, + onInputChange: onInputChange, + }), fstate.error && jsxRuntime.jsx(InputError, { error: fstate.error }), ], }), diff --git a/lib/build/passwordless-shared3.js b/lib/build/passwordless-shared3.js index 54b23e5a7..73e421acf 100644 --- a/lib/build/passwordless-shared3.js +++ b/lib/build/passwordless-shared3.js @@ -2569,18 +2569,12 @@ function PhoneNumberInput(_a) { value = _a.value; function handleFocus() { if (onInputFocus !== undefined) { - onInputFocus({ - id: name, - value: value, - }); + onInputFocus(value); } } function handleBlur() { if (onInputBlur !== undefined) { - onInputBlur({ - id: name, - value: value, - }); + onInputBlur(value); } } var _b = React.useState(), @@ -2596,10 +2590,7 @@ function PhoneNumberInput(_a) { var handleChange = React.useCallback( function (newValue) { if (onChangeRef.current !== undefined) { - onChangeRef.current({ - id: name, - value: newValue, - }); + onChangeRef.current(newValue); } }, [onChangeRef] @@ -2607,10 +2598,7 @@ function PhoneNumberInput(_a) { var handleCountryChange = React.useCallback( function (ev) { if (onChangeRef.current !== undefined && phoneInputInstance !== undefined) { - onChangeRef.current({ - id: name, - value: ev.target.value, - }); + onChangeRef.current(ev.target.value); } }, [onChangeRef] diff --git a/lib/build/recipe/emailpassword/components/library/input.d.ts b/lib/build/recipe/emailpassword/components/library/input.d.ts index 44893e14a..973042e7c 100644 --- a/lib/build/recipe/emailpassword/components/library/input.d.ts +++ b/lib/build/recipe/emailpassword/components/library/input.d.ts @@ -1,5 +1,4 @@ /// -import type { APIFormField } from "../../../../types"; export declare type InputProps = { type: string; name: string; @@ -9,9 +8,9 @@ export declare type InputProps = { hasError: boolean; placeholder: string; value: string; - onInputBlur?: (field: APIFormField) => void; - onInputFocus?: (field: APIFormField) => void; - onChange?: (field: APIFormField) => void; + onInputBlur?: (value: string) => void; + onInputFocus?: (value: string) => void; + onChange?: (value: string) => void; }; declare const Input: React.FC; export default Input; diff --git a/lib/build/recipe/emailpassword/components/themes/resetPasswordUsingToken/resetPasswordEmail.d.ts b/lib/build/recipe/emailpassword/components/themes/resetPasswordUsingToken/resetPasswordEmail.d.ts index 8ff8a9b05..bcc6e2b05 100644 --- a/lib/build/recipe/emailpassword/components/themes/resetPasswordUsingToken/resetPasswordEmail.d.ts +++ b/lib/build/recipe/emailpassword/components/themes/resetPasswordUsingToken/resetPasswordEmail.d.ts @@ -1,7 +1,7 @@ /// export declare const ResetPasswordEmail: import("react").ComponentType< import("../../../../../types").ThemeBaseProps & { - formFields: import("../../../types").FormFieldThemeProps[]; + formFields: Omit[]; error: string | undefined; } & { recipeImplementation: import("supertokens-web-js/recipe/emailpassword").RecipeInterface; diff --git a/lib/build/recipe/emailpassword/components/themes/resetPasswordUsingToken/submitNewPassword.d.ts b/lib/build/recipe/emailpassword/components/themes/resetPasswordUsingToken/submitNewPassword.d.ts index b8ec36fd7..9c795dce2 100644 --- a/lib/build/recipe/emailpassword/components/themes/resetPasswordUsingToken/submitNewPassword.d.ts +++ b/lib/build/recipe/emailpassword/components/themes/resetPasswordUsingToken/submitNewPassword.d.ts @@ -1,7 +1,7 @@ /// export declare const SubmitNewPassword: import("react").ComponentType< import("../../../../../types").ThemeBaseProps & { - formFields: import("../../../types").FormFieldThemeProps[]; + formFields: Omit[]; error: string | undefined; } & { recipeImplementation: import("supertokens-web-js/recipe/emailpassword").RecipeInterface; diff --git a/lib/build/recipe/emailpassword/components/themes/signInAndUp/signIn.d.ts b/lib/build/recipe/emailpassword/components/themes/signInAndUp/signIn.d.ts index 2b40c960b..64639bb96 100644 --- a/lib/build/recipe/emailpassword/components/themes/signInAndUp/signIn.d.ts +++ b/lib/build/recipe/emailpassword/components/themes/signInAndUp/signIn.d.ts @@ -1,7 +1,7 @@ /// export declare const SignIn: import("react").ComponentType< import("../../../../../types").ThemeBaseProps & { - formFields: import("../../../types").FormFieldThemeProps[]; + formFields: Omit[]; error: string | undefined; } & { recipeImplementation: import("supertokens-web-js/recipe/emailpassword").RecipeInterface; diff --git a/lib/build/recipe/emailpassword/components/themes/signInAndUp/signInForm.d.ts b/lib/build/recipe/emailpassword/components/themes/signInAndUp/signInForm.d.ts index 7dbc1321c..f57175ca5 100644 --- a/lib/build/recipe/emailpassword/components/themes/signInAndUp/signInForm.d.ts +++ b/lib/build/recipe/emailpassword/components/themes/signInAndUp/signInForm.d.ts @@ -1,7 +1,7 @@ /// export declare const SignInForm: import("react").ComponentType< import("../../../../../types").ThemeBaseProps & { - formFields: import("../../../types").FormFieldThemeProps[]; + formFields: Omit[]; error: string | undefined; } & { recipeImplementation: import("supertokens-web-js/recipe/emailpassword").RecipeInterface; diff --git a/lib/build/recipe/emailpassword/components/themes/signInAndUp/signUp.d.ts b/lib/build/recipe/emailpassword/components/themes/signInAndUp/signUp.d.ts index 380a6e333..32bc386b1 100644 --- a/lib/build/recipe/emailpassword/components/themes/signInAndUp/signUp.d.ts +++ b/lib/build/recipe/emailpassword/components/themes/signInAndUp/signUp.d.ts @@ -1,14 +1,13 @@ /// export declare const SignUp: import("react").ComponentType< import("../../../../../types").ThemeBaseProps & { - formFields: import("../../../types").FormFieldThemeProps[]; - error: string | undefined; - } & { recipeImplementation: import("supertokens-web-js/recipe/emailpassword").RecipeInterface; clearError: () => void; onError: (error: string) => void; config: import("../../../types").NormalisedConfig; signInClicked?: (() => void) | undefined; onSuccess: (result: { user: import("supertokens-web-js/types").User }) => void; + formFields: import("../../../types").FormFieldThemeProps[]; + error: string | undefined; } >; diff --git a/lib/build/recipe/emailpassword/components/themes/signInAndUp/signUpForm.d.ts b/lib/build/recipe/emailpassword/components/themes/signInAndUp/signUpForm.d.ts index bbe4a2eb2..85b7e4c8d 100644 --- a/lib/build/recipe/emailpassword/components/themes/signInAndUp/signUpForm.d.ts +++ b/lib/build/recipe/emailpassword/components/themes/signInAndUp/signUpForm.d.ts @@ -1,15 +1,14 @@ /// export declare const SignUpForm: import("react").ComponentType< import("../../../../../types").ThemeBaseProps & { - formFields: import("../../../types").FormFieldThemeProps[]; - error: string | undefined; - } & { recipeImplementation: import("supertokens-web-js/recipe/emailpassword").RecipeInterface; clearError: () => void; onError: (error: string) => void; config: import("../../../types").NormalisedConfig; signInClicked?: (() => void) | undefined; onSuccess: (result: { user: import("supertokens-web-js/types").User }) => void; + formFields: import("../../../types").FormFieldThemeProps[]; + error: string | undefined; } & { header?: JSX.Element | undefined; footer?: JSX.Element | undefined; diff --git a/lib/build/recipe/emailpassword/types.d.ts b/lib/build/recipe/emailpassword/types.d.ts index d819f58c8..d1cb6e9ea 100644 --- a/lib/build/recipe/emailpassword/types.d.ts +++ b/lib/build/recipe/emailpassword/types.d.ts @@ -26,7 +26,6 @@ import type { NormalisedConfig as NormalisedAuthRecipeModuleConfig, UserInput as AuthRecipeModuleUserInput, } from "../authRecipe/types"; -import type React from "react"; import type { Dispatch } from "react"; import type { OverrideableBuilder } from "supertokens-js-override"; import type { RecipeInterface } from "supertokens-web-js/recipe/emailpassword"; @@ -78,12 +77,16 @@ export declare type NormalisedSignInAndUpFeatureConfig = { signInForm: NormalisedSignInFormFeatureConfig; }; export declare type SignUpFormFeatureUserInput = FeatureBaseConfig & { - formFields?: FormFieldSignUpConfig[]; + formFields?: (FormField & { + inputComponent?: (props: InputProps) => JSX.Element; + })[]; privacyPolicyLink?: string; termsOfServiceLink?: string; }; export declare type NormalisedSignUpFormFeatureConfig = NormalisedBaseConfig & { - formFields: NormalisedFormField[]; + formFields: (NormalisedFormField & { + inputComponent?: (props: InputProps) => JSX.Element; + })[]; privacyPolicyLink?: string; termsOfServiceLink?: string; }; @@ -94,7 +97,6 @@ export declare type NormalisedSignInFormFeatureConfig = NormalisedBaseConfig & { formFields: NormalisedFormField[]; }; export declare type FormFieldSignInConfig = FormFieldBaseConfig; -export declare type FormFieldSignUpConfig = FormField; export declare type ResetPasswordUsingTokenUserInput = { disableDefaultUI?: boolean; submitNewPasswordForm?: FeatureBaseConfig; @@ -111,11 +113,11 @@ export declare type NormalisedSubmitNewPasswordForm = FeatureBaseConfig & { export declare type NormalisedEnterEmailForm = FeatureBaseConfig & { formFields: NormalisedFormField[]; }; -declare type FormThemeBaseProps = ThemeBaseProps & { - formFields: FormFieldThemeProps[]; +declare type NonSignUpFormThemeBaseProps = ThemeBaseProps & { + formFields: Omit[]; error: string | undefined; }; -export declare type SignInThemeProps = FormThemeBaseProps & { +export declare type SignInThemeProps = NonSignUpFormThemeBaseProps & { recipeImplementation: RecipeInterface; clearError: () => void; onError: (error: string) => void; @@ -124,13 +126,15 @@ export declare type SignInThemeProps = FormThemeBaseProps & { forgotPasswordClick: () => void; onSuccess: (result: { user: User }) => void; }; -export declare type SignUpThemeProps = FormThemeBaseProps & { +export declare type SignUpThemeProps = ThemeBaseProps & { recipeImplementation: RecipeInterface; clearError: () => void; onError: (error: string) => void; config: NormalisedConfig; signInClicked?: () => void; onSuccess: (result: { user: User }) => void; + formFields: FormFieldThemeProps[]; + error: string | undefined; }; export declare type SignInAndUpThemeProps = { signInForm: SignInThemeProps; @@ -144,9 +148,9 @@ export declare type SignInAndUpThemeProps = { }; export declare type FormFieldThemeProps = NormalisedFormField & { labelComponent?: JSX.Element; - inputComponent?: React.FC; showIsRequired?: boolean; clearOnSubmit?: boolean; + inputComponent?: (props: InputProps) => JSX.Element; }; export declare type FormFieldError = { id: string; @@ -192,7 +196,7 @@ export declare type ResetPasswordUsingTokenThemeProps = { config: NormalisedConfig; userContext?: any; }; -export declare type EnterEmailProps = FormThemeBaseProps & { +export declare type EnterEmailProps = NonSignUpFormThemeBaseProps & { recipeImplementation: RecipeInterface; error: string | undefined; clearError: () => void; @@ -200,7 +204,7 @@ export declare type EnterEmailProps = FormThemeBaseProps & { config: NormalisedConfig; onBackButtonClicked: () => void; }; -export declare type SubmitNewPasswordProps = FormThemeBaseProps & { +export declare type SubmitNewPasswordProps = NonSignUpFormThemeBaseProps & { recipeImplementation: RecipeInterface; error: string | undefined; clearError: () => void; diff --git a/lib/build/types.d.ts b/lib/build/types.d.ts index 1aa7f2568..93605a26e 100644 --- a/lib/build/types.d.ts +++ b/lib/build/types.d.ts @@ -89,6 +89,7 @@ export declare type FormFieldBaseConfig = { id: string; label: string; placeholder?: string; + getDefaultValue?: () => string; }; export declare type FormField = FormFieldBaseConfig & { validate?: (value: any) => Promise; @@ -106,6 +107,7 @@ export declare type NormalisedFormField = { optional: boolean; autoComplete?: string; autofocus?: boolean; + getDefaultValue?: () => string; }; export declare type ReactComponentClass

= ComponentClass | ((props: P) => JSX.Element); export declare type FeatureBaseConfig = { diff --git a/lib/ts/recipe/emailpassword/components/library/formBase.tsx b/lib/ts/recipe/emailpassword/components/library/formBase.tsx index 1dabbd92a..f9d5e201b 100644 --- a/lib/ts/recipe/emailpassword/components/library/formBase.tsx +++ b/lib/ts/recipe/emailpassword/components/library/formBase.tsx @@ -25,7 +25,7 @@ import STGeneralError from "supertokens-web-js/utils/error"; import { MANDATORY_FORM_FIELDS_ID_ARRAY } from "../../constants"; import type { APIFormField } from "../../../../types"; -import type { FormBaseProps } from "../../types"; +import type { FormBaseProps, FormFieldThemeProps } from "../../types"; import type { FormEvent } from "react"; import { Button, FormRow, Input, InputError, Label } from "."; @@ -37,6 +37,89 @@ type FieldState = { value: string; }; +const fetchDefaultValue = (field: FormFieldThemeProps): string => { + if (field.getDefaultValue !== undefined) { + const defaultValue = field.getDefaultValue(); + if (typeof defaultValue !== "string") { + throw new Error(`getDefaultValue for ${field.id} must return a string`); + } else { + return defaultValue; + } + } + return ""; +}; + +function InputComponentWrapper(props: { + field: FormFieldThemeProps; + type: string; + fstate: FieldState; + onInputFocus: (field: APIFormField) => void; + onInputBlur: (field: APIFormField) => void; + onInputChange: (field: APIFormField) => void; +}) { + const { field, type, fstate, onInputFocus, onInputBlur, onInputChange } = props; + + const useCallbackOnInputFocus = useCallback<(value: string) => void>( + (value) => { + onInputFocus({ + id: field.id, + value, + }); + }, + [onInputFocus, field] + ); + + const useCallbackOnInputBlur = useCallback<(value: string) => void>( + (value) => { + onInputBlur({ + id: field.id, + value, + }); + }, + [onInputBlur, field] + ); + + const useCallbackOnInputChange = useCallback( + (value) => { + onInputChange({ + id: field.id, + value, + }); + }, + [onInputChange, field] + ); + + return field.inputComponent !== undefined ? ( + + ) : ( + + ); +} + export const FormBase: React.FC> = (props) => { const { footer, buttonLabel, showLabels, validateOnBlur, formFields } = props; @@ -50,7 +133,7 @@ export const FormBase: React.FC> = (props) => { }, [unmounting]); const [fieldStates, setFieldStates] = useState( - props.formFields.map((f) => ({ id: f.id, value: "" })) + props.formFields.map((f) => ({ id: f.id, value: fetchDefaultValue(f) })) ); const [isLoading, setIsLoading] = useState(false); @@ -95,6 +178,9 @@ export const FormBase: React.FC> = (props) => { const onInputChange = useCallback( (field: APIFormField) => { + if (typeof field.value !== "string") { + throw new Error(`${field.id} value must be a string`); + } updateFieldState(field.id, (os) => ({ ...os, value: field.value, error: undefined })); props.clearError(); }, @@ -191,12 +277,11 @@ export const FormBase: React.FC> = (props) => { if (field.id === "confirm-password") { type = "password"; } - const fstate: FieldState = fieldStates.find((s) => s.id === field.id) || { - id: field.id, - validated: false, - error: undefined, - value: "", - }; + + const fstate: FieldState | undefined = fieldStates.find((s) => s.id === field.id); + if (fstate === undefined) { + throw new Error("Should never come here"); + } return ( @@ -208,36 +293,14 @@ export const FormBase: React.FC> = (props) => { diff --git a/lib/ts/recipe/emailpassword/components/library/input.tsx b/lib/ts/recipe/emailpassword/components/library/input.tsx index 70f34f331..0f1877e1b 100644 --- a/lib/ts/recipe/emailpassword/components/library/input.tsx +++ b/lib/ts/recipe/emailpassword/components/library/input.tsx @@ -20,7 +20,6 @@ import CheckedIcon from "../../../../components/assets/checkedIcon"; import ErrorIcon from "../../../../components/assets/errorIcon"; import ShowPasswordIcon from "../../../../components/assets/showPasswordIcon"; -import type { APIFormField } from "../../../../types"; import type { ChangeEvent } from "react"; export type InputProps = { @@ -32,9 +31,9 @@ export type InputProps = { hasError: boolean; placeholder: string; value: string; - onInputBlur?: (field: APIFormField) => void; - onInputFocus?: (field: APIFormField) => void; - onChange?: (field: APIFormField) => void; + onInputBlur?: (value: string) => void; + onInputFocus?: (value: string) => void; + onChange?: (value: string) => void; }; const Input: React.FC = ({ @@ -59,28 +58,19 @@ const Input: React.FC = ({ function handleFocus() { if (onInputFocus !== undefined) { - onInputFocus({ - id: name, - value: value, - }); + onInputFocus(value); } } function handleBlur() { if (onInputBlur !== undefined) { - onInputBlur({ - id: name, - value, - }); + onInputBlur(value); } } function handleChange(event: ChangeEvent) { if (onChange) { - onChange({ - id: name, - value: event.target.value, - }); + onChange(event.target.value); } } diff --git a/lib/ts/recipe/emailpassword/types.ts b/lib/ts/recipe/emailpassword/types.ts index 253329077..5ffe5b3d1 100644 --- a/lib/ts/recipe/emailpassword/types.ts +++ b/lib/ts/recipe/emailpassword/types.ts @@ -41,7 +41,6 @@ import type { NormalisedConfig as NormalisedAuthRecipeModuleConfig, UserInput as AuthRecipeModuleUserInput, } from "../authRecipe/types"; -import type React from "react"; import type { Dispatch } from "react"; import type { OverrideableBuilder } from "supertokens-js-override"; import type { RecipeInterface } from "supertokens-web-js/recipe/emailpassword"; @@ -135,7 +134,9 @@ export type SignUpFormFeatureUserInput = FeatureBaseConfig & { /* * Form fields for SignUp. */ - formFields?: FormFieldSignUpConfig[]; + formFields?: (FormField & { + inputComponent?: (props: InputProps) => JSX.Element; + })[]; /* * Privacy policy link for sign up form. @@ -152,7 +153,9 @@ export type NormalisedSignUpFormFeatureConfig = NormalisedBaseConfig & { /* * Normalised form fields for SignUp. */ - formFields: NormalisedFormField[]; + formFields: (NormalisedFormField & { + inputComponent?: (props: InputProps) => JSX.Element; + })[]; /* * Privacy policy link for sign up form. @@ -181,8 +184,6 @@ export type NormalisedSignInFormFeatureConfig = NormalisedBaseConfig & { export type FormFieldSignInConfig = FormFieldBaseConfig; -export type FormFieldSignUpConfig = FormField; - export type ResetPasswordUsingTokenUserInput = { /* * Disable default implementation with default routes. @@ -225,20 +226,16 @@ export type NormalisedEnterEmailForm = FeatureBaseConfig & { formFields: NormalisedFormField[]; }; -/* - * Props Types. - */ - -type FormThemeBaseProps = ThemeBaseProps & { +type NonSignUpFormThemeBaseProps = ThemeBaseProps & { /* - * Form fields to use in the signin form. + * Omit since, custom inputComponent only part of signup */ - formFields: FormFieldThemeProps[]; + formFields: Omit[]; error: string | undefined; }; -export type SignInThemeProps = FormThemeBaseProps & { +export type SignInThemeProps = NonSignUpFormThemeBaseProps & { recipeImplementation: RecipeInterface; clearError: () => void; onError: (error: string) => void; @@ -248,13 +245,15 @@ export type SignInThemeProps = FormThemeBaseProps & { onSuccess: (result: { user: User }) => void; }; -export type SignUpThemeProps = FormThemeBaseProps & { +export type SignUpThemeProps = ThemeBaseProps & { recipeImplementation: RecipeInterface; clearError: () => void; onError: (error: string) => void; config: NormalisedConfig; signInClicked?: () => void; onSuccess: (result: { user: User }) => void; + formFields: FormFieldThemeProps[]; + error: string | undefined; }; export type SignInAndUpThemeProps = { @@ -274,11 +273,6 @@ export type FormFieldThemeProps = NormalisedFormField & { */ labelComponent?: JSX.Element; - /* - * Custom component that replaces the standard input component - */ - inputComponent?: React.FC; - /* * Show Is required (*) next to label */ @@ -288,6 +282,11 @@ export type FormFieldThemeProps = NormalisedFormField & { * Clears the field after calling the API. */ clearOnSubmit?: boolean; + + /* + * Ability to add custom components + */ + inputComponent?: (props: InputProps) => JSX.Element; }; export type FormFieldError = { @@ -367,7 +366,7 @@ export type ResetPasswordUsingTokenThemeProps = { userContext?: any; }; -export type EnterEmailProps = FormThemeBaseProps & { +export type EnterEmailProps = NonSignUpFormThemeBaseProps & { recipeImplementation: RecipeInterface; error: string | undefined; clearError: () => void; @@ -376,7 +375,7 @@ export type EnterEmailProps = FormThemeBaseProps & { onBackButtonClicked: () => void; }; -export type SubmitNewPasswordProps = FormThemeBaseProps & { +export type SubmitNewPasswordProps = NonSignUpFormThemeBaseProps & { recipeImplementation: RecipeInterface; error: string | undefined; clearError: () => void; diff --git a/lib/ts/recipe/passwordless/components/themes/signInUp/phoneNumberInput.tsx b/lib/ts/recipe/passwordless/components/themes/signInUp/phoneNumberInput.tsx index 584c230dd..2bff1c1d0 100644 --- a/lib/ts/recipe/passwordless/components/themes/signInUp/phoneNumberInput.tsx +++ b/lib/ts/recipe/passwordless/components/themes/signInUp/phoneNumberInput.tsx @@ -45,19 +45,13 @@ function PhoneNumberInput({ }: InputProps & PhoneNumberInputProps): JSX.Element { function handleFocus() { if (onInputFocus !== undefined) { - onInputFocus({ - id: name, - value: value, - }); + onInputFocus(value); } } function handleBlur() { if (onInputBlur !== undefined) { - onInputBlur({ - id: name, - value: value, - }); + onInputBlur(value); } } @@ -71,10 +65,7 @@ function PhoneNumberInput({ const handleChange = useCallback( (newValue: string) => { if (onChangeRef.current !== undefined) { - onChangeRef.current({ - id: name, - value: newValue, - }); + onChangeRef.current(newValue); } }, [onChangeRef] @@ -83,10 +74,7 @@ function PhoneNumberInput({ const handleCountryChange = useCallback( (ev) => { if (onChangeRef.current !== undefined && phoneInputInstance !== undefined) { - onChangeRef.current({ - id: name, - value: ev.target.value, - }); + onChangeRef.current(ev.target.value); } }, [onChangeRef] diff --git a/lib/ts/types.ts b/lib/ts/types.ts index 8399585b5..16beba60b 100644 --- a/lib/ts/types.ts +++ b/lib/ts/types.ts @@ -222,6 +222,11 @@ export type FormFieldBaseConfig = { * placeholder of the input field. */ placeholder?: string; + + /* + *Ability to provide default value to input field. + */ + getDefaultValue?: () => string; }; export type FormField = FormFieldBaseConfig & { @@ -283,6 +288,11 @@ export type NormalisedFormField = { * Moves focus to the input element when it mounts */ autofocus?: boolean; + + /* + *Ability to provide default value to input field. + */ + getDefaultValue?: () => string; }; export type ReactComponentClass

= ComponentClass | ((props: P) => JSX.Element); diff --git a/test/end-to-end/signin.test.js b/test/end-to-end/signin.test.js index 86a204645..e9c4721f1 100644 --- a/test/end-to-end/signin.test.js +++ b/test/end-to-end/signin.test.js @@ -669,6 +669,94 @@ describe("SuperTokens SignIn", function () { }); }); }); + + describe("SignIn default field tests", function () { + it("Should contain email and password fields prefilled", async function () { + await page.evaluate(() => window.localStorage.setItem("SHOW_SIGNIN_DEFAULT_FIELDS", "YES")); + + await page.reload({ + waitUntil: "domcontentloaded", + }); + + const expectedDefaultValues = { + email: "abc@xyz.com", + password: "fakepassword123", + }; + + const emailInput = await getInputField(page, "email"); + const defaultEmail = await emailInput.evaluate((f) => f.value); + assert.strictEqual(defaultEmail, expectedDefaultValues["email"]); + + const passwordInput = await getInputField(page, "password"); + const defaultPassword = await passwordInput.evaluate((f) => f.value); + assert.strictEqual(defaultPassword, expectedDefaultValues["password"]); + }); + + it("Should have default values in the signin request payload", async function () { + await page.evaluate(() => window.localStorage.setItem("SHOW_SIGNIN_DEFAULT_FIELDS", "YES")); + + await page.reload({ + waitUntil: "domcontentloaded", + }); + + const expectedDefautlValues = [ + { id: "email", value: "abc@xyz.com" }, + { id: "password", value: "fakepassword123" }, + ]; + + let assertionError = null; + let interceptionPassed = false; + + const requestHandler = async (request) => { + if (request.url().includes(SIGN_IN_API) && request.method() === "POST") { + try { + const postData = JSON.parse(request.postData()); + expectedDefautlValues.forEach(({ id, value }) => { + let findFormData = postData.formFields.find((inputData) => inputData.id === id); + if (findFormData) { + assert.strictEqual(findFormData["value"], value, `Mismatch in payload for key: ${id}`); + } else { + throw new Error("Field not found in req.data"); + } + }); + interceptionPassed = true; + return request.respond({ + status: 200, + headers: { + "access-control-allow-origin": TEST_CLIENT_BASE_URL, + "access-control-allow-credentials": "true", + }, + body: JSON.stringify({ + status: "OK", + }), + }); + } catch (error) { + assertionError = error; // Store the error + } + } + return request.continue(); + }; + + await page.setRequestInterception(true); + page.on("request", requestHandler); + + try { + // Perform the button click and wait for all network activity to finish + await Promise.all([page.waitForNetworkIdle(), submitForm(page)]); + } finally { + page.off("request", requestHandler); + await page.setRequestInterception(false); + } + + if (assertionError) { + throw assertionError; + } + + if (!interceptionPassed) { + throw new Error("test failed"); + } + }); + }); }); describe("SuperTokens SignIn => Server Error", function () { diff --git a/test/end-to-end/signup.test.js b/test/end-to-end/signup.test.js index 857bfbc42..829ad2c4f 100644 --- a/test/end-to-end/signup.test.js +++ b/test/end-to-end/signup.test.js @@ -39,6 +39,8 @@ import { getGeneralError, waitForSTElement, backendBeforeEach, + setSelectDropdownValue, + getInputField, } from "../helpers"; import { @@ -342,6 +344,314 @@ describe("SuperTokens SignUp", function () { assert.deepStrictEqual(emailError, "This email already exists. Please sign in instead."); }); }); + + describe("Signup custom fields test", function () { + beforeEach(async function () { + // set cookie and reload which loads the form with custom field + await page.evaluate(() => window.localStorage.setItem("SHOW_CUSTOM_FIELDS", "YES")); + + await page.reload({ + waitUntil: "domcontentloaded", + }); + await toggleSignInSignUp(page); + }); + + it("Check if the custom fields are loaded", async function () { + let text = await getAuthPageHeaderText(page); + assert.deepStrictEqual(text, "Sign Up"); + + // check if select dropdown is loaded + const selectDropdownExists = await waitForSTElement(page, "select"); + assert.ok(selectDropdownExists, "Select dropdown exists"); + + // check if checbox is loaded + const checkboxExists = await waitForSTElement(page, 'input[type="checkbox"]'); + assert.ok(checkboxExists, "Checkbox exists"); + }); + + it("Should show error messages, based on optional flag", async function () { + await submitForm(page); + let formFieldErrors = await getFieldErrors(page); + + // 2 regular form field errors + + // 1 required custom field => terms checkbox + assert.deepStrictEqual(formFieldErrors, [ + "Field is not optional", + "Field is not optional", + "Field is not optional", + ]); + + // supply values for regular-required fields only + await setInputValues(page, [ + { name: "email", value: "jack.doe@supertokens.io" }, + { name: "password", value: "Str0ngP@ssw0rd" }, + ]); + + await submitForm(page); + formFieldErrors = await getFieldErrors(page); + assert.deepStrictEqual(formFieldErrors, ["Field is not optional"]); + + // check terms and condition checkbox + let termsCheckbox = await waitForSTElement(page, '[name="terms"]'); + await page.evaluate((e) => e.click(), termsCheckbox); + + //un-checking the required checkbox should throw custom error message + await page.evaluate((e) => e.click(), termsCheckbox); + + await submitForm(page); + formFieldErrors = await getFieldErrors(page); + assert.deepStrictEqual(formFieldErrors, ["Please check Terms and conditions"]); + }); + + it("Check if custom values are part of the signup payload", async function () { + const customFields = { + terms: "true", + "select-dropdown": "option 3", + }; + let assertionError = null; + let interceptionPassed = false; + + const requestHandler = async (request) => { + if (request.url().includes(SIGN_UP_API) && request.method() === "POST") { + try { + const postData = JSON.parse(request.postData()); + Object.keys(customFields).forEach((key) => { + let findFormData = postData.formFields.find((inputData) => inputData.id === key); + if (findFormData) { + assert.strictEqual( + findFormData["value"], + customFields[key], + `Mismatch in payload for key: ${key}` + ); + } else { + throw new Error("Field not found in req.data"); + } + }); + interceptionPassed = true; + return request.respond({ + status: 200, + headers: { + "access-control-allow-origin": TEST_CLIENT_BASE_URL, + "access-control-allow-credentials": "true", + }, + body: JSON.stringify({ + status: "OK", + }), + }); + } catch (error) { + assertionError = error; // Store the error + } + } + return request.continue(); + }; + + await page.setRequestInterception(true); + page.on("request", requestHandler); + + try { + // Fill and submit the form with custom fields + await setInputValues(page, [ + { name: "email", value: "john.doe@supertokens.io" }, + { name: "password", value: "Str0ngP@assw0rd" }, + ]); + + await setSelectDropdownValue(page, "select", customFields["select-dropdown"]); + + // Check terms and condition checkbox + let termsCheckbox = await waitForSTElement(page, '[name="terms"]'); + await page.evaluate((e) => e.click(), termsCheckbox); + + // Perform the button click and wait for all network activity to finish + await Promise.all([page.waitForNetworkIdle(), submitForm(page)]); + } finally { + page.off("request", requestHandler); + await page.setRequestInterception(false); + } + + if (assertionError) { + throw assertionError; + } + + if (!interceptionPassed) { + throw new Error("test failed"); + } + }); + }); + + // Default values test + describe("Signup default value for fields test", function () { + beforeEach(async function () { + // set cookie and reload which loads the form fields with default values + await page.evaluate(() => window.localStorage.setItem("SHOW_CUSTOM_FIELDS_WITH_DEFAULT_VALUES", "YES")); + + await page.reload({ + waitUntil: "domcontentloaded", + }); + await toggleSignInSignUp(page); + }); + + it("Check if default values are set already", async function () { + const fieldsWithDefault = { + country: "India", + "select-dropdown": "option 2", + terms: true, + }; + + // regular input field default value + const countryInput = await getInputField(page, "country"); + const defaultCountry = await countryInput.evaluate((f) => f.value); + assert.strictEqual(defaultCountry, fieldsWithDefault["country"]); + + // custom dropdown default value is also getting set correctly + const selectDropdown = await waitForSTElement(page, "select"); + const defaultOption = await selectDropdown.evaluate((f) => f.value); + assert.strictEqual(defaultOption, fieldsWithDefault["select-dropdown"]); + + // custom dropdown default value is also getting set correctly + const termsCheckbox = await waitForSTElement(page, '[name="terms"]'); + // checkbox is checked + const defaultChecked = await termsCheckbox.evaluate((f) => f.checked); + assert.strictEqual(defaultChecked, fieldsWithDefault["terms"]); + // also the value = string + const defaultValue = await termsCheckbox.evaluate((f) => f.value); + assert.strictEqual(defaultValue, fieldsWithDefault["terms"].toString()); + }); + + it("Check if changing the field value actually overwrites the default value", async function () { + const updatedFields = { + country: "USA", + "select-dropdown": "option 3", + }; + + await setInputValues(page, [{ name: "country", value: updatedFields["country"] }]); + await setSelectDropdownValue(page, "select", updatedFields["select-dropdown"]); + + // input field default value + const countryInput = await getInputField(page, "country"); + const updatedCountry = await countryInput.evaluate((f) => f.value); + assert.strictEqual(updatedCountry, updatedFields["country"]); + + // dropdown default value is also getting set correctly + const selectDropdown = await waitForSTElement(page, "select"); + const updatedOption = await selectDropdown.evaluate((f) => f.value); + assert.strictEqual(updatedOption, updatedFields["select-dropdown"]); + }); + + it("Check if default values are getting sent in signup-payload", async function () { + // directly submit the form and test the payload + const expectedDefautlValues = [ + { id: "email", value: "test@one.com" }, + { id: "password", value: "fakepassword123" }, + { id: "terms", value: "true" }, + { id: "select-dropdown", value: "option 2" }, + { id: "country", value: "India" }, + ]; + + let assertionError = null; + let interceptionPassed = false; + + const requestHandler = async (request) => { + if (request.url().includes(SIGN_UP_API) && request.method() === "POST") { + try { + const postData = JSON.parse(request.postData()); + expectedDefautlValues.forEach(({ id, value }) => { + let findFormData = postData.formFields.find((inputData) => inputData.id === id); + if (findFormData) { + assert.strictEqual(findFormData["value"], value, `Mismatch in payload for key: ${id}`); + } else { + throw new Error("Field not found in req.data"); + } + }); + interceptionPassed = true; + return request.respond({ + status: 200, + headers: { + "access-control-allow-origin": TEST_CLIENT_BASE_URL, + "access-control-allow-credentials": "true", + }, + body: JSON.stringify({ + status: "OK", + }), + }); + } catch (error) { + assertionError = error; // Store the error + } + } + return request.continue(); + }; + + await page.setRequestInterception(true); + page.on("request", requestHandler); + + try { + // Perform the button click and wait for all network activity to finish + await Promise.all([page.waitForNetworkIdle(), submitForm(page)]); + } finally { + page.off("request", requestHandler); + await page.setRequestInterception(false); + } + + if (assertionError) { + throw assertionError; + } + + if (!interceptionPassed) { + throw new Error("test failed"); + } + }); + }); + + describe("Incorrect field config test", function () { + beforeEach(async function () { + // set cookie and reload which loads the form fields with default values + await page.evaluate(() => window.localStorage.setItem("SHOW_INCORRECT_FIELDS", "YES")); + + await page.reload({ + waitUntil: "domcontentloaded", + }); + }); + + it("Check if incorrect getDefaultValue throws error", async function () { + let pageErrorMessage = ""; + page.on("pageerror", (err) => { + pageErrorMessage = err.message; + }); + + await page.reload({ + waitUntil: "domcontentloaded", + }); + await toggleSignInSignUp(page); + + const expectedErrorMessage = "getDefaultValue for country must return a string"; + assert( + pageErrorMessage.includes(expectedErrorMessage), + `Expected "${expectedErrorMessage}" to be included in page-error` + ); + }); + + it("Check if non-string params to onChange throws error", async function () { + await page.evaluate(() => window.localStorage.setItem("INCORRECT_ONCHANGE", "YES")); + await page.reload({ + waitUntil: "domcontentloaded", + }); + await toggleSignInSignUp(page); + + let pageErrorMessage = ""; + page.on("pageerror", (err) => { + pageErrorMessage = err.message; + }); + + // check terms and condition checkbox since it emits non-string value => boolean + let termsCheckbox = await waitForSTElement(page, '[name="terms"]'); + await page.evaluate((e) => e.click(), termsCheckbox); + + const expectedErrorMessage = "terms value must be a string"; + assert( + pageErrorMessage.includes(expectedErrorMessage), + `Expected "${expectedErrorMessage}" to be included in page-error` + ); + }); + }); }); describe("SuperTokens SignUp => Server Error", function () { diff --git a/test/helpers.js b/test/helpers.js index bc7138534..ebfbbc067 100644 --- a/test/helpers.js +++ b/test/helpers.js @@ -417,6 +417,20 @@ export async function setInputValues(page, fields) { return await new Promise((r) => setTimeout(r, 300)); } +export async function setSelectDropdownValue(page, selector, optionValue) { + const shadowRootHandle = await getShadowRootHandle(page); + return await page.evaluate( + (root, selector, optionValue) => { + const select = root.querySelector(selector); + select.value = optionValue; + select.dispatchEvent(new Event("change", { bubbles: true })); + }, + shadowRootHandle, + selector, + optionValue + ); +} + export async function clearBrowserCookiesWithoutAffectingConsole(page, console) { let toReturn = [...console]; const client = await page.target().createCDPSession(); @@ -1006,3 +1020,8 @@ export async function backendBeforeEach() { }).catch(console.error); } } + +export async function getShadowRootHandle(page) { + const hostElement = await page.$(ST_ROOT_SELECTOR); + return await page.evaluateHandle((el) => el.shadowRoot, hostElement); +} diff --git a/test/with-typescript/src/App.tsx b/test/with-typescript/src/App.tsx index 65b0a3a5d..1f8a4b3fa 100644 --- a/test/with-typescript/src/App.tsx +++ b/test/with-typescript/src/App.tsx @@ -373,6 +373,71 @@ function getEmailPasswordConfigs() { placeholder: "Where do you live?", optional: true, }, + { + id: "terms", + label: "", + optional: false, + getDefaultValue: () => "true", + inputComponent: (inputProps) => ( +

+ { + if (inputProps.onChange) { + inputProps.onChange(e.target.checked.toString()); + } + }}> + I agree to the terms and conditions +
+ ), + validate: async (value) => { + if (value === "true") { + return undefined; + } + return "Please check Terms and conditions"; + }, + }, + { + id: "select", + label: "Select", + getDefaultValue: () => "option 2", + inputComponent: (inputProps) => ( + + ), + optional: true, + }, ], }, },