Skip to content

Commit 52ce791

Browse files
authored
feat(clerk-js,types): Construct urls based on Organization or User <OrganizationSwitcher/> (#1503)
+ Introduces dynamicParamParser (+tests) + Add types for functions and string literals in OrganizationSwitcherProps & CreateOrganizationProps + afterSelectPersonalUrl & afterSelectOrganizationUrl
1 parent 58a3db6 commit 52ce791

File tree

10 files changed

+274
-13
lines changed

10 files changed

+274
-13
lines changed

.changeset/strange-owls-behave.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
'@clerk/types': patch
4+
---
5+
6+
Construct urls based on context in <OrganizationSwitcher/>
7+
- Deprecate `afterSwitchOrganizationUrl`
8+
- Introduce `afterSelectOrganizationUrl` & `afterSelectPersonalUrl`
9+
10+
`afterSelectOrganizationUrl` accepts
11+
- Full URL -> 'https://clerk.com/'
12+
- relative path -> '/organizations'
13+
- relative path -> with param '/organizations/:id'
14+
- function that returns a string -> (org) => `/org/${org.slug}`
15+
`afterSelectPersonalUrl` accepts
16+
- Full URL -> 'https://clerk.com/'
17+
- relative path -> '/users'
18+
- relative path -> with param '/users/:username'
19+
- function that returns a string -> (user) => `/users/${user.id}`

packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationPage.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { OrganizationResource } from '@clerk/types';
12
import React from 'react';
23

34
import { QuestionMark } from '../../../ui/icons';
@@ -27,6 +28,7 @@ export const CreateOrganizationPage = withCardStateProvider(() => {
2728
const { setActive, closeCreateOrganization } = useCoreClerk();
2829
const { mode, navigateAfterCreateOrganization, skipInvitationScreen } = useCreateOrganizationContext();
2930
const { organization } = useCoreOrganization();
31+
const lastCreatedOrganizationRef = React.useRef<OrganizationResource | null>(null);
3032

3133
const wizard = useWizard({ onNextStep: () => card.setError(undefined) });
3234

@@ -61,6 +63,7 @@ export const CreateOrganizationPage = withCardStateProvider(() => {
6163
await organization.setLogo({ file });
6264
}
6365

66+
lastCreatedOrganizationRef.current = organization;
6467
await setActive({ organization });
6568

6669
if (skipInvitationScreen ?? organization.maxAllowedMemberships === 1) {
@@ -74,7 +77,9 @@ export const CreateOrganizationPage = withCardStateProvider(() => {
7477
};
7578

7679
const completeFlow = () => {
77-
void navigateAfterCreateOrganization();
80+
// We are confident that lastCreatedOrganizationRef.current will never be null
81+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
82+
void navigateAfterCreateOrganization(lastCreatedOrganizationRef.current!);
7883
if (mode === 'modal') {
7984
closeCreateOrganization();
8085
}

packages/clerk-js/src/ui/components/CreateOrganization/__tests__/CreateOrganization.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,4 +158,54 @@ describe('CreateOrganization', () => {
158158
expect(queryByText(/Invite members/i)).not.toBeInTheDocument();
159159
});
160160
});
161+
162+
describe('navigation', () => {
163+
it('constructs afterCreateOrganizationUrl from function', async () => {
164+
const { wrapper, fixtures, props } = await createFixtures(f => {
165+
f.withOrganizations();
166+
f.withUser({
167+
email_addresses: ['test@clerk.dev'],
168+
});
169+
});
170+
171+
const createdOrg = getCreatedOrg({
172+
maxAllowedMemberships: 1,
173+
});
174+
175+
fixtures.clerk.createOrganization.mockReturnValue(Promise.resolve(createdOrg));
176+
177+
props.setProps({ afterCreateOrganizationUrl: org => `/org/${org.id}` });
178+
const { getByRole, userEvent, getByLabelText } = render(<CreateOrganization />, {
179+
wrapper,
180+
});
181+
await userEvent.type(getByLabelText(/Organization name/i), 'new org');
182+
await userEvent.click(getByRole('button', { name: /create organization/i }));
183+
184+
expect(fixtures.router.navigate).toHaveBeenCalledWith(`/org/${createdOrg.id}`);
185+
});
186+
187+
it('constructs afterCreateOrganizationUrl from `:slug` ', async () => {
188+
const { wrapper, fixtures, props } = await createFixtures(f => {
189+
f.withOrganizations();
190+
f.withUser({
191+
email_addresses: ['test@clerk.dev'],
192+
});
193+
});
194+
195+
const createdOrg = getCreatedOrg({
196+
maxAllowedMemberships: 1,
197+
});
198+
199+
fixtures.clerk.createOrganization.mockReturnValue(Promise.resolve(createdOrg));
200+
201+
props.setProps({ afterCreateOrganizationUrl: '/org/:slug' });
202+
const { getByRole, userEvent, getByLabelText } = render(<CreateOrganization />, {
203+
wrapper,
204+
});
205+
await userEvent.type(getByLabelText(/Organization name/i), 'new org');
206+
await userEvent.click(getByRole('button', { name: /create organization/i }));
207+
208+
expect(fixtures.router.navigate).toHaveBeenCalledWith(`/org/${createdOrg.slug}`);
209+
});
210+
});
161211
});

packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, Orga
4848
afterCreateOrganizationUrl,
4949
navigateCreateOrganization,
5050
navigateOrganizationProfile,
51-
navigateAfterSwitchOrganization,
51+
navigateAfterSelectPersonal,
52+
navigateAfterSelectOrganization,
5253
} = useOrganizationSwitcherContext();
5354

5455
const user = useCoreUser();
@@ -60,12 +61,19 @@ export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, Orga
6061
}
6162

6263
const handleOrganizationClicked = (organization: OrganizationResource) => {
63-
return card.runAsync(() => setActive({ organization, beforeEmit: navigateAfterSwitchOrganization })).then(close);
64+
return card
65+
.runAsync(() =>
66+
setActive({
67+
organization,
68+
beforeEmit: () => navigateAfterSelectOrganization(organization),
69+
}),
70+
)
71+
.then(close);
6472
};
6573

6674
const handlePersonalWorkspaceClicked = () => {
6775
return card
68-
.runAsync(() => setActive({ organization: null, beforeEmit: navigateAfterSwitchOrganization }))
76+
.runAsync(() => setActive({ organization: null, beforeEmit: () => navigateAfterSelectPersonal(user) }))
6977
.then(close);
7078
};
7179

packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,56 @@ describe('OrganizationSwitcher', () => {
131131
expect(queryByRole('button', { name: 'Create Organization' })).not.toBeInTheDocument();
132132
});
133133

134-
it.todo('switches between active organizations when one is clicked');
134+
it("switches between active organizations when one is clicked'", async () => {
135+
const { wrapper, props, fixtures } = await createFixtures(f => {
136+
f.withOrganizations();
137+
f.withUser({
138+
email_addresses: ['test@clerk.dev'],
139+
organization_memberships: [
140+
{ name: 'Org1', role: 'basic_member' },
141+
{ name: 'Org2', role: 'admin' },
142+
],
143+
create_organization_enabled: false,
144+
});
145+
});
146+
fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve());
147+
148+
props.setProps({ hidePersonal: true });
149+
const { getByRole, getByText, userEvent } = render(<OrganizationSwitcher />, { wrapper });
150+
await userEvent.click(getByRole('button'));
151+
await userEvent.click(getByText('Org2'));
152+
153+
expect(fixtures.clerk.setActive).toHaveBeenCalledWith(
154+
expect.objectContaining({
155+
organization: expect.objectContaining({
156+
name: 'Org2',
157+
}),
158+
}),
159+
);
160+
});
161+
162+
it("switches to personal workspace when clicked'", async () => {
163+
const { wrapper, fixtures } = await createFixtures(f => {
164+
f.withOrganizations();
165+
f.withUser({
166+
email_addresses: ['test@clerk.dev'],
167+
organization_memberships: [
168+
{ name: 'Org1', role: 'basic_member' },
169+
{ name: 'Org2', role: 'admin' },
170+
],
171+
});
172+
});
173+
174+
fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve());
175+
const { getByRole, getByText, userEvent } = render(<OrganizationSwitcher />, { wrapper });
176+
await userEvent.click(getByRole('button'));
177+
await userEvent.click(getByText(/Personal workspace/i));
178+
179+
expect(fixtures.clerk.setActive).toHaveBeenCalledWith(
180+
expect.objectContaining({
181+
organization: null,
182+
}),
183+
);
184+
});
135185
});
136186
});

packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import type { OrganizationResource, UserResource } from '@clerk/types';
12
import React from 'react';
23

3-
import { buildAuthQueryString, buildURL, pickRedirectionProp } from '../../utils';
4+
import { buildAuthQueryString, buildURL, createDynamicParamParser, pickRedirectionProp } from '../../utils';
45
import { useCoreClerk, useEnvironment, useOptions } from '../contexts';
56
import type { ParsedQs } from '../router';
67
import { useRouter } from '../router';
@@ -15,6 +16,8 @@ import type {
1516
UserProfileCtx,
1617
} from '../types';
1718

19+
const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ });
20+
1821
export const ComponentContext = React.createContext<AvailableComponentCtx | null>(null);
1922

2023
export type SignUpContextType = SignUpCtx & {
@@ -230,8 +233,50 @@ export const useOrganizationSwitcherContext = () => {
230233
const navigateCreateOrganization = () => navigate(ctx.createOrganizationUrl || displayConfig.createOrganizationUrl);
231234
const navigateOrganizationProfile = () =>
232235
navigate(ctx.organizationProfileUrl || displayConfig.organizationProfileUrl);
233-
const navigateAfterSwitchOrganization = () =>
234-
ctx.afterSwitchOrganizationUrl ? navigate(ctx.afterSwitchOrganizationUrl) : Promise.resolve();
236+
237+
const navigateAfterSelectOrganizationOrPersonal = ({
238+
organization,
239+
user,
240+
}: {
241+
organization?: OrganizationResource;
242+
user?: UserResource;
243+
}) => {
244+
if (typeof ctx.afterSelectPersonalUrl === 'function' && user) {
245+
return navigate(ctx.afterSelectPersonalUrl(user));
246+
}
247+
248+
if (typeof ctx.afterSelectOrganizationUrl === 'function' && organization) {
249+
return navigate(ctx.afterSelectOrganizationUrl(organization));
250+
}
251+
252+
if (ctx.afterSelectPersonalUrl && user) {
253+
const parsedUrl = populateParamFromObject({
254+
urlWithParam: ctx.afterSelectPersonalUrl as string,
255+
entity: user,
256+
});
257+
return navigate(parsedUrl);
258+
}
259+
260+
if (ctx.afterSelectOrganizationUrl && organization) {
261+
const parsedUrl = populateParamFromObject({
262+
urlWithParam: ctx.afterSelectOrganizationUrl as string,
263+
entity: organization,
264+
});
265+
return navigate(parsedUrl);
266+
}
267+
268+
// Continue to support afterSwitchOrganizationUrl
269+
if (ctx.afterSwitchOrganizationUrl) {
270+
return navigate(ctx.afterSwitchOrganizationUrl);
271+
}
272+
273+
return Promise.resolve();
274+
};
275+
276+
const navigateAfterSelectOrganization = (organization: OrganizationResource) =>
277+
navigateAfterSelectOrganizationOrPersonal({ organization });
278+
279+
const navigateAfterSelectPersonal = (user: UserResource) => navigateAfterSelectOrganizationOrPersonal({ user });
235280

236281
return {
237282
...ctx,
@@ -242,7 +287,8 @@ export const useOrganizationSwitcherContext = () => {
242287
afterLeaveOrganizationUrl,
243288
navigateOrganizationProfile,
244289
navigateCreateOrganization,
245-
navigateAfterSwitchOrganization,
290+
navigateAfterSelectOrganization,
291+
navigateAfterSelectPersonal,
246292
componentName,
247293
};
248294
};
@@ -275,8 +321,21 @@ export const useCreateOrganizationContext = () => {
275321
throw new Error('Clerk: useCreateOrganizationContext called outside CreateOrganization.');
276322
}
277323

278-
const navigateAfterCreateOrganization = () =>
279-
navigate(ctx.afterCreateOrganizationUrl || displayConfig.afterCreateOrganizationUrl);
324+
const navigateAfterCreateOrganization = (organization: OrganizationResource) => {
325+
if (typeof ctx.afterCreateOrganizationUrl === 'function') {
326+
return navigate(ctx.afterCreateOrganizationUrl(organization));
327+
}
328+
329+
if (ctx.afterCreateOrganizationUrl) {
330+
const parsedUrl = populateParamFromObject({
331+
urlWithParam: ctx.afterCreateOrganizationUrl,
332+
entity: organization,
333+
});
334+
return navigate(parsedUrl);
335+
}
336+
337+
return navigate(displayConfig.afterCreateOrganizationUrl);
338+
};
280339

281340
return {
282341
...ctx,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe } from '@jest/globals';
2+
3+
import { createDynamicParamParser } from '../dynamicParamParser';
4+
5+
const entity = {
6+
foo: 'foo_string',
7+
bar: 'bar_string',
8+
};
9+
10+
describe('createDynamicParamParser', () => {
11+
const testCases = [
12+
[':foo', entity, 'foo_string'],
13+
['/:foo', entity, '/foo_string'],
14+
['/some/:bar/any', entity, '/some/bar_string/any'],
15+
['/:notValid', entity, '/:notValid'],
16+
] as const;
17+
18+
it.each(testCases)(
19+
'replaces the dynamic param with the value assigned to the key inside the object. Url=(%s), Object=(%s), result=(%s)',
20+
(urlWithParam, obj, result) => {
21+
expect(
22+
createDynamicParamParser({ regex: /:(\w+)/ })({
23+
urlWithParam,
24+
entity: obj,
25+
}),
26+
).toEqual(result);
27+
},
28+
);
29+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export const createDynamicParamParser =
2+
({ regex }: { regex: RegExp }) =>
3+
<T extends Record<any, any>>({ urlWithParam, entity }: { urlWithParam: string; entity: T }) => {
4+
const match = regex.exec(urlWithParam);
5+
6+
if (match) {
7+
const key = match[1];
8+
if (key in entity) {
9+
const value = entity[key] as string;
10+
return urlWithParam.replace(match[0], value);
11+
}
12+
}
13+
return urlWithParam;
14+
};

packages/clerk-js/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from './beforeUnloadTracker';
22
export * from './componentGuards';
33
export * from './cookies';
4+
export * from './dynamicParamParser';
45
export * from './devBrowser';
56
export * from './email';
67
export * from './encoders';

0 commit comments

Comments
 (0)