Skip to content

Commit b2be957

Browse files
chore(clerk-js): Keyless ui refactor (#7798)
Co-authored-by: Robert Soriano <sorianorobertc@gmail.com>
1 parent 5196122 commit b2be957

File tree

7 files changed

+860
-508
lines changed

7 files changed

+860
-508
lines changed

.changeset/beige-snakes-guess.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Updates Keyless Prompt content.

integration/tests/next-quickstart-keyless.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,6 @@ test.describe('Keyless mode @quickstart', () => {
7878

7979
await u.po.keylessPopover.waitForMounted();
8080

81-
expect(await u.po.keylessPopover.isExpanded()).toBe(false);
82-
await u.po.keylessPopover.toggle();
8381
expect(await u.po.keylessPopover.isExpanded()).toBe(true);
8482

8583
const claim = await u.po.keylessPopover.promptsToClaim();

packages/clerk-js/src/test/create-fixtures.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ const unboundCreateFixtures = (
9292
'SubscriptionDetails',
9393
'PlanDetails',
9494
'Checkout',
95+
'KeylessPrompt',
9596
];
9697
const contextWrappedChildren = !componentsWithoutContext.includes(componentName) ? (
9798
<ComponentContextProvider

packages/clerk-js/src/test/fixture-helpers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,10 @@ const createAuthConfigFixtureHelpers = (environment: EnvironmentJSON) => {
307307
const withReverification = () => {
308308
ac.reverification = true;
309309
};
310-
return { withMultiSessionMode, withReverification };
310+
const withClaimedAt = (claimedAt: string | null) => {
311+
ac.claimed_at = claimedAt;
312+
};
313+
return { withMultiSessionMode, withReverification, withClaimedAt };
311314
};
312315

313316
const createDisplayConfigFixtureHelpers = (environment: EnvironmentJSON) => {
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import React from 'react';
2+
import { describe, expect, it, vi } from 'vitest';
3+
4+
import { bindCreateFixtures } from '@/test/create-fixtures';
5+
import { render } from '@/test/utils';
6+
7+
import { getCurrentState, getResolvedContent, KeylessPrompt } from '../index';
8+
9+
const { createFixtures } = bindCreateFixtures('KeylessPrompt' as any);
10+
11+
describe('getCurrentState', () => {
12+
it('returns completed when success is true', () => {
13+
expect(getCurrentState(true, true, true)).toBe('completed');
14+
expect(getCurrentState(true, true, false)).toBe('completed');
15+
expect(getCurrentState(false, true, true)).toBe('completed');
16+
expect(getCurrentState(false, true, false)).toBe('completed');
17+
});
18+
19+
it('returns claimed when claimed is true and success is false', () => {
20+
expect(getCurrentState(true, false, true)).toBe('claimed');
21+
expect(getCurrentState(true, false, false)).toBe('claimed');
22+
});
23+
24+
it('returns userCreated when isSignedIn is true and claimed/success are false', () => {
25+
expect(getCurrentState(false, false, true)).toBe('userCreated');
26+
});
27+
28+
it('returns idle when all flags are false', () => {
29+
expect(getCurrentState(false, false, false)).toBe('idle');
30+
});
31+
32+
it('follows precedence: completed > claimed > userCreated > idle', () => {
33+
// All true -> completed
34+
expect(getCurrentState(true, true, true)).toBe('completed');
35+
36+
// claimed + isSignedIn but no success -> claimed
37+
expect(getCurrentState(true, false, true)).toBe('claimed');
38+
39+
// isSignedIn but no claimed/success -> userCreated
40+
expect(getCurrentState(false, false, true)).toBe('userCreated');
41+
42+
// All false -> idle
43+
expect(getCurrentState(false, false, false)).toBe('idle');
44+
});
45+
});
46+
47+
describe('getResolvedContent', () => {
48+
const baseContext = {
49+
appName: 'Test App',
50+
instanceUrl: 'https://dashboard.clerk.com/apps/app_123/instances/ins_456',
51+
claimUrl: 'https://dashboard.clerk.com/claim',
52+
onDismiss: null,
53+
};
54+
55+
describe('idle state', () => {
56+
it('builds correct view model for idle state', () => {
57+
const resolvedContent = getResolvedContent('idle', baseContext);
58+
59+
expect(resolvedContent.state).toBe('idle');
60+
expect(resolvedContent.title).toBe('Configure your application');
61+
expect(resolvedContent.triggerWidth).toBe('14.25rem');
62+
expect(resolvedContent.cta.kind).toBe('link');
63+
expect(resolvedContent.cta.text).toBe('Configure your application');
64+
if (resolvedContent.cta.kind === 'link') {
65+
expect(resolvedContent.cta.href).toBe(baseContext.claimUrl);
66+
}
67+
});
68+
69+
it('resolves static description correctly', () => {
70+
const resolvedContent = getResolvedContent('idle', baseContext);
71+
expect(resolvedContent.description).toBeDefined();
72+
expect(React.isValidElement(resolvedContent.description)).toBe(true);
73+
});
74+
});
75+
76+
describe('userCreated state', () => {
77+
it('builds correct view model for userCreated state', () => {
78+
const resolvedContent = getResolvedContent('userCreated', baseContext);
79+
80+
expect(resolvedContent.state).toBe('userCreated');
81+
expect(resolvedContent.title).toBe("You've created your first user!");
82+
expect(resolvedContent.triggerWidth).toBe('15.75rem');
83+
expect(resolvedContent.cta.kind).toBe('link');
84+
expect(resolvedContent.cta.text).toBe('Configure your application');
85+
if (resolvedContent.cta.kind === 'link') {
86+
expect(resolvedContent.cta.href).toBe(baseContext.claimUrl);
87+
}
88+
});
89+
});
90+
91+
describe('claimed state', () => {
92+
it('builds correct view model for claimed state', () => {
93+
const resolvedContent = getResolvedContent('claimed', baseContext);
94+
95+
expect(resolvedContent.state).toBe('claimed');
96+
expect(resolvedContent.title).toBe('Missing environment keys');
97+
expect(resolvedContent.triggerWidth).toBe('14.25rem');
98+
expect(resolvedContent.cta.kind).toBe('link');
99+
expect(resolvedContent.cta.text).toBe('Get API keys');
100+
if (resolvedContent.cta.kind === 'link') {
101+
expect(resolvedContent.cta.href).toBe(baseContext.claimUrl);
102+
}
103+
});
104+
});
105+
106+
describe('completed state', () => {
107+
it('builds correct view model for completed state', () => {
108+
const resolvedContent = getResolvedContent('completed', baseContext);
109+
110+
expect(resolvedContent.state).toBe('completed');
111+
expect(resolvedContent.title).toBe('Your app is ready');
112+
expect(resolvedContent.triggerWidth).toBe('10.5rem');
113+
expect(resolvedContent.cta.kind).toBe('action');
114+
expect(resolvedContent.cta.text).toBe('Dismiss');
115+
if (resolvedContent.cta.kind === 'action') {
116+
expect(typeof resolvedContent.cta.onClick).toBe('function');
117+
}
118+
});
119+
120+
it('resolves function-based description with context', () => {
121+
const resolvedContent = getResolvedContent('completed', baseContext);
122+
expect(resolvedContent.description).toBeDefined();
123+
expect(React.isValidElement(resolvedContent.description)).toBe(true);
124+
});
125+
126+
it('creates onClick handler that calls onDismiss', async () => {
127+
const onDismiss = vi.fn().mockResolvedValue(undefined);
128+
// Note: window.location.reload cannot be easily mocked in jsdom,
129+
// so we verify that onDismiss is called correctly
130+
// The reload side effect is tested at integration level
131+
132+
const resolvedContent = getResolvedContent('completed', {
133+
...baseContext,
134+
onDismiss,
135+
});
136+
137+
expect(resolvedContent.cta.kind).toBe('action');
138+
if (resolvedContent.cta.kind === 'action') {
139+
resolvedContent.cta.onClick();
140+
// Wait for the promise chain to complete
141+
await new Promise(resolve => setTimeout(resolve, 0));
142+
expect(onDismiss).toHaveBeenCalledOnce();
143+
// Note: window.location.reload() is called but cannot be verified in jsdom
144+
}
145+
});
146+
147+
it('handles null onDismiss gracefully', () => {
148+
// Note: window.location.reload cannot be easily mocked in jsdom,
149+
// so we verify the handler executes without error
150+
// The reload side effect is tested at integration level
151+
152+
const resolvedContent = getResolvedContent('completed', {
153+
...baseContext,
154+
onDismiss: null,
155+
});
156+
157+
expect(resolvedContent.cta.kind).toBe('action');
158+
if (resolvedContent.cta.kind === 'action') {
159+
const onClick = resolvedContent.cta.onClick;
160+
// Should execute without throwing an error even when onDismiss is null
161+
expect(() => {
162+
onClick();
163+
}).not.toThrow();
164+
// Note: window.location.reload() is called but cannot be verified in jsdom
165+
// The onClick handler uses void to fire-and-forget the promise chain
166+
}
167+
});
168+
});
169+
170+
describe('CTA href resolution', () => {
171+
it('resolves function-based href with context', () => {
172+
const context = {
173+
...baseContext,
174+
claimUrl: 'https://custom-claim.com',
175+
instanceUrl: 'https://custom-instance.com',
176+
};
177+
178+
const resolvedContent = getResolvedContent('idle', context);
179+
expect(resolvedContent.cta.kind).toBe('link');
180+
if (resolvedContent.cta.kind === 'link') {
181+
expect(resolvedContent.cta.href).toBe(context.claimUrl);
182+
}
183+
});
184+
185+
it('resolves string-based href directly', () => {
186+
// This test verifies that if we had a string href, it would work
187+
// Currently all states use function-based hrefs, but the logic supports both
188+
const resolvedContent = getResolvedContent('idle', baseContext);
189+
expect(resolvedContent.cta.kind).toBe('link');
190+
if (resolvedContent.cta.kind === 'link') {
191+
expect(typeof resolvedContent.cta.href).toBe('string');
192+
}
193+
});
194+
});
195+
196+
describe('description resolution', () => {
197+
it('resolves static descriptions', () => {
198+
const resolvedContent = getResolvedContent('idle', baseContext);
199+
expect(React.isValidElement(resolvedContent.description)).toBe(true);
200+
});
201+
202+
it('resolves function-based descriptions with context', () => {
203+
const resolvedContent = getResolvedContent('completed', {
204+
...baseContext,
205+
appName: 'My Test App',
206+
instanceUrl: 'https://test-instance.com',
207+
});
208+
209+
expect(React.isValidElement(resolvedContent.description)).toBe(true);
210+
// The description should contain the app name
211+
const descriptionString = JSON.stringify(resolvedContent.description);
212+
expect(descriptionString).toContain('My Test App');
213+
});
214+
});
215+
});
216+
217+
describe('KeylessPrompt component', () => {
218+
it('renders with idle state content when user is not signed in', async () => {
219+
const { wrapper } = await createFixtures();
220+
const { getAllByText } = render(
221+
<KeylessPrompt
222+
claimUrl='https://dashboard.clerk.com/claim'
223+
copyKeysUrl='https://dashboard.clerk.com/copy-keys'
224+
onDismiss={null}
225+
/>,
226+
{ wrapper },
227+
);
228+
229+
// The text appears in both the trigger button and the CTA link
230+
const elements = getAllByText('Configure your application');
231+
expect(elements).toHaveLength(2);
232+
expect(elements[0]).toBeInTheDocument();
233+
expect(elements[1]).toBeInTheDocument();
234+
});
235+
236+
it('renders with userCreated state content when user is signed in', async () => {
237+
const { wrapper } = await createFixtures(f => {
238+
f.withUser({ email_addresses: ['test@clerk.com'] });
239+
});
240+
241+
const { getByText } = render(
242+
<KeylessPrompt
243+
claimUrl='https://dashboard.clerk.com/claim'
244+
copyKeysUrl='https://dashboard.clerk.com/copy-keys'
245+
onDismiss={null}
246+
/>,
247+
{ wrapper },
248+
);
249+
250+
expect(getByText("You've created your first user!")).toBeInTheDocument();
251+
});
252+
253+
it('renders CTA link with correct href for idle state', async () => {
254+
const { wrapper } = await createFixtures();
255+
const claimUrl = 'https://dashboard.clerk.com/claim?test=123';
256+
257+
const { getByRole } = render(
258+
<KeylessPrompt
259+
claimUrl={claimUrl}
260+
copyKeysUrl='https://dashboard.clerk.com/copy-keys'
261+
onDismiss={null}
262+
/>,
263+
{ wrapper },
264+
);
265+
266+
const link = getByRole('link', { name: 'Configure your application' });
267+
expect(link).toBeInTheDocument();
268+
expect(link).toHaveAttribute('href', expect.stringContaining('claim'));
269+
expect(link).toHaveAttribute('target', '_blank');
270+
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
271+
});
272+
273+
it('renders CTA button for completed state when onDismiss is provided', async () => {
274+
const onDismiss = vi.fn().mockResolvedValue(undefined);
275+
const { wrapper } = await createFixtures(f => {
276+
f.withUser({ email_addresses: ['test@clerk.com'] });
277+
// Mock environment to simulate claimed state
278+
f.withClaimedAt(new Date().toISOString());
279+
});
280+
281+
const { getByRole } = render(
282+
<KeylessPrompt
283+
claimUrl='https://dashboard.clerk.com/claim'
284+
copyKeysUrl='https://dashboard.clerk.com/copy-keys'
285+
onDismiss={onDismiss}
286+
/>,
287+
{ wrapper },
288+
);
289+
290+
const button = getByRole('button', { name: 'Dismiss' });
291+
expect(button).toBeInTheDocument();
292+
expect(button.tagName).toBe('BUTTON');
293+
});
294+
295+
it('toggles expanded state when trigger button is clicked', async () => {
296+
const { wrapper } = await createFixtures();
297+
const { getByRole, container } = render(
298+
<KeylessPrompt
299+
claimUrl='https://dashboard.clerk.com/claim'
300+
copyKeysUrl='https://dashboard.clerk.com/copy-keys'
301+
onDismiss={null}
302+
/>,
303+
{ wrapper },
304+
);
305+
306+
const triggerButton = getByRole('button', { name: /keyless prompt/i });
307+
const promptContainer = container.querySelector('[data-expanded]');
308+
309+
// Initially should be expanded (isOpen defaults to true)
310+
expect(promptContainer).toHaveAttribute('data-expanded', 'true');
311+
312+
// Click to collapse
313+
await triggerButton.click();
314+
expect(promptContainer).toHaveAttribute('data-expanded', 'false');
315+
316+
// Click again to expand
317+
await triggerButton.click();
318+
expect(promptContainer).toHaveAttribute('data-expanded', 'true');
319+
});
320+
321+
it('renders description content correctly for idle state', async () => {
322+
const { wrapper } = await createFixtures();
323+
const { getByText } = render(
324+
<KeylessPrompt
325+
claimUrl='https://dashboard.clerk.com/claim'
326+
copyKeysUrl='https://dashboard.clerk.com/copy-keys'
327+
onDismiss={null}
328+
/>,
329+
{ wrapper },
330+
);
331+
332+
expect(getByText(/Temporary API keys are enabled/i)).toBeInTheDocument();
333+
expect(getByText(/Add SSO connections/i)).toBeInTheDocument();
334+
});
335+
});

0 commit comments

Comments
 (0)