Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(elements,ui,clerk-js): Legal consent elements support and improvements #4427

Merged
merged 11 commits into from
Oct 31, 2024
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'>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will want to replace the gap-2 usage here with padding left on the label.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense!

<Common.Input
type='checkbox'
asChild
checked={checked}
{...restProps}
>
<Field.Checkbox />
</Common.Input>

<Common.Label asChild>
<Field.Label>
<span>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a reason for the extra wrapping span here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah because the <Field.Label/> components has a flex and I cant override id as the classes are not merged so I wrapped it in a span to escape it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, maybe we need a prop on the label to set the flex direction? Trying to reduce the amount of extra markup added to reduce the need for descriptors.

<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
Loading