Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-waitlist-review-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 5 additions & 0 deletions .changeset/mosaic-waitlist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': minor
---

Add `<Waitlist />`, a card that adds an email address to the application's waitlist. It takes `afterJoinWaitlistUrl` and `signInUrl` like the existing `Waitlist` component, fills the email field from an `email_address` query parameter, and its strings can be overridden under the `waitlist` localization namespace.
30 changes: 30 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
SessionWithActivitiesJSON,
UserJSON,
UserOrganizationInvitationJSON,
WaitlistJSON,
Web3WalletJSON,
} from '@clerk/shared/types';
import { http, HttpResponse } from 'msw';
Expand Down Expand Up @@ -77,6 +78,7 @@ export interface FakeFapiState {
apiKeys: ApiKeyJSON[];
verification: FakeVerificationState;
passwordUpdates: URLSearchParams[];
waitlistEntries: string[];
enterpriseConnections: EnterpriseConnectionJSON[];
enterpriseLinking: FakeEnterpriseLinking;
mfa: FakeMfaState;
Expand Down Expand Up @@ -316,6 +318,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
suggestions: [],
apiKeys: [],
passwordUpdates: [],
waitlistEntries: [],
enterpriseConnections: [],
deviceTrackingEnabled: true,
mfa: createMfaState(),
Expand Down Expand Up @@ -574,6 +577,33 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
target.status = 'revoked';
return envelope({ ...deviceSessionPayload(target), user: requester.user, status: 'revoked' }, state.client);
}),
http.post(fapiUrl('/v1/waitlist'), async ({ request }) => {
const emailAddress = new URLSearchParams(await request.text()).get('email_address') ?? '';
if (!/^[^\s@]+@[^\s@]+$/.test(emailAddress)) {
return HttpResponse.json(
{
errors: [
{
code: 'form_param_format_invalid',
message: 'is invalid',
long_message: 'email_address must be a valid email address.',
meta: { param_name: 'email_address' },
},
],
},
{ status: 422 },
);
}
state.waitlistEntries.push(emailAddress);
const now = Date.now();
const entry: WaitlistJSON = {
object: 'waitlist',
id: `wle_${state.waitlistEntries.length}`,
created_at: now,
updated_at: now,
};
return envelope(entry, null);
}),
http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => {
const session = findSession(state, state.client.last_active_session_id);
if (!session) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, onTestFinished, vi } from 'vitest';

import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
import { fapiEnvironment } from '../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../__tests__/feature/render';
import type { WaitlistProps } from '../waitlist';
import { Waitlist } from '../waitlist';

function renderWaitlist(props: WaitlistProps = {}) {
return renderWithClerk(<Waitlist {...props} />);
}

const emailField = () => screen.getByRole('textbox', { name: 'Email address' });
const joinButton = () => screen.getByRole('button', { name: 'Join the waitlist' });

async function join(emailAddress: string) {
const user = userEvent.setup();
await user.type(await screen.findByRole('textbox', { name: 'Email address' }), emailAddress);
await user.click(joinButton());
return user;
}

