-
Notifications
You must be signed in to change notification settings - Fork 422
feat(react): Implement waitlist hook with signal primitives #7097
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
Changes from all commits
26419cb
bd48cfc
7dc031e
000592b
c3484e1
1e6865f
12688ca
60ca143
bfc3364
4b79f9a
8af2285
61b8bd0
17774f2
346a83d
d541b79
203131c
8a6e71f
cd0104b
406f16a
3386df5
b24759b
51f6853
f8606fa
ce964e5
0cf9faf
2da25c6
c7b5ae7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@clerk/clerk-js': minor | ||
| '@clerk/shared': minor | ||
| '@clerk/react': minor | ||
| --- | ||
|
|
||
| Introduce `useWaitlist()` hook |
| 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'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> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import type { ClerkClient } from '@clerk/backend'; | ||
|
|
||
| export type WaitlistService = { | ||
| clearWaitlistByEmail: (email: string) => Promise<void>; | ||
| }; | ||
|
|
||
| export const createWaitlistService = (clerkClient: ClerkClient) => { | ||
| const self: WaitlistService = { | ||
| clearWaitlistByEmail: async (email: string) => { | ||
| const { data: entries } = await clerkClient.waitlistEntries.list({ query: email, status: 'pending' }); | ||
|
|
||
| if (entries.length > 0) { | ||
| await clerkClient.waitlistEntries.delete(entries[0].id); | ||
| } | ||
| }, | ||
| }; | ||
|
|
||
| return self; | ||
| }; | ||
|
Comment on lines
+7
to
+19
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Search for patterns where waitlist entries are created or cleared
rg -n -C3 --type=ts 'clearWaitlistByEmail|waitlist.*create|waitlist.*join' integration/Repository: clerk/javascript Length of output: 3765 🏁 Script executed: # Also check the WaitlistService interface definition
rg -n 'interface WaitlistService|type WaitlistService' --type=tsRepository: clerk/javascript Length of output: 134 🏁 Script executed: # Check if there are any scenarios with multiple entries per email
rg -n 'waitlistEntries.*list' --type=ts -A5 -B5Repository: clerk/javascript Length of output: 1577 🏁 Script executed: # Check JSDoc patterns in other testUtils services
rg -n '/\*\*' integration/testUtils/ -A5 -B1 --type=tsRepository: clerk/javascript Length of output: 4789 🏁 Script executed: # Check if there are other scenarios where the same email might create multiple entries
rg -n 'fakeEmail|fakeUser.email' integration/tests/custom-flows/waitlist.test.ts -B5 -A5Repository: clerk/javascript Length of output: 1853 Add JSDoc documentation to the public WaitlistService API. Per the project's TypeScript guidelines, public APIs require JSDoc comments. The +/**
+ * Creates a waitlist service for managing test waitlist entries.
+ * @param clerkClient - The Clerk client instance
+ * @returns A service for clearing waitlist entries by email
+ */
export const createWaitlistService = (clerkClient: ClerkClient) => {
const self: WaitlistService = {
+ /**
+ * Clears a pending waitlist entry by email address.
+ * @param email - The email address to clear from the waitlist
+ */
clearWaitlistByEmail: async (email: string) => {🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| 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 { hash } from '../../models/helpers'; | ||
| import { appConfigs } from '../../presets'; | ||
| import { createTestUtils } from '../../testUtils'; | ||
|
|
||
| test.describe('Custom Flows Waitlist @custom', () => { | ||
| test.describe.configure({ mode: 'parallel' }); | ||
| let app: Application; | ||
| const fakeEmails: string[] = []; | ||
|
|
||
| test.beforeAll(async () => { | ||
| app = await appConfigs.customFlows.reactVite.clone().commit(); | ||
| await app.setup(); | ||
| await app.withEnv(appConfigs.envs.withWaitlistMode); | ||
| await app.dev(); | ||
|
|
||
| const publishableKey = appConfigs.envs.withWaitlistMode.publicVariables.get('CLERK_PUBLISHABLE_KEY'); | ||
| const secretKey = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_SECRET_KEY'); | ||
| const apiUrl = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_API_URL'); | ||
| const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Destructuring from potentially
Use - const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);
+ const parsedKey = parsePublishableKey(publishableKey, { fatal: true });
+ const { frontendApi: frontendApiUrl } = parsedKey;
🤖 Prompt for AI Agents |
||
|
|
||
|
Comment on lines
+21
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle possibly
Consider guarding or asserting non-null first, e.g.: - const publishableKey = appConfigs.envs.withWaitlistMode.publicVariables.get('CLERK_PUBLISHABLE_KEY');
- const secretKey = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_SECRET_KEY');
- const apiUrl = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_API_URL');
- const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);
+ const publishableKey = appConfigs.envs.withWaitlistMode.publicVariables.get('CLERK_PUBLISHABLE_KEY');
+ const secretKey = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_SECRET_KEY');
+ const apiUrl = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_API_URL');
+
+ const parsedKey = parsePublishableKey(publishableKey, { fatal: true });
+ if (!parsedKey) {
+ throw new Error('Invalid CLERK_PUBLISHABLE_KEY for withWaitlistMode test environment');
+ }
+ const { frontendApi: frontendApiUrl } = parsedKey;This satisfies TS and avoids a potential runtime destructuring of
🤖 Prompt for AI Agents |
||
| await clerkSetup({ | ||
| publishableKey, | ||
| frontendApiUrl, | ||
| secretKey, | ||
| // @ts-expect-error | ||
| apiUrl, | ||
| dotenv: false, | ||
| }); | ||
| }); | ||
|
|
||
| test.afterAll(async () => { | ||
| const u = createTestUtils({ app }); | ||
| await Promise.all(fakeEmails.map(email => u.services.waitlist.clearWaitlistByEmail(email))); | ||
| await app.teardown(); | ||
| }); | ||
|
|
||
| test('can join waitlist with email', async ({ page, context }) => { | ||
| const fakeEmail = `${hash()}+clerk_test@clerkcookie.com`; | ||
| fakeEmails.push(fakeEmail); | ||
|
|
||
| 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(fakeEmail); | ||
| 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 fakeEmail = `${hash()}+clerk_test@clerkcookie.com`; | ||
| fakeEmails.push(fakeEmail); | ||
|
|
||
| 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(fakeEmail); | ||
|
|
||
| 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(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Who put that there! 👀