Skip to content

Commit

Permalink
feat(elements,ui,clerk-js): Legal consent elements support and improv…
Browse files Browse the repository at this point in the history
…ements (#4427)

Co-authored-by: Tom Milewski <[email protected]>
  • Loading branch information
octoper and tmilewski authored Oct 31, 2024
1 parent 69c8f4f commit 434b432
Show file tree
Hide file tree
Showing 17 changed files with 250 additions and 35 deletions.
5 changes: 5 additions & 0 deletions .changeset/odd-peas-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/elements": minor
---

Added support for `__experimental_legalAccepted` field
8 changes: 8 additions & 0 deletions .changeset/witty-meals-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@clerk/clerk-js": patch
"@clerk/types": patch
---

- Changed `__experimental_legalAccepted` checkbox Indicator element descriptor and element id
- Changed `__experimental_legalAccepted` checkbox Label element descriptor and element id
- Added two new element descriptors `formFieldCheckboxInput`, `formFieldCheckboxLabel`.
2 changes: 1 addition & 1 deletion packages/clerk-js/src/ui/components/SignUp/SignUpStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ function _SignUpStart(): JSX.Element {
enableOAuthProviders={showOauthProviders}
enableWeb3Providers={showWeb3Providers}
continueSignUp={missingRequirementsWithTicket}
legalAccepted={Boolean(formState.__experimental_legalAccepted.checked)}
legalAccepted={Boolean(formState.__experimental_legalAccepted.checked) || undefined}
/>
)}
{shouldShowForm && (
Expand Down
2 changes: 2 additions & 0 deletions packages/clerk-js/src/ui/customizables/elementDescriptors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'formFieldRadioLabel',
'formFieldRadioLabelTitle',
'formFieldRadioLabelDescription',
'formFieldCheckboxInput',
'formFieldCheckboxLabel',
'formFieldAction',
'formFieldInput',
'formFieldErrorText',
Expand Down
44 changes: 26 additions & 18 deletions packages/clerk-js/src/ui/elements/FieldControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Text,
useLocalizations,
} from '../customizables';
import type { ElementDescriptor, ElementId } from '../customizables/elementDescriptors';
import { FormFieldContextProvider, sanitizeInputProps, useFormField } from '../primitives/hooks';
import type { PropsOfComponent } from '../styledSystem';
import type { useFormControl as useFormControlUtil } from '../utils';
Expand Down Expand Up @@ -208,25 +209,32 @@ const PasswordInputElement = forwardRef<HTMLInputElement>((_, ref) => {
);
});

const CheckboxIndicator = forwardRef<HTMLInputElement>((_, ref) => {
const formField = useFormField();
const { placeholder, ...inputProps } = sanitizeInputProps(formField);
type CheckboxIndicatorProps = {
elementDescriptor?: ElementDescriptor;
elementId?: ElementId;
};

return (
<CheckboxInput
ref={ref}
{...inputProps}
elementDescriptor={descriptors.formFieldInput}
elementId={descriptors.formFieldInput.setId(formField.fieldId)}
focusRing={false}
sx={t => ({
width: 'fit-content',
flexShrink: 0,
marginTop: t.space.$0x5,
})}
/>
);
});
const CheckboxIndicator = forwardRef<HTMLInputElement, CheckboxIndicatorProps>(
({ elementDescriptor, elementId }, ref) => {
const formField = useFormField();
const { placeholder, ...inputProps } = sanitizeInputProps(formField);

return (
<CheckboxInput
ref={ref}
{...inputProps}
elementDescriptor={elementDescriptor || descriptors.formFieldInput}
elementId={elementId || descriptors.formFieldInput.setId(formField.fieldId)}
focusRing={false}
sx={t => ({
width: 'fit-content',
flexShrink: 0,
marginTop: t.space.$0x5,
})}
/>
);
},
);

