Skip to content

Commit 1ad16da

Browse files
feat(clerk-js,clerk-react,types): Introduce TaskChooseOrganization (#6446)
Co-authored-by: Iago Dahlem Lorensini <[email protected]>
1 parent 0560de8 commit 1ad16da

File tree

42 files changed

+1111
-256
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1111
-256
lines changed

.changeset/brown-garlics-boil.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@clerk/tanstack-react-start': patch
3+
'@clerk/localizations': patch
4+
'@clerk/react-router': patch
5+
'@clerk/clerk-js': patch
6+
'@clerk/testing': patch
7+
'@clerk/nextjs': patch
8+
'@clerk/clerk-react': patch
9+
'@clerk/remix': patch
10+
'@clerk/types': patch
11+
---
12+
13+
Introduce `TaskChooseOrganization` component which replaces `TaskSelectOrganization` with a new UI that make the experience similar to the previous `SignIn` and `SignUp` steps

integration/tests/session-tasks-eject-flow.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ return (
5252
.addFile(
5353
'src/app/onboarding/select-organization/page.tsx',
5454
() => `
55-
import { TaskSelectOrganization } from '@clerk/nextjs';
55+
import { TaskChooseOrganization } from '@clerk/nextjs';
5656
5757
export default function Page() {
5858
return (
59-
<TaskSelectOrganization redirectUrlComplete='/'/>
59+
<TaskChooseOrganization redirectUrlComplete='/'/>
6060
);
6161
}`,
6262
)

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "618KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "620KB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115.08KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "55.2KB" },

packages/clerk-js/sandbox/app.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import * as l from '../../localizations';
21
import type { Clerk as ClerkType } from '../';
2+
import * as l from '../../localizations';
33

44
const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[];
55

@@ -35,6 +35,7 @@ const AVAILABLE_COMPONENTS = [
3535
'pricingTable',
3636
'apiKeys',
3737
'oauthConsent',
38+
'taskChooseOrganization',
3839
] as const;
3940

4041
const COMPONENT_PROPS_NAMESPACE = 'clerk-js-sandbox';
@@ -95,6 +96,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component
9596
pricingTable: buildComponentControls('pricingTable'),
9697
apiKeys: buildComponentControls('apiKeys'),
9798
oauthConsent: buildComponentControls('oauthConsent'),
99+
taskChooseOrganization: buildComponentControls('taskChooseOrganization'),
98100
};
99101

100102
declare global {
@@ -335,6 +337,14 @@ void (async () => {
335337
},
336338
);
337339
},
340+
'/task-choose-organization': () => {
341+
Clerk.mountTaskChooseOrganization(
342+
app,
343+
componentControls.taskChooseOrganization.getProps() ?? {
344+
redirectUrlComplete: '/user-profile',
345+
},
346+
);
347+
},
338348
'/open-sign-in': () => {
339349
mountOpenSignInButton(app, componentControls.signIn.getProps() ?? {});
340350
},

packages/clerk-js/sandbox/template.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@
154154
OAuthConsent
155155
</a>
156156
</li>
157+
<li class="relative">
158+
<a
159+
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"
160+
href="/task-choose-organization"
161+
>
162+
TaskChooseOrganization
163+
</a>
164+
</li>
157165
<li class="relative">
158166
<a
159167
class="relative isolate flex w-full rounded-md border border-white px-2 py-[0.4375rem] text-sm hover:bg-gray-50 aria-[current]:bg-gray-50"

packages/clerk-js/src/core/__tests__/clerk.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,7 @@ describe('Clerk singleton', () => {
950950
signUp: new SignUp({
951951
status: 'complete',
952952
} as any as SignUpJSON),
953+
isEligibleForTouch: () => false,
953954
}),
954955
);
955956

@@ -995,6 +996,7 @@ describe('Clerk singleton', () => {
995996
signedInSessions: [mockResource],
996997
signIn: new SignIn(null),
997998
signUp: new SignUp(null),
999+
isEligibleForTouch: () => false,
9981000
}),
9991001
);
10001002