describe('Waitlist', () => {
describe('joining', () => {
it('adds the email address to the waitlist and thanks the user', async () => {
const fapi = serveFapi();
await renderWaitlist();

await join('alice@example.com');

expect(await screen.findByRole('heading', { name: 'Thanks for joining the waitlist!' })).toHaveFocus();
expect(screen.getByText('We’ll be in touch when your spot is ready')).toBeInTheDocument();
expect(fapi.waitlistEntries).toEqual(['alice@example.com']);
expect(screen.queryByRole('status')).toBeNull();
});

it('holds the form while the request is in flight', async () => {
serveFapi();
await renderWaitlist();
const request = holdRequests('post', '/v1/waitlist');

await join('alice@example.com');

await waitFor(() => expect(request.requests).toHaveLength(1));
expect(emailField()).toHaveAttribute('aria-disabled', 'true');
await userEvent.setup().click(screen.getByRole('button', { name: /Join the waitlist/ }));
expect(request.requests).toHaveLength(1);

request.release();
expect(await screen.findByRole('heading', { name: 'Thanks for joining the waitlist!' })).toBeInTheDocument();
});

it('cannot be submitted without an email address', async () => {
serveFapi();
await renderWaitlist();

await screen.findByRole('textbox', { name: 'Email address' });
expect(joinButton()).toHaveAttribute('aria-disabled', 'true');
});

it('fills in the email address from the query string', async () => {
const originalUrl = window.location.href;
window.history.replaceState(null, '', '?email_address=bob%40example.com');
onTestFinished(() => window.history.replaceState(null, '', originalUrl));
const fapi = serveFapi();
await renderWaitlist();

expect(await screen.findByRole('textbox', { name: 'Email address' })).toHaveValue('bob@example.com');
await userEvent.setup().click(joinButton());

await screen.findByRole('heading', { name: 'Thanks for joining the waitlist!' });
expect(fapi.waitlistEntries).toEqual(['bob@example.com']);
});
});

describe('errors', () => {
it('shows an invalid email address on the field', async () => {
const fapi = serveFapi();
await renderWaitlist();

await join('not-an-email');

await waitFor(() =>
expect(emailField()).toHaveAccessibleDescription('Email address must be a valid email address.'),
);
expect(emailField()).toHaveAttribute('aria-invalid', 'true');
expect(fapi.waitlistEntries).toEqual([]);
expect(screen.queryByRole('heading', { name: 'Thanks for joining the waitlist!' })).toBeNull();
});

it('shows a failure that names no field above the form, and lets the user try again', async () => {
serveFapi();
await renderWaitlist();
const request = holdRequests('post', '/v1/waitlist');

await join('alice@example.com');
await waitFor(() => expect(request.requests).toHaveLength(1));
request.fail('waitlist_unavailable', 'The waitlist is not accepting new entries.');

await waitFor(() =>
expect(screen.getByRole('alert')).toHaveTextContent('The waitlist is not accepting new entries.'),
);
expect(emailField()).toHaveValue('alice@example.com');
expect(emailField()).not.toHaveAttribute('aria-disabled');
expect(joinButton()).not.toHaveAttribute('aria-disabled');
});
});

describe('after joining', () => {
it('sends the user to afterJoinWaitlistUrl once the thank-you has shown', async () => {
serveFapi();
const { navigate } = await renderWaitlist({ afterJoinWaitlistUrl: '/thanks' });
vi.useFakeTimers({ shouldAdvanceTime: true });
onTestFinished(() => {
vi.useRealTimers();
});

const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await user.type(await screen.findByRole('textbox', { name: 'Email address' }), 'alice@example.com');
await user.click(joinButton());

expect(await screen.findByRole('status')).toHaveTextContent('You will be redirected soon…');
expect(navigate).not.toHaveBeenCalled();

await act(() => vi.advanceTimersByTimeAsync(2000));
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/thanks'));
});
});

describe('signing in', () => {
it('goes to the application sign-in URL', async () => {
serveFapi({ environment: fapiEnvironment({ display_config: { sign_in_url: '/login' } }) });
const { navigate } = await renderWaitlist();

await userEvent.setup().click(await screen.findByRole('button', { name: 'Sign in' }));

await waitFor(() => expect(navigate).toHaveBeenCalledWith('/login'));
});

it('goes to signInUrl when one is passed', async () => {
serveFapi();
const { navigate } = await renderWaitlist({ signInUrl: '/custom-sign-in' });

await userEvent.setup().click(await screen.findByRole('button', { name: 'Sign in' }));

await waitFor(() => expect(navigate).toHaveBeenCalledWith('/custom-sign-in'));
});
});

describe('branding', () => {
it('signs the card when the instance is branded', async () => {
serveFapi({ environment: fapiEnvironment({ display_config: { branded: true } }) });
await renderWaitlist();

await screen.findByRole('textbox', { name: 'Email address' });
expect(screen.getByText(/Secured by/)).toBeInTheDocument();
});

it('carries no signature when the branding is paid off', async () => {
serveFapi();
await renderWaitlist();

await screen.findByRole('textbox', { name: 'Email address' });
expect(screen.queryByText(/Secured by/)).toBeNull();
});
});
});
57 changes: 57 additions & 0 deletions packages/mosaic/src/features/waitlist/waitlist.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useForm } from '../../components/form';
import { setup } from '../../machine/setup';
import { useMachine } from '../../machine/use-machine';
import type { WaitlistModel } from './waitlist.types';
import type { WaitlistViewProps } from './waitlist.view';