const CheckboxLabel = (props: { description?: string | LocalizationKey }) => {
const { label, id } = useFormField();
Expand Down
7 changes: 5 additions & 2 deletions packages/clerk-js/src/ui/elements/LegalConsentCheckbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,12 @@ export const LegalCheckbox = (
return (
<Field.Root {...props}>
<Flex justify='center'>
<Field.CheckboxIndicator />
<Field.CheckboxIndicator
elementDescriptor={descriptors.formFieldCheckboxInput}
elementId={descriptors.formFieldInput.setId('__experimental_legalAccepted')}
/>
<FormLabel
elementDescriptor={descriptors.formFieldRadioLabel}
elementDescriptor={descriptors.formFieldCheckboxLabel}
htmlFor={props.itemID}
sx={t => ({
paddingLeft: t.space.$1x5,
Expand Down
9 changes: 7 additions & 2 deletions packages/elements/src/internals/machines/form/form.machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,14 @@ export const FormMachine = setup({
throw new Error('Field name is required');
}

if (context.fields.has(params.name)) {
const fieldsNameMap: Record<string, string> = {
legalAccepted: '__experimental_legalAccepted',
};
const fieldName = fieldsNameMap[params.name] || params.name;

if (context.fields.has(fieldName)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
context.fields.get(params.name)!.feedback = params.feedback;
context.fields.get(fieldName)!.feedback = params.feedback;
}

return context.fields;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { snakeToCamel } from '@clerk/shared/underscore';
import type { SignUpResource } from '@clerk/types';
import type { DoneActorEvent } from 'xstate';
import { fromPromise, setup } from 'xstate';
import { fromPromise, not, or, setup } from 'xstate';

import { SIGN_UP_DEFAULT_BASE_PATH } from '~/internals/constants';
import type { FormDefaultValues, FormFields } from '~/internals/machines/form';
Expand Down Expand Up @@ -62,6 +62,21 @@ export const SignUpContinueMachine = setup({
context.parent.send({ type: 'NEXT', resource: (event as unknown as DoneActorEvent<SignUpResource>).output }),
sendToLoading,
},
guards: {
isStatusMissingRequirements: ({ context }) =>
context.parent.getSnapshot().context.clerk?.client?.signUp?.status === 'missing_requirements',
hasMetPreviousMissingRequirements: ({ context }) => {
const signUp = context.parent.getSnapshot().context.clerk.client.signUp;

const fields = context.formRef.getSnapshot().context.fields;
const signUpMissingFields = signUp.missingFields.map(snakeToCamel);
const missingFields = Array.from(context.formRef.getSnapshot().context.fields.keys()).filter(key => {
return !signUpMissingFields.includes(key) && !fields.get(key)?.value && !fields.get(key)?.checked;
});

return missingFields.length === 0;
},
},
types: {} as SignUpContinueSchema,
}).createMachine({
id: SignUpContinueMachineId,
Expand All @@ -82,6 +97,7 @@ export const SignUpContinueMachine = setup({
description: 'Waiting for user input',
on: {
SUBMIT: {
guard: or(['hasMetPreviousMissingRequirements', not('isStatusMissingRequirements')]),
target: 'Attempting',
reenter: true,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import type { SignUpCreateParams, SignUpUpdateParams } from '@clerk/types';

import type { FormFields } from '~/internals/machines/form';

const SignUpAdditionalKeys = ['firstName', 'lastName', 'emailAddress', 'username', 'password', 'phoneNumber'] as const;
const SignUpAdditionalKeys = [
'firstName',
'lastName',
'emailAddress',
'username',
'password',
'phoneNumber',
'__experimental_legalAccepted',
] as const;

type SignUpAdditionalKeys = (typeof SignUpAdditionalKeys)[number];

Expand All @@ -17,10 +25,16 @@ export function fieldsToSignUpParams<T extends SignUpCreateParams | SignUpUpdate
): Pick<T, SignUpAdditionalKeys> {
const params: SignUpUpdateParams = {};

fields.forEach(({ value }, key) => {
if (isSignUpParam(key) && value !== undefined) {
fields.forEach(({ value, checked, type }, key) => {
if (isSignUpParam(key) && value !== undefined && type !== 'checkbox') {
// @ts-expect-error - Type is not narrowed to string
params[key] = value as string;
}

if (isSignUpParam(key) && checked !== undefined && type === 'checkbox') {
// @ts-expect-error - Type is not narrowed to boolean
params[key] = checked as boolean;
}
});

return params;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,17 @@ export const ThirdPartyMachine = setup({
input: ({ context, event }) => {
assertEvent(event, 'REDIRECT');

const legalAcceptedField = context.formRef
.getSnapshot()
.context.fields.get('__experimental_legalAccepted')?.checked;

return {
basePath: context.basePath,
flow: context.flow,
params: event.params,
params: {
...event.params,
__experimental_legalAccepted: legalAcceptedField || undefined,
},
parent: context.parent,
};
},
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/appearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export type ElementsConfig = {
formFieldRadioLabel: WithOptions<FieldId, ControlState>;
formFieldRadioLabelTitle: WithOptions<FieldId, ControlState>;
formFieldRadioLabelDescription: WithOptions<FieldId, ControlState>;
formFieldCheckboxInput: WithOptions<FieldId, ControlState>;
formFieldCheckboxLabel: WithOptions<FieldId, ControlState>;
formFieldAction: WithOptions<FieldId, ControlState>;
formFieldInput: WithOptions<FieldId, ControlState>;
formFieldErrorText: WithOptions<FieldId, ControlState>;
Expand Down
75 changes: 75 additions & 0 deletions packages/ui/src/common/legal-accepted.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as Common from '@clerk/elements/common';
import React from 'react';

import { useAppearance } from '~/contexts';
import { useEnvironment } from '~/hooks/use-environment';
import { useLocalizations } from '~/hooks/use-localizations';
import * as Field from '~/primitives/field';

import { LinkRenderer } from './link-renderer';

export function LegalAcceptedField({
className,
checked = false,
...restProps
}: Omit<React.ComponentProps<typeof Common.Input>, 'type'>) {
const { t } = useLocalizations();
const { displayConfig } = useEnvironment();
const { parsedAppearance } = useAppearance();
const termsUrl = parsedAppearance.options.termsPageUrl || displayConfig.termsUrl;
const privacyPolicyUrl = parsedAppearance.options.privacyPageUrl || displayConfig.privacyPolicyUrl;

let localizedText: string | undefined;

if (termsUrl && privacyPolicyUrl) {
localizedText = t('signUp.__experimental_legalConsent.checkbox.label__termsOfServiceAndPrivacyPolicy', {
termsOfServiceLink: termsUrl,
privacyPolicyLink: privacyPolicyUrl,
});
} else if (termsUrl) {
localizedText = t('signUp.__experimental_legalConsent.checkbox.label__onlyTermsOfService', {
termsOfServiceLink: termsUrl,
});
} else if (privacyPolicyUrl) {
localizedText = t('signUp.__experimental_legalConsent.checkbox.label__onlyPrivacyPolicy', {
privacyPolicyLink: privacyPolicyUrl,
});
}

return (
<Common.Field
name='__experimental_legalAccepted'
asChild
>
<Field.Root>
<div className='flex justify-center gap-2'>
<Common.Input
type='checkbox'
asChild
checked={checked}
{...restProps}
>
<Field.Checkbox />
</Common.Input>

<Common.Label asChild>
<Field.Label>
<span>
<LinkRenderer
text={localizedText || ''}
className='underline underline-offset-2'
/>
</span>
</Field.Label>
</Common.Label>
</div>

<Common.FieldError asChild>
{({ message }) => {
return <Field.Message intent='error'>{message}</Field.Message>;
}}
</Common.FieldError>
</Field.Root>
</Common.Field>
);
}
44 changes: 44 additions & 0 deletions packages/ui/src/common/link-renderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React, { memo, useMemo } from 'react';

interface LinkRendererProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'children' | 'class'> {
text: string;
className?: string;
}

const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g; // parses [text](url)

export const LinkRenderer: React.FC<LinkRendererProps> = memo(({ text, ...linkProps }) => {
const memoizedLinkProps = useMemo(() => linkProps, [linkProps]);

const renderedContent = useMemo(() => {
const parts: (string | JSX.Element)[] = [];
let lastIndex = 0;

text.replace(LINK_REGEX, (match, linkText, url, offset) => {
if (offset > lastIndex) {
parts.push(text.slice(lastIndex, offset));
}
parts.push(
<a
{...memoizedLinkProps}
href={url}
target='_blank'
rel='noopener noreferrer'
key={offset}
>
{linkText}
</a>,
);
lastIndex = offset + match.length;
return match;
});

if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}

return parts;
}, [text, memoizedLinkProps]);

return renderedContent;
});
Loading

0 comments on commit 434b432

Please sign in to comment.