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
1 change: 1 addition & 0 deletions integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ Assuming you have a `react-parcel` template defined in `integration/templates`,
```

Here's what each thing is doing:

- `setName`: Set internal name
- `useTemplate`: Define which template inside `integration/templates` to use
- `setEnvFormatter`: Define how environment variables should be formatted. The first argument accepts `'public'` and `'private'`. Inside [`envs.ts`](./presets/envs.ts) the environment variables you can use through [`withEnv`](#environment-configs) are defined. Since different frameworks require environment variables to be in different formats (e.g. Next.js wants public env vars to be prefixed with `NEXT_PUBLIC_`) you can use this formatter to change that.
Expand Down
5 changes: 5 additions & 0 deletions integration/templates/custom-flows-react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Home } from './routes/Home';
import { SignIn } from './routes/SignIn';
import { SignUp } from './routes/SignUp';
import { Protected } from './routes/Protected';
import { Waitlist } from './routes/Waitlist';

// Import your Publishable Key
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
Expand Down Expand Up @@ -37,6 +38,10 @@ createRoot(document.getElementById('root')!).render(
path='/sign-up'
element={<SignUp />}
/>
<Route
path='/waitlist'
element={<Waitlist />}
/>
<Route
path='/protected'
element={<Protected />}
Expand Down
112 changes: 112 additions & 0 deletions integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use client';

import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useWaitlist } from '@clerk/react';
import { NavLink } from 'react-router';

export function Waitlist({ className, ...props }: React.ComponentProps<'div'>) {
const { waitlist, errors, fetchStatus } = useWaitlist();

const handleSubmit = async (formData: FormData) => {
const emailAddress = formData.get('emailAddress') as string | null;

if (!emailAddress) {
return;
}

await waitlist.join({ emailAddress });
};

if (waitlist?.id) {
return (
<div
className={cn('flex flex-col gap-6', className)}
{...props}
>
<Card>
<CardHeader className='text-center'>
<CardTitle className='text-xl'>Successfully joined!</CardTitle>
<CardDescription>You&apos;re on the waitlist</CardDescription>
</CardHeader>
<CardContent>
<div className='grid gap-6'>
<div className='text-center text-sm'>
Already have an account?{' '}
<NavLink
to='/sign-in'
className='underline underline-offset-4'
data-testid='sign-in-link'
>
Sign in
</NavLink>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

return (
<div
className={cn('flex flex-col gap-6', className)}
{...props}
>
<Card>
<CardHeader className='text-center'>
<CardTitle className='text-xl'>Join the Waitlist</CardTitle>
<CardDescription>Enter your email address to join the waitlist</CardDescription>
</CardHeader>
<CardContent>
<form action={handleSubmit}>
<div className='grid gap-6'>
<div className='grid gap-6'>
<div className='grid gap-3'>
<Label htmlFor='emailAddress'>Email address</Label>
<Input
id='emailAddress'
type='email'
placeholder='Email address'
required
name='emailAddress'
data-testid='email-input'
/>
{errors.fields.emailAddress && (
<p
className='text-sm text-red-600'
data-testid='email-error'
>
{errors.fields.emailAddress.longMessage}
</p>
)}
</div>
<Button
type='submit'
className='w-full'
disabled={fetchStatus === 'fetching'}
data-testid='submit-button'
>
Join Waitlist
</Button>
</div>
<div className='text-center text-sm'>
Already have an account?{' '}
<NavLink
to='/sign-in'
className='underline underline-offset-4'
data-testid='sign-in-link'
>
Sign in
</NavLink>
</div>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
132 changes: 132 additions & 0 deletions integration/tests/custom-flows/waitlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { parsePublishableKey } from '@clerk/shared/keys';
import { clerkSetup } from '@clerk/testing/playwright';
import { expect, test } from '@playwright/test';

import type { Application } from '../../models/application';
import { appConfigs } from '../../presets';
import type { FakeUser } from '../../testUtils';
import { createTestUtils } from '../../testUtils';

test.describe('Custom Flows Waitlist @custom', () => {
test.describe.configure({ mode: 'parallel' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
app = await appConfigs.customFlows.reactVite.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withWaitlistdMode);
await app.dev();

const publishableKey = appConfigs.envs.withWaitlistdMode.publicVariables.get('CLERK_PUBLISHABLE_KEY');
const secretKey = appConfigs.envs.withWaitlistdMode.privateVariables.get('CLERK_SECRET_KEY');
const apiUrl = appConfigs.envs.withWaitlistdMode.privateVariables.get('CLERK_API_URL');
const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);

await clerkSetup({
publishableKey,
frontendApiUrl,
secretKey,
// @ts-expect-error
apiUrl,
dotenv: false,
});

const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
});

test.afterAll(async () => {
await fakeUser.deleteIfExists();
await app.teardown();
});

test('can join waitlist with email', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await u.page.waitForClerkJsLoaded();
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await emailInput.fill(fakeUser.email);
await submitButton.click();

await expect(u.page.getByText('Successfully joined!')).toBeVisible();
await expect(u.page.getByText("You're on the waitlist")).toBeVisible();
});

test('renders error with invalid email', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await u.page.waitForClerkJsLoaded();
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await emailInput.fill('invalid-email@com');
await submitButton.click();

await expect(u.page.getByTestId('email-error')).toBeVisible();
});

test('displays loading state while joining', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await u.page.waitForClerkJsLoaded();
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await emailInput.fill(fakeUser.email);

const submitPromise = submitButton.click();

// Check that button is disabled during fetch
await expect(submitButton).toBeDisabled();

await submitPromise;

// Wait for success state
await expect(u.page.getByText('Successfully joined!')).toBeVisible();
});

test('can navigate to sign-in from waitlist', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await u.page.waitForClerkJsLoaded();
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const signInLink = u.page.getByTestId('sign-in-link');
await expect(signInLink).toBeVisible();
await signInLink.click();

await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
await u.page.waitForURL(/sign-in/);
});

test('waitlist hook provides correct properties', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await u.page.waitForClerkJsLoaded();

// Check initial state - waitlist resource should be available but empty
const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await expect(emailInput).toBeVisible();
await expect(submitButton).toBeEnabled();

// Join waitlist
await emailInput.fill(fakeUser.email);
await submitButton.click();

// After successful join, the component should show success state
await expect(u.page.getByText('Successfully joined!')).toBeVisible();
});
});
48 changes: 46 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { JoinWaitlistParams, WaitlistJSON, WaitlistResource } from '@clerk/shared/types';
import type { JoinWaitlistParams, WaitlistFutureResource, WaitlistJSON, WaitlistResource } from '@clerk/shared/types';

import { unixEpochToDate } from '../../utils/date';
import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask';
import { eventBus } from '../events';
import { BaseResource } from './internal';

export class Waitlist extends BaseResource implements WaitlistResource {
Expand All @@ -10,7 +12,22 @@ export class Waitlist extends BaseResource implements WaitlistResource {
updatedAt: Date | null = null;
createdAt: Date | null = null;

constructor(data: WaitlistJSON) {
/**
* @experimental This experimental API is subject to change.
*
* An instance of `WaitlistFuture`, which has a different API than `Waitlist`, intended to be used in custom flows.
*/
__internal_future: WaitlistFuture = new WaitlistFuture(this);

/**
* @internal Only used for internal purposes, and is not intended to be used directly.
*
* This property is used to provide access to underlying Client methods to `WaitlistFuture`, which wraps an instance
* of `Waitlist`.
*/
__internal_basePost = this._basePost.bind(this);

constructor(data: WaitlistJSON | null = null) {
super();
this.fromJSON(data);
}
Expand All @@ -23,6 +40,8 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.id = data.id;
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
return this;
}

Expand All @@ -38,3 +57,28 @@ export class Waitlist extends BaseResource implements WaitlistResource {
return new Waitlist(json);
}
}

class WaitlistFuture implements WaitlistFutureResource {
constructor(readonly resource: Waitlist) {}

get id() {
return this.resource.id || undefined;
}

get createdAt() {
return this.resource.createdAt;
}

get updatedAt() {
return this.resource.updatedAt;
}

async join(params: JoinWaitlistParams): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: params,
});
});
}
}
17 changes: 16 additions & 1 deletion packages/clerk-js/src/core/signals.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { isClerkAPIResponseError } from '@clerk/shared/error';
import type { Errors, SignInSignal, SignUpSignal } from '@clerk/shared/types';
import type { Errors, SignInSignal, SignUpSignal, WaitlistSignal } from '@clerk/shared/types';
import { snakeToCamel } from '@clerk/shared/underscore';
import { computed, signal } from 'alien-signals';

import type { SignIn } from './resources/SignIn';
import type { SignUp } from './resources/SignUp';
import type { Waitlist } from './resources/Waitlist';

export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null });
export const signInErrorSignal = signal<{ error: unknown }>({ error: null });
Expand Down Expand Up @@ -34,6 +35,20 @@ export const signUpComputedSignal: SignUpSignal = computed(() => {
return { errors, fetchStatus, signUp: signUp ? signUp.__internal_future : null };
});

export const waitlistResourceSignal = signal<{ resource: Waitlist | null }>({ resource: null });
export const waitlistErrorSignal = signal<{ error: unknown }>({ error: null });
export const waitlistFetchSignal = signal<{ status: 'idle' | 'fetching' }>({ status: 'idle' });

export const waitlistComputedSignal: WaitlistSignal = computed(() => {
const waitlist = waitlistResourceSignal().resource;
const error = waitlistErrorSignal().error;
const fetchStatus = waitlistFetchSignal().status;

const errors = errorsToParsedErrors(error);

return { errors, fetchStatus, waitlist: waitlist ? waitlist.__internal_future : null };
});

/**
* Converts an error to a parsed errors object that reports the specific fields that the error pertains to. Will put
* generic non-API errors into the global array.
Expand Down
Loading
Loading