@@ -2451,7 +2453,12 @@ describe('Clerk singleton', () => {
24512453

24522454
beforeEach(() => {
24532455
mockResource.touch.mockReturnValueOnce(Promise.resolve());
2454-
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockResource] }));
2456+
mockClientFetch.mockReturnValue(
2457+
Promise.resolve({
2458+
signedInSessions: [mockResource],
2459+
isEligibleForTouch: () => false,
2460+
}),
2461+
);
24552462
});
24562463

24572464
afterEach(() => {
@@ -2516,7 +2523,12 @@ describe('Clerk singleton', () => {
25162523

25172524
it('navigates to redirect url on completion', async () => {
25182525
mockSession.touch.mockReturnValue(Promise.resolve());
2519-
mockClientFetch.mockReturnValue(Promise.resolve({ signedInSessions: [mockSession] }));
2526+
mockClientFetch.mockReturnValue(
2527+
Promise.resolve({
2528+
signedInSessions: [mockSession],
2529+
isEligibleForTouch: () => false,
2530+
}),
2531+
);
25202532

25212533
const sut = new Clerk(productionPublishableKey);
25222534
await sut.load(mockedLoadOptions);

packages/clerk-js/src/core/clerk.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ import type {
7676
SignUpProps,
7777
SignUpRedirectOptions,
7878
SignUpResource,
79-
TaskSelectOrganizationProps,
79+
TaskChooseOrganizationProps,
8080
UnsubscribeCallback,
8181
UserButtonProps,
8282
UserProfileProps,
@@ -1163,31 +1163,31 @@ export class Clerk implements ClerkInterface {
11631163
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
11641164
};
11651165

1166-
public mountTaskSelectOrganization = (node: HTMLDivElement, props?: TaskSelectOrganizationProps) => {
1166+
public mountTaskChooseOrganization = (node: HTMLDivElement, props?: TaskChooseOrganizationProps) => {
11671167
this.assertComponentsReady(this.#componentControls);
11681168

11691169
if (disabledOrganizationsFeature(this, this.environment)) {
11701170
if (this.#instanceType === 'development') {
1171-
throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('TaskSelectOrganization'), {
1171+
throw new ClerkRuntimeError(warnings.cannotRenderAnyOrganizationComponent('TaskChooseOrganization'), {
11721172
code: CANNOT_RENDER_ORGANIZATIONS_DISABLED_ERROR_CODE,
11731173
});
11741174
}
11751175
return;
11761176
}
11771177

1178-
void this.#componentControls.ensureMounted({ preloadHint: 'TaskSelectOrganization' }).then(controls =>
1178+
void this.#componentControls.ensureMounted({ preloadHint: 'TaskChooseOrganization' }).then(controls =>
11791179
controls.mountComponent({
1180-
name: 'TaskSelectOrganization',
1181-
appearanceKey: 'taskSelectOrganization',
1180+
name: 'TaskChooseOrganization',
1181+
appearanceKey: 'taskChooseOrganization',
11821182
node,
11831183
props,
11841184
}),
11851185
);
11861186

1187-
this.telemetry?.record(eventPrebuiltComponentMounted('TaskSelectOrganization', props));
1187+
this.telemetry?.record(eventPrebuiltComponentMounted('TaskChooseOrganization', props));
11881188
};
11891189

1190-
public unmountTaskSelectOrganization = (node: HTMLDivElement) => {
1190+
public unmountTaskChooseOrganization = (node: HTMLDivElement) => {
11911191
this.assertComponentsReady(this.#componentControls);
11921192
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
11931193
};
@@ -1388,6 +1388,16 @@ export class Clerk implements ClerkInterface {
13881388
public __internal_navigateToTaskIfAvailable = async ({
13891389
redirectUrlComplete,
13901390
}: __internal_NavigateToTaskIfAvailableParams = {}): Promise<void> => {
1391+
const onBeforeSetActive: SetActiveHook =
1392+
typeof window !== 'undefined' && typeof window.__unstable__onBeforeSetActive === 'function'
1393+
? window.__unstable__onBeforeSetActive
1394+
: noop;
1395+
1396+
const onAfterSetActive: SetActiveHook =
1397+
typeof window !== 'undefined' && typeof window.__unstable__onAfterSetActive === 'function'
1398+
? window.__unstable__onAfterSetActive
1399+
: noop;
1400+
13911401
const session = this.session;
13921402
if (!session || !this.environment) {
13931403
return;
@@ -1403,17 +1413,30 @@ export class Clerk implements ClerkInterface {
14031413
return;
14041414
}
14051415

1416+
await onBeforeSetActive();
1417+
14061418
if (redirectUrlComplete) {
14071419
const tracker = createBeforeUnloadTracker(this.#options.standardBrowser);
14081420

14091421
await tracker.track(async () => {
1410-
await this.navigate(redirectUrlComplete);
1422+
if (!this.client) {
1423+
return;
1424+
}
1425+
1426+
if (this.client.isEligibleForTouch()) {
1427+
const absoluteRedirectUrl = new URL(redirectUrlComplete, window.location.href);
1428+
await this.navigate(this.buildUrlWithAuth(this.client.buildTouchUrl({ redirectUrl: absoluteRedirectUrl })));
1429+
} else {
1430+
await this.navigate(redirectUrlComplete);
1431+
}
14111432
});
14121433

14131434
if (tracker.isUnloading()) {
14141435
return;
14151436
}
14161437
}
1438+
1439+
await onAfterSetActive();
14171440
};
14181441

14191442
public addListener = (listener: ListenerCallback): UnsubscribeCallback => {

packages/clerk-js/src/core/warnings.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ const createMessageForDisabledOrganizations = (
1010
| 'OrganizationSwitcher'
1111
| 'OrganizationList'
1212
| 'CreateOrganization'
13-
| 'TaskSelectOrganization',
13+
| 'TaskChooseOrganization',
1414
) => {
1515
return formatWarning(
1616
`The <${componentName}/> cannot be rendered when the feature is turned off. Visit 'dashboard.clerk.com' to enable the feature. Since the feature is turned off, this is no-op.`,
@@ -29,7 +29,7 @@ const warnings = {
2929
cannotRenderSignUpComponentWhenTaskExists:
3030
'The <SignUp/> component cannot render when a user has a pending task, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the task instead.',
3131
cannotRenderComponentWhenTaskDoesNotExist:
32-
'<TaskSelectOrganization/> cannot render unless a session task is pending. Clerk is redirecting to the value set in `redirectUrlComplete` instead.',
32+
'<TaskChooseOrganization/> cannot render unless a session task is pending. Clerk is redirecting to the value set in `redirectUrlComplete` instead.',
3333
cannotRenderSignInComponentWhenSessionExists:
3434
'The <SignIn/> component cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the `afterSignIn` URL instead.',
3535
cannotRenderSignInComponentWhenTaskExists:
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { UserOrganizationInvitationResource } from '@clerk/types';
2+
import type { PropsWithChildren } from 'react';
3+
import { forwardRef } from 'react';
4+
5+
import type { ElementDescriptor } from '@/ui/customizables/elementDescriptors';
6+
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
7+
import { PreviewButton } from '@/ui/elements/PreviewButton';
8+
9+
import { Box, Button, Col, descriptors, Flex, Spinner } from '../../customizables';
10+
import { SwitchArrowRight } from '../../icons';
11+
import type { ThemableCssProp } from '../../styledSystem';
12+
import { common } from '../../styledSystem';
13+
14+
type OrganizationPreviewListItemsProps = PropsWithChildren<{
15+
elementDescriptor: ElementDescriptor;
16+
}>;
17+
18+
export const OrganizationPreviewListItems = ({ elementDescriptor, children }: OrganizationPreviewListItemsProps) => {
19+
return (
20+
<Col
21+
elementDescriptor={elementDescriptor}
22+
sx={t => ({
23+
maxHeight: `calc(8 * ${t.sizes.$12})`,
24+
overflowY: 'auto',
25+
borderTopWidth: t.borderWidths.$normal,
26+
borderTopStyle: t.borderStyles.$solid,
27+
borderTopColor: t.colors.$borderAlpha100,
28+
...common.unstyledScrollbar(t),
29+
})}
30+
>
31+
{children}
32+
</Col>
33+
);
34+
};
35+
36+
const sharedStyles: ThemableCssProp = t => ({
37+
padding: `${t.space.$4} ${t.space.$5}`,
38+
});
39+
40+
export const sharedMainIdentifierSx: ThemableCssProp = t => ({
41+
color: t.colors.$colorForeground,
42+
':hover': {
43+
color: t.colors.$colorForeground,
44+
},
45+
});
46+
47+
type OrganizationPreviewListItemProps = PropsWithChildren<{
48+
elementId: React.ComponentProps<typeof OrganizationPreview>['elementId'];
49+
elementDescriptor: React.ComponentProps<typeof OrganizationPreview>['elementDescriptor'];
50+
organizationData: UserOrganizationInvitationResource['publicOrganizationData'];
51+
}>;
52+
53+
export const OrganizationPreviewListItem = ({
54+
children,
55+
elementId,
56+
elementDescriptor,
57+
organizationData,
58+
}: OrganizationPreviewListItemProps) => {
59+
return (
60+
<Flex
61+
align='center'
62+
gap={2}
63+
sx={[
64+
t => ({
65+
minHeight: 'unset',
66+
justifyContent: 'space-between',
67+
borderTopWidth: t.borderWidths.$normal,
68+
borderTopStyle: t.borderStyles.$solid,
69+
borderTopColor: t.colors.$borderAlpha100,
70+
}),
71+
sharedStyles,
72+
]}
73+
elementDescriptor={elementDescriptor}
74+
>
75+
<OrganizationPreview
76+
elementId={elementId}
77+
mainIdentifierSx={sharedMainIdentifierSx}
78+
organization={organizationData}
79+
/>
80+
{children}
81+
</Flex>
82+
);
83+
};
84+
85+
export const OrganizationPreviewSpinner = forwardRef<HTMLDivElement>((_, ref) => {
86+
return (
87+
<Box
88+
ref={ref}
89+
sx={t => ({
90+
width: '100%',
91+
height: t.space.$12,
92+
position: 'relative',
93+
})}
94+
>
95+
<Box
96+
sx={{
97+
margin: 'auto',
98+
position: 'absolute',
99+
left: '50%',
100+
top: '50%',
101+
transform: 'translateY(-50%) translateX(-50%)',
102+
}}
103+
>
104+
<Spinner
105+
size='sm'
106+
colorScheme='primary'
107+
elementDescriptor={descriptors.spinner}
108+
/>
109+
</Box>
110+
</Box>
111+
);
112+
});
113+
114+
export const OrganizationPreviewListItemButton = (props: Parameters<typeof Button>[0]) => {
115+
return (
116+
<Button
117+
textVariant='buttonSmall'
118+
variant='outline'
119+
size='xs'
120+
{...props}
121+
/>
122+
);
123+
};
124+
125+
type OrganizationPreviewButtonProps = PropsWithChildren<{
126+
onClick: () => void | Promise<void>;
127+
elementDescriptor: ElementDescriptor;
128+
}>;
129+
130+
export const OrganizationPreviewButton = (props: OrganizationPreviewButtonProps) => {
131+
return (
132+
<PreviewButton
133+
sx={[sharedStyles]}
134+
icon={SwitchArrowRight}
135+
{...props}
136+
/>
137+
);
138+
};

0 commit comments

Comments
 (0)