export const WAITLIST_REDIRECT_DELAY_MS = 2000;

interface WaitlistMachineContext {
onRedirect: (() => void) | undefined;
}

type WaitlistMachineEvent = { type: 'JOINED' };

const { createMachine } = setup<WaitlistMachineContext, WaitlistMachineEvent>();

const waitlistMachine = createMachine({
id: 'waitlist',
initial: 'form',
context: { onRedirect: undefined },
states: {
form: {
on: { JOINED: 'joined' },
},
joined: {
after: {
[WAITLIST_REDIRECT_DELAY_MS]: {
guard: context => context.onRedirect !== undefined,
actions: context => context.onRedirect?.(),
},
},
},
},
});

export type WaitlistReadyModel = Extract<WaitlistModel, { status: 'ready' }>;

export function useWaitlistController(model: WaitlistReadyModel): WaitlistViewProps {
const { initialEmailAddress, renderBranding, onJoinWaitlist, onSignIn, onRedirect } = model;
const [snapshot, send] = useMachine(waitlistMachine, { context: { onRedirect } });
const form = useForm({
initialValues: { emailAddress: initialEmailAddress },
canSubmit: values => values.emailAddress.trim() !== '',
onSubmit: async values => {
await onJoinWaitlist(values.emailAddress);
send({ type: 'JOINED' });
},
});

return {
step: snapshot.value === 'form' ? 'form' : 'joined',
form,
renderBranding,
isRedirecting: onRedirect !== undefined,
onSignIn,
};
}
19 changes: 19 additions & 0 deletions packages/mosaic/src/features/waitlist/waitlist.messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const waitlistMessages = {
start: {
title: 'Join the waitlist',
description: 'Enter your email address and we’ll let you know when your spot is ready',
emailLabel: 'Email address',
emailPlaceholder: 'Enter your email address',
submit: 'Join the waitlist',
pending: 'Joining the waitlist',
},
success: {
title: 'Thanks for joining the waitlist!',
description: 'We’ll be in touch when your spot is ready',
redirecting: 'You will be redirected soon…',
},
signIn: {
text: 'Already have access?',
action: 'Sign in',
},
} as const;
34 changes: 34 additions & 0 deletions packages/mosaic/src/features/waitlist/waitlist.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useClerk } from '@clerk/shared/react';
import type { WaitlistProps } from '@clerk/shared/types';

import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment';
import { useMosaicRouter } from '../../hooks/use-mosaic-router';
import { save } from '../../utils/errors';
import type { WaitlistField, WaitlistModel } from './waitlist.types';

export type WaitlistModelOptions = Pick<WaitlistProps, 'afterJoinWaitlistUrl' | 'signInUrl'>;

const WAITLIST_FIELDS: readonly WaitlistField[] = ['emailAddress'];

export function useWaitlistModel(options: WaitlistModelOptions = {}): WaitlistModel {
const clerk = useClerk();
const environment = useMosaicEnvironment();
const router = useMosaicRouter();

if (!clerk.loaded || !environment) {
return { status: 'loading' };
}

const { displayConfig } = environment;
const signInUrl = options.signInUrl || clerk.__internal_getOption('signInUrl') || displayConfig.signInUrl;
const { afterJoinWaitlistUrl } = options;

return {
status: 'ready',
initialEmailAddress: router.getSearchParams().get('email_address') ?? '',
renderBranding: displayConfig.branded,
onJoinWaitlist: emailAddress => save(() => clerk.joinWaitlist({ emailAddress }), WAITLIST_FIELDS),
onSignIn: () => void router.navigate(clerk.buildUrlWithAuth(signInUrl)),
onRedirect: afterJoinWaitlistUrl ? () => void router.navigate(afterJoinWaitlistUrl) : undefined,
};
}
18 changes: 18 additions & 0 deletions packages/mosaic/src/features/waitlist/waitlist.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as stylex from '@stylexjs/stylex';

import { space } from '../../tokens.stylex';

export const styles = stylex.create({
redirecting: {
gap: space['2'],
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
},
footer: {
justifyContent: 'center',
},
footerText: {
textAlign: 'center',
},
});
Loading
Loading