From 5e4e4ae87a105581264c0dcffc30c8cb78e1c018 Mon Sep 17 00:00:00 2001 From: George Desipris Date: Mon, 11 Sep 2023 12:06:53 +0300 Subject: [PATCH 1/4] feat(types): Add initialValues prop to the SignIn/Up types --- packages/types/src/clerk.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index d91e33c5573..fbeafa34288 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -547,6 +547,21 @@ export interface Resources { export type RoutingStrategy = 'path' | 'hash' | 'virtual'; +export type SignInInitialValues = { + emailAddress?: string; + phoneNumber?: string; + username?: string; +}; + +export type SignUpInitialValues = { + emailAddress?: string; + phoneNumber?: string; + firstName?: string; + lastName?: string; + username?: string; + web3WalletAddress?: string; +}; + export type RedirectOptions = { /** * Full URL or path to navigate after successful sign in. @@ -611,6 +626,10 @@ export type SignInProps = { * prop of ClerkProvided (if one is provided) */ appearance?: SignInTheme; + /** + * Initial values that are used to prefill the sign in form. + */ + initialValues?: SignInInitialValues; } & RedirectOptions; export type SignUpProps = { @@ -638,6 +657,10 @@ export type SignUpProps = { * Additional arbitrary metadata to be stored alongside the User object */ unsafeMetadata?: SignUpUnsafeMetadata; + /** + * Initial values that are used to prefill the sign up form. + */ + initialValues?: SignUpInitialValues; } & RedirectOptions; export type UserProfileProps = { From 144ce0ed210ebaafce05e7fa02adb52e84c0e923 Mon Sep 17 00:00:00 2001 From: George Desipris Date: Mon, 11 Sep 2023 13:39:20 +0300 Subject: [PATCH 2/4] feat(clerk-js): Prefill SignIn/Up components with initial values feat(types): Remove web3WalletAddress from SignUpInitialValues chore(repo): Changeset feat(clerk-js): Add initial values to SignUpContinue chore(clerk-js): Address PR comments chore(clerk-js): Avoid initialValues optional chaining feat(clerk-js): Include initialValues from query params in sign in/up context feat(clerk-js,clerk-react,types): Add initialValues support to redirectToSignIn/Up methods feat(clerk-react): Add initialValues to and fix(clerk-js): Use router queryString for initialValues chore(types): Remove unused RedirectToProps type refactor(clerk-js): Extract and reuse query param initial value logic fix(clerk-js): Prioritize initial values from query params fix(clerk-react): Fix initialValues type for --- .changeset/fresh-papayas-cheat.md | 7 +++ packages/clerk-js/src/core/clerk.ts | 18 ++++-- packages/clerk-js/src/core/constants.ts | 3 + .../src/ui/components/SignIn/SignInStart.tsx | 55 ++++++++++++++----- .../ui/components/SignUp/SignUpContinue.tsx | 12 ++-- .../src/ui/components/SignUp/SignUpStart.tsx | 11 ++-- .../ui/contexts/ClerkUIComponentsContext.tsx | 32 ++++++++++- .../src/ui/elements/PhoneInput/PhoneInput.tsx | 10 ++-- .../clerk-js/src/utils/__tests__/url.test.ts | 18 +++--- packages/clerk-js/src/utils/url.ts | 17 +++++- .../src/components/controlComponents.tsx | 6 +- packages/react/src/isomorphicClerk.ts | 7 ++- packages/react/src/types.ts | 6 +- packages/types/src/clerk.ts | 19 ++++++- 14 files changed, 162 insertions(+), 59 deletions(-) create mode 100644 .changeset/fresh-papayas-cheat.md diff --git a/.changeset/fresh-papayas-cheat.md b/.changeset/fresh-papayas-cheat.md new file mode 100644 index 00000000000..862e73fc413 --- /dev/null +++ b/.changeset/fresh-papayas-cheat.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +`` and `` input fields can now be prefilled with the `initialValues` prop. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 89727469a14..5e2c652b588 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -41,12 +41,14 @@ import type { Resources, SetActiveParams, SignInProps, + SignInRedirectOptions, SignInResource, SignOut, SignOutCallback, SignOutOptions, SignUpField, SignUpProps, + SignUpRedirectOptions, SignUpResource, UnsubscribeCallback, UserButtonProps, @@ -58,6 +60,7 @@ import type { MountComponentRenderer } from '../ui/Components'; import { completeSignUpFlow } from '../ui/components/SignUp/util'; import { appendAsQueryParams, + appendUrlsAsQueryParams, buildURL, createBeforeUnloadTracker, createCookieHandler, @@ -694,11 +697,11 @@ export default class Clerk implements ClerkInterface { return setDevBrowserJWTInURL(toURL, devBrowserJwt, asQueryParam).href; } - public buildSignInUrl(options?: RedirectOptions): string { + public buildSignInUrl(options?: SignInRedirectOptions): string { return this.#buildUrl('signInUrl', options); } - public buildSignUpUrl(options?: RedirectOptions): string { + public buildSignUpUrl(options?: SignUpRedirectOptions): string { return this.#buildUrl('signUpUrl', options); } @@ -757,14 +760,14 @@ export default class Clerk implements ClerkInterface { return; }; - public redirectToSignIn = async (options?: RedirectOptions): Promise => { + public redirectToSignIn = async (options?: SignInRedirectOptions): Promise => { if (inBrowser()) { return this.navigate(this.buildSignInUrl(options)); } return; }; - public redirectToSignUp = async (options?: RedirectOptions): Promise => { + public redirectToSignUp = async (options?: SignUpRedirectOptions): Promise => { if (inBrowser()) { return this.navigate(this.buildSignUpUrl(options)); } @@ -1456,7 +1459,7 @@ export default class Clerk implements ClerkInterface { }); }; - #buildUrl = (key: 'signInUrl' | 'signUpUrl', options?: RedirectOptions): string => { + #buildUrl = (key: 'signInUrl' | 'signUpUrl', options?: SignInRedirectOptions | SignUpRedirectOptions): string => { if (!this.#isReady || !this.#environment || !this.#environment.displayConfig) { return ''; } @@ -1472,7 +1475,10 @@ export default class Clerk implements ClerkInterface { { options: this.#options, displayConfig: this.#environment.displayConfig }, false, ); - return this.buildUrlWithAuth(appendAsQueryParams(signInOrUpUrl, opts)); + + return this.buildUrlWithAuth( + appendUrlsAsQueryParams(appendAsQueryParams(signInOrUpUrl, options?.initialValues || {}), opts), + ); }; assertComponentsReady(controls: unknown): asserts controls is ReturnType { diff --git a/packages/clerk-js/src/core/constants.ts b/packages/clerk-js/src/core/constants.ts index 17df8558c34..ffa65e9171c 100644 --- a/packages/clerk-js/src/core/constants.ts +++ b/packages/clerk-js/src/core/constants.ts @@ -17,3 +17,6 @@ export const ERROR_CODES = { NOT_ALLOWED_ACCESS: 'not_allowed_access', SAML_USER_ATTRIBUTE_MISSING: 'saml_user_attribute_missing', }; + +export const SIGN_IN_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username']; +export const SIGN_UP_INITIAL_VALUE_KEYS = ['email_address', 'phone_number', 'username', 'first_name', 'last_name']; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 865239bab18..5b893c9b6a1 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -1,5 +1,5 @@ import type { ClerkAPIError, SignInCreateParams } from '@clerk/types'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ERROR_CODES } from '../../../core/constants'; import { clerkInvalidFAPIResponse } from '../../../core/errors'; @@ -64,42 +64,73 @@ export function _SignInStart(): JSX.Element { placeholder: localizationKeys('formFieldInputPlaceholder__password') as any, }); - const identifierField = useFormControl('identifier', '', { + const ctxInitialValues = ctx.initialValues || {}; + const initialValues: Record = useMemo( + () => ({ + email_address: ctxInitialValues.emailAddress, + email_address_username: ctxInitialValues.emailAddress || ctxInitialValues.username, + username: ctxInitialValues.username, + phone_number: ctxInitialValues.phoneNumber, + }), + [ctx.initialValues], + ); + + const hasSocialOrWeb3Buttons = !!authenticatableSocialStrategies.length || !!web3FirstFactors.length; + const [shouldAutofocus, setShouldAutofocus] = useState(!isMobileDevice() && !hasSocialOrWeb3Buttons); + const textIdentifierField = useFormControl('identifier', initialValues[identifierAttribute] || '', { ...currentIdentifier, isRequired: true, }); + const phoneIdentifierField = useFormControl('identifier', initialValues['phone_number'] || '', { + ...currentIdentifier, + isRequired: true, + }); + + const identifierField = identifierAttribute === 'phone_number' ? phoneIdentifierField : textIdentifierField; + + const identifierFieldRef = useRef(null); + const switchToNextIdentifier = () => { setIdentifierAttribute( i => identifierAttributes[(identifierAttributes.indexOf(i) + 1) % identifierAttributes.length], ); - identifierField.setValue(''); + setShouldAutofocus(true); }; - const switchToPhoneInput = (value?: string) => { + const switchToPhoneInput = () => { setIdentifierAttribute('phone_number'); - identifierField.setValue(value || ''); + setShouldAutofocus(true); }; // switch to the phone input (if available) if a "+" is entered // (either by the browser or the user) // this does not work in chrome as it does not fire the change event and the value is // not available via js - React.useLayoutEffect(() => { + useLayoutEffect(() => { if ( identifierField.value.startsWith('+') && identifierAttributes.includes('phone_number') && identifierAttribute !== 'phone_number' && !hasSwitchedByAutofill ) { - switchToPhoneInput(identifierField.value); + switchToPhoneInput(); // do not switch automatically on subsequent autofills // by the browser to avoid a switch loop setHasSwitchedByAutofill(true); } }, [identifierField.value, identifierAttributes]); - React.useEffect(() => { + useLayoutEffect(() => { + if (identifierAttribute === 'phone_number' && identifierField.value) { + //value should be kept as we have auto-switched to the phone input + return; + } + + identifierField.setValue(initialValues[identifierAttribute] || ''); + }, [identifierAttribute]); + + useEffect(() => { if (!organizationTicket) { return; } @@ -137,7 +168,7 @@ export function _SignInStart(): JSX.Element { }); }, []); - React.useEffect(() => { + useEffect(() => { async function handleOauthError() { const error = signIn?.firstFactorVerification?.error; if (error) { @@ -244,9 +275,6 @@ export function _SignInStart(): JSX.Element { return ; } - const hasSocialOrWeb3Buttons = !!authenticatableSocialStrategies.length || !!web3FirstFactors.length; - const shouldAutofocus = !isMobileDevice() && !hasSocialOrWeb3Buttons; - return ( @@ -271,6 +299,7 @@ export function _SignInStart(): JSX.Element { }) const ref = useRef(null); // show password if it's autofilled by the browser - React.useLayoutEffect(() => { + useLayoutEffect(() => { const intervalId = setInterval(() => { if (ref?.current) { const autofilled = window.getComputedStyle(ref.current, ':autofill').animationName === 'onAutoFillStart'; diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx index 135f286dd48..ff08f7685b3 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpContinue.tsx @@ -32,7 +32,7 @@ function _SignUpContinue() { const { navigate } = useRouter(); const { displayConfig, userSettings } = useEnvironment(); const { attributes } = userSettings; - const { navigateAfterSignUp, signInUrl, unsafeMetadata } = useSignUpContext(); + const { navigateAfterSignUp, signInUrl, unsafeMetadata, initialValues = {} } = useSignUpContext(); const signUp = useCoreSignUp(); const isProgressiveSignUp = userSettings.signUp.progressive; const [activeCommIdentifierType, setActiveCommIdentifierType] = React.useState( @@ -47,27 +47,27 @@ function _SignUpContinue() { // TODO: This form should be shared between SignUpStart and SignUpContinue const formState = { - firstName: useFormControl('firstName', '', { + firstName: useFormControl('firstName', initialValues.firstName || '', { type: 'text', label: localizationKeys('formFieldLabel__firstName'), placeholder: localizationKeys('formFieldInputPlaceholder__firstName'), }), - lastName: useFormControl('lastName', '', { + lastName: useFormControl('lastName', initialValues.lastName || '', { type: 'text', label: localizationKeys('formFieldLabel__lastName'), placeholder: localizationKeys('formFieldInputPlaceholder__lastName'), }), - emailAddress: useFormControl('emailAddress', '', { + emailAddress: useFormControl('emailAddress', initialValues.emailAddress || '', { type: 'email', label: localizationKeys('formFieldLabel__emailAddress'), placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'), }), - username: useFormControl('username', '', { + username: useFormControl('username', initialValues.username || '', { type: 'text', label: localizationKeys('formFieldLabel__username'), placeholder: localizationKeys('formFieldInputPlaceholder__username'), }), - phoneNumber: useFormControl('phoneNumber', '', { + phoneNumber: useFormControl('phoneNumber', initialValues.phoneNumber || '', { type: 'tel', label: localizationKeys('formFieldLabel__phoneNumber'), placeholder: localizationKeys('formFieldInputPlaceholder__phoneNumber'), diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx index eff57d14705..526e03815be 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx @@ -42,6 +42,7 @@ function _SignUpStart(): JSX.Element { getInitialActiveIdentifier(attributes, userSettings.signUp.progressive), ); const { t, locale } = useLocalizations(); + const initialValues = ctx.initialValues || {}; const [missingRequirementsWithTicket, setMissingRequirementsWithTicket] = React.useState(false); @@ -51,27 +52,27 @@ function _SignUpStart(): JSX.Element { const { failedValidationsText } = usePasswordComplexity(passwordSettings); const formState = { - firstName: useFormControl('firstName', signUp.firstName || '', { + firstName: useFormControl('firstName', signUp.firstName || initialValues.firstName || '', { type: 'text', label: localizationKeys('formFieldLabel__firstName'), placeholder: localizationKeys('formFieldInputPlaceholder__firstName'), }), - lastName: useFormControl('lastName', signUp.lastName || '', { + lastName: useFormControl('lastName', signUp.lastName || initialValues.lastName || '', { type: 'text', label: localizationKeys('formFieldLabel__lastName'), placeholder: localizationKeys('formFieldInputPlaceholder__lastName'), }), - emailAddress: useFormControl('emailAddress', signUp.emailAddress || '', { + emailAddress: useFormControl('emailAddress', signUp.emailAddress || initialValues.emailAddress || '', { type: 'email', label: localizationKeys('formFieldLabel__emailAddress'), placeholder: localizationKeys('formFieldInputPlaceholder__emailAddress'), }), - username: useFormControl('username', signUp.username || '', { + username: useFormControl('username', signUp.username || initialValues.username || '', { type: 'text', label: localizationKeys('formFieldLabel__username'), placeholder: localizationKeys('formFieldInputPlaceholder__username'), }), - phoneNumber: useFormControl('phoneNumber', signUp.phoneNumber || '', { + phoneNumber: useFormControl('phoneNumber', signUp.phoneNumber || initialValues.phoneNumber || '', { type: 'tel', label: localizationKeys('formFieldLabel__phoneNumber'), placeholder: localizationKeys('formFieldInputPlaceholder__phoneNumber'), diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index e5b9f0c36bd..98ed7863ed2 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -1,6 +1,8 @@ +import { snakeToCamel } from '@clerk/shared'; import type { OrganizationResource, UserResource } from '@clerk/types'; -import React from 'react'; +import React, { useMemo } from 'react'; +import { SIGN_IN_INITIAL_VALUE_KEYS, SIGN_UP_INITIAL_VALUE_KEYS } from '../../core/constants'; import { buildAuthQueryString, buildURL, createDynamicParamParser, pickRedirectionProp } from '../../utils'; import { useCoreClerk, useEnvironment, useOptions } from '../contexts'; import type { ParsedQs } from '../router'; @@ -21,6 +23,18 @@ const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ }); export const ComponentContext = React.createContext(null); +const getInitialValuesFromQueryParams = (queryString: string, params: string[]) => { + const props: Record = {}; + const searchParams = new URLSearchParams(queryString); + searchParams.forEach((value, key) => { + if (params.includes(key) && typeof value === 'string') { + props[snakeToCamel(key)] = value; + } + }); + + return props; +}; + export type SignUpContextType = SignUpCtx & { navigateAfterSignUp: () => any; queryParams: ParsedQs; @@ -33,10 +47,15 @@ export const useSignUpContext = (): SignUpContextType => { const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as SignUpCtx; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); - const { queryParams } = useRouter(); + const { queryParams, queryString } = useRouter(); const options = useOptions(); const clerk = useCoreClerk(); + const initialValuesFromQueryParams = useMemo( + () => getInitialValuesFromQueryParams(queryString, SIGN_UP_INITIAL_VALUE_KEYS), + [], + ); + if (componentName !== 'SignUp') { throw new Error('Clerk: useSignUpContext called outside of the mounted SignUp component.'); } @@ -87,6 +106,7 @@ export const useSignUpContext = (): SignUpContextType => { afterSignInUrl, navigateAfterSignUp, queryParams, + initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString: authQs, }; }; @@ -103,10 +123,15 @@ export const useSignInContext = (): SignInContextType => { const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as SignInCtx; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); - const { queryParams } = useRouter(); + const { queryParams, queryString } = useRouter(); const options = useOptions(); const clerk = useCoreClerk(); + const initialValuesFromQueryParams = useMemo( + () => getInitialValuesFromQueryParams(queryString, SIGN_IN_INITIAL_VALUE_KEYS), + [], + ); + if (componentName !== 'SignIn') { throw new Error('Clerk: useSignInContext called outside of the mounted SignIn component.'); } @@ -154,6 +179,7 @@ export const useSignInContext = (): SignInContextType => { navigateAfterSignIn, signUpContinueUrl, queryParams, + initialValues: { ...ctx.initialValues, ...initialValuesFromQueryParams }, authQueryString: authQs, }; }; diff --git a/packages/clerk-js/src/ui/elements/PhoneInput/PhoneInput.tsx b/packages/clerk-js/src/ui/elements/PhoneInput/PhoneInput.tsx index 5077d0a3ecd..fda71753ac5 100644 --- a/packages/clerk-js/src/ui/elements/PhoneInput/PhoneInput.tsx +++ b/packages/clerk-js/src/ui/elements/PhoneInput/PhoneInput.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useLayoutEffect, useState } from 'react'; +import { forwardRef, memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCoreClerk } from '../../contexts'; import { descriptors, Flex, Input, Text } from '../../customizables'; @@ -24,7 +24,7 @@ type PhoneInputProps = PropsOfComponent & { locationBasedCountryIs const PhoneInputBase = forwardRef((props, ref) => { const { onChange: onChangeProp, value, locationBasedCountryIso, sx, ...rest } = props; - const phoneInputRef = React.useRef(null); + const phoneInputRef = useRef(null); const { setNumber, setIso, setNumberAndIso, numberWithCode, iso, formattedNumber } = useFormattedPhoneNumber({ initPhoneWithCode: value as string, locationBasedCountryIso, @@ -37,11 +37,11 @@ const PhoneInputBase = forwardRef((props, ref onChangeProp?.({ target: { value: numberWithCode } } as any); }; - const selectedCountryOption = React.useMemo(() => { + const selectedCountryOption = useMemo(() => { return countryOptions.find(o => o.country.iso === iso) || countryOptions[0]; }, [iso]); - React.useEffect(callOnChangeProp, [numberWithCode]); + useEffect(callOnChangeProp, [numberWithCode]); const handlePaste = (e: React.ClipboardEvent) => { e.preventDefault(); @@ -153,7 +153,7 @@ const PhoneInputBase = forwardRef((props, ref type CountryCodeListItemProps = PropsOfComponent & { country: CountryEntry; }; -const CountryCodeListItem = React.memo((props: CountryCodeListItemProps) => { +const CountryCodeListItem = memo((props: CountryCodeListItemProps) => { const { country, sx, ...rest } = props; return ( { describe('appendQueryParams(base,url)', () => { it('returns the same url if no params provided', () => { const base = new URL('https://dashboard.clerk.com'); - const res = appendAsQueryParams(base); + const res = appendUrlsAsQueryParams(base); expect(res).toBe('https://dashboard.clerk.com/'); }); it('handles URL objects', () => { const base = new URL('https://dashboard.clerk.com'); const url = new URL('https://dashboard.clerk.com/applications/appid/instances/'); - const res = appendAsQueryParams(base, { redirect_url: url }); + const res = appendUrlsAsQueryParams(base, { redirect_url: url }); expect(res).toBe('https://dashboard.clerk.com/#/?redirect_url=%2Fapplications%2Fappid%2Finstances%2F'); }); it('handles plain strings', () => { const base = 'https://dashboard.clerk.com'; const url = 'https://dashboard.clerk.com/applications/appid/instances/'; - const res = appendAsQueryParams(base, { redirect_url: url }); + const res = appendUrlsAsQueryParams(base, { redirect_url: url }); expect(res).toBe('https://dashboard.clerk.com/#/?redirect_url=%2Fapplications%2Fappid%2Finstances%2F'); }); it('handles multiple params', () => { const base = 'https://dashboard.clerk.com'; const url = 'https://dashboard.clerk.com/applications/appid/instances/'; - const res = appendAsQueryParams(base, { + const res = appendUrlsAsQueryParams(base, { redirect_url: url, after_sign_in_url: url, }); @@ -274,26 +274,26 @@ describe('appendQueryParams(base,url)', () => { it('skips falsy values', () => { const base = new URL('https://dashboard.clerk.com'); - const res = appendAsQueryParams(base, { redirect_url: undefined }); + const res = appendUrlsAsQueryParams(base, { redirect_url: undefined }); expect(res).toBe('https://dashboard.clerk.com/'); }); it('converts relative to absolute urls', () => { const base = new URL('https://dashboard.clerk.com'); - const res = appendAsQueryParams(base, { redirect_url: '/test' }); + const res = appendUrlsAsQueryParams(base, { redirect_url: '/test' }); expect(res).toBe('https://dashboard.clerk.com/#/?redirect_url=http%3A%2F%2Flocalhost%2Ftest'); }); it('converts keys from camel to snake case', () => { const base = new URL('https://dashboard.clerk.com'); - const res = appendAsQueryParams(base, { redirectUrl: '/test' }); + const res = appendUrlsAsQueryParams(base, { redirectUrl: '/test' }); expect(res).toBe('https://dashboard.clerk.com/#/?redirect_url=http%3A%2F%2Flocalhost%2Ftest'); }); it('keeps origin before appending if base and url have different origin', () => { const base = new URL('https://dashboard.clerk.com'); const url = new URL('https://www.google.com/something'); - const res = appendAsQueryParams(base, { redirect_url: url }); + const res = appendUrlsAsQueryParams(base, { redirect_url: url }); expect(res).toBe('https://dashboard.clerk.com/#/?redirect_url=https%3A%2F%2Fwww.google.com%2Fsomething'); }); }); diff --git a/packages/clerk-js/src/utils/url.ts b/packages/clerk-js/src/utils/url.ts index 632fcdb8e11..a4e08b0c09b 100644 --- a/packages/clerk-js/src/utils/url.ts +++ b/packages/clerk-js/src/utils/url.ts @@ -210,7 +210,7 @@ export const trimTrailingSlash = (path: string): string => { return (path || '').replace(/\/+$/, ''); }; -export const appendAsQueryParams = ( +export const appendUrlsAsQueryParams = ( baseUrl: string | URL, urls: Record = {}, ): string => { @@ -231,6 +231,21 @@ export const appendAsQueryParams = ( return `${base}${params.toString() ? '#/?' + params.toString() : ''}`; }; +export const appendAsQueryParams = ( + baseUrl: string | URL, + values: Record = {}, +): string => { + const base = toURL(baseUrl); + for (const [key, val] of Object.entries(values)) { + if (!val) { + continue; + } + base.searchParams.append(camelToSnake(key), val); + } + + return baseUrl.toString() + base.search; +}; + export const hasExternalAccountSignUpError = (signUp: SignUpResource): boolean => { const { externalAccount } = signUp.verifications; return !!externalAccount.error; diff --git a/packages/react/src/components/controlComponents.tsx b/packages/react/src/components/controlComponents.tsx index 9701835e499..20791102102 100644 --- a/packages/react/src/components/controlComponents.tsx +++ b/packages/react/src/components/controlComponents.tsx @@ -5,7 +5,7 @@ import { useAuthContext } from '../contexts/AuthContext'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; import { useSessionContext } from '../contexts/SessionContext'; import { LoadedGuarantee } from '../contexts/StructureContext'; -import type { RedirectToProps, WithClerkProp } from '../types'; +import type { RedirectToSignInProps, RedirectToSignUpProps, WithClerkProp } from '../types'; import { withClerk } from './withClerk'; export const SignedIn = ({ children }: React.PropsWithChildren): JSX.Element | null => { @@ -40,7 +40,7 @@ export const ClerkLoading = ({ children }: React.PropsWithChildren): JS return <>{children}; }; -export const RedirectToSignIn = withClerk(({ clerk, ...props }: WithClerkProp) => { +export const RedirectToSignIn = withClerk(({ clerk, ...props }: WithClerkProp) => { const { client, session } = clerk; // TODO: Remove temp use of __unstable__environment const { __unstable__environment } = clerk as any; @@ -59,7 +59,7 @@ export const RedirectToSignIn = withClerk(({ clerk, ...props }: WithClerkProp) => { +export const RedirectToSignUp = withClerk(({ clerk, ...props }: WithClerkProp) => { React.useEffect(() => { void clerk.redirectToSignUp(props); }, []); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 398e675f9de..7a1200ecf6e 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -14,13 +14,14 @@ import type { OrganizationListProps, OrganizationMembershipResource, OrganizationResource, - RedirectOptions, SetActiveParams, SignInProps, + SignInRedirectOptions, SignOut, SignOutCallback, SignOutOptions, SignUpProps, + SignUpRedirectOptions, UnsubscribeCallback, UserButtonProps, UserProfileProps, @@ -596,7 +597,7 @@ export default class IsomorphicClerk { } }; - redirectToSignIn = (opts: RedirectOptions): void => { + redirectToSignIn = (opts: SignInRedirectOptions): void => { const callback = () => this.clerkjs?.redirectToSignIn(opts as any); if (this.clerkjs && this.#loaded) { void callback(); @@ -605,7 +606,7 @@ export default class IsomorphicClerk { } }; - redirectToSignUp = (opts: RedirectOptions): void => { + redirectToSignUp = (opts: SignUpRedirectOptions): void => { const callback = () => this.clerkjs?.redirectToSignUp(opts as any); if (this.clerkjs && this.#loaded) { void callback(); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index b764d7a4bc6..7d8109601cc 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -6,8 +6,9 @@ import type { LoadedClerk, MultiDomainAndOrProxy, PublishableKeyOrFrontendApi, - RedirectOptions, SessionResource, + SignInRedirectOptions, + SignUpRedirectOptions, UserResource, } from '@clerk/types'; @@ -83,4 +84,5 @@ export interface SignUpButtonProps extends ButtonProps { export type SignInWithMetamaskButtonProps = Pick; -export type RedirectToProps = RedirectOptions; +export type RedirectToSignInProps = SignInRedirectOptions; +export type RedirectToSignUpProps = SignUpRedirectOptions; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index fbeafa34288..bdfbc653045 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -358,14 +358,14 @@ export interface Clerk { * * @param opts A {@link RedirectOptions} object */ - redirectToSignIn(opts?: RedirectOptions): Promise; + redirectToSignIn(opts?: SignInRedirectOptions): Promise; /** * Redirects to the configured URL where is mounted. * * @param opts A {@link RedirectOptions} object */ - redirectToSignUp(opts?: RedirectOptions): Promise; + redirectToSignUp(opts?: SignUpRedirectOptions): Promise; /** * Redirects to the configured URL where is mounted. @@ -559,7 +559,6 @@ export type SignUpInitialValues = { firstName?: string; lastName?: string; username?: string; - web3WalletAddress?: string; }; export type RedirectOptions = { @@ -584,6 +583,20 @@ export type RedirectOptions = { redirectUrl?: string | null; }; +export type SignInRedirectOptions = RedirectOptions & { + /** + * Initial values that are used to prefill the sign in form. + */ + initialValues?: SignInInitialValues; +}; + +export type SignUpRedirectOptions = RedirectOptions & { + /** + * Initial values that are used to prefill the sign up form. + */ + initialValues?: SignUpInitialValues; +}; + export type SetActiveParams = { /** * The session resource or session id (string version) to be set as active. From c7f0305b6c585458b2cca6706078ab7fdb2e5d0a Mon Sep 17 00:00:00 2001 From: George Desipris Date: Thu, 28 Sep 2023 17:03:11 +0300 Subject: [PATCH 3/4] test(clerk-js): Add tests for the initialValues prop --- .../SignIn/__tests__/SignInStart.test.tsx | 32 +++++++++++++++++++ .../SignUp/__tests__/SignUpStart.test.tsx | 32 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx index fbe391c9902..b728563281d 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx @@ -222,4 +222,36 @@ describe('SignInStart', () => { expect(screen.getByRole('textbox', { name: /phone number/i })).toHaveAttribute('type', 'tel'); }); }); + + describe('initialValues', () => { + it('prefills the emailAddress field with the correct initial value', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + }); + props.setProps({ initialValues: { emailAddress: 'foo@clerk.com' } }); + + render(, { wrapper }); + screen.getByDisplayValue(/foo@clerk.com/i); + }); + + it('prefills the phoneNumber field with the correct initial value', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withPhoneNumber(); + }); + props.setProps({ initialValues: { phoneNumber: '+306911111111' } }); + + render(, { wrapper }); + screen.getByDisplayValue(/691 1111111/i); + }); + + it('prefills the username field with the correct initial value', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withUsername(); + }); + + props.setProps({ initialValues: { username: 'foo' } }); + render(, { wrapper }); + screen.getByDisplayValue(/foo/i); + }); + }); }); diff --git a/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpStart.test.tsx b/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpStart.test.tsx index 5b24d443dba..9a2de2d257e 100644 --- a/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/__tests__/SignUpStart.test.tsx @@ -167,4 +167,36 @@ describe('SignUpStart', () => { expect(screen.getByRole('textbox', { name: 'Phone number' })).toHaveValue('(123) 456-789'); }); }); + + describe('initialValues', () => { + it('prefills the emailAddress field with the correct initial value', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withEmailAddress(); + }); + props.setProps({ initialValues: { emailAddress: 'foo@clerk.com' } }); + + render(, { wrapper }); + screen.getByDisplayValue(/foo@clerk.com/i); + }); + + it('prefills the phoneNumber field with the correct initial value', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withPhoneNumber(); + }); + props.setProps({ initialValues: { phoneNumber: '+306911111111' } }); + + render(, { wrapper }); + screen.getByDisplayValue(/691 1111111/i); + }); + + it('prefills the username field with the correct initial value', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withUsername(); + }); + + props.setProps({ initialValues: { username: 'foo' } }); + render(, { wrapper }); + screen.getByDisplayValue(/foo/i); + }); + }); }); From 775100b96ec8498d5e5808d7de50423e1e2652c0 Mon Sep 17 00:00:00 2001 From: George Desipris Date: Thu, 28 Sep 2023 19:38:12 +0300 Subject: [PATCH 4/4] chore(clerk-js): Update changeset --- .changeset/fresh-papayas-cheat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fresh-papayas-cheat.md b/.changeset/fresh-papayas-cheat.md index 862e73fc413..61f3f389556 100644 --- a/.changeset/fresh-papayas-cheat.md +++ b/.changeset/fresh-papayas-cheat.md @@ -4,4 +4,4 @@ '@clerk/types': minor --- -`` and `` input fields can now be prefilled with the `initialValues` prop. +``, ``, ``, ``, `clerk.redirectToSignIn()` and `clerk.redirectToSignUp()` now accept the `initialValues` option, which will prefill the appropriate form fields with the values provided.