Skip to content

Commit

Permalink
feat(clerk-js,types): Construct urls based on Organization or User <O…
Browse files Browse the repository at this point in the history
…rganizationSwitcher/>

+ Introduces dynamicParamParser (+tests)
+ Add types for functions and string literals in OrganizationSwitcherProps & CreateOrganizationProps
+ afterSelectPersonalUrl & afterSelectOrganizationUrl
  • Loading branch information
panteliselef committed Aug 8, 2023
1 parent 58a3db6 commit 7017b8e
Show file tree
Hide file tree
Showing 10 changed files with 274 additions and 13 deletions.
19 changes: 19 additions & 0 deletions .changeset/strange-owls-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Construct urls based on context in <OrganizationSwitcher/>
- Deprecate `afterSwitchOrganizationUrl`
- Introduce `afterSelectOrganizationUrl` & `afterSelectPersonalUrl`

`afterSelectOrganizationUrl` accepts
- Full URL -> 'https://clerk.com/'
- relative path -> '/organizations'
- relative path -> with param '/organizations/:id'
- function that returns a string -> (org) => `/org/${org.slug}`
`afterSelectPersonalUrl` accepts
- Full URL -> 'https://clerk.com/'
- relative path -> '/users'
- relative path -> with param '/users/:username'
- function that returns a string -> (user) => `/users/${user.id}`
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { OrganizationResource } from '@clerk/types';
import React from 'react';

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

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

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

lastCreatedOrganizationRef.current = organization;
await setActive({ organization });

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

const completeFlow = () => {
void navigateAfterCreateOrganization();
// We are confident that lastCreatedOrganizationRef.current will never be null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
void navigateAfterCreateOrganization(lastCreatedOrganizationRef.current!);
if (mode === 'modal') {
closeCreateOrganization();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,54 @@ describe('CreateOrganization', () => {
expect(queryByText(/Invite members/i)).not.toBeInTheDocument();
});
});

describe('navigation', () => {
it('constructs afterCreateOrganizationUrl from function', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.dev'],
});
});

const createdOrg = getCreatedOrg({
maxAllowedMemberships: 1,
});

fixtures.clerk.createOrganization.mockReturnValue(Promise.resolve(createdOrg));

props.setProps({ afterCreateOrganizationUrl: org => `/org/${org.id}` });
const { getByRole, userEvent, getByLabelText } = render(<CreateOrganization />, {
wrapper,
});
await userEvent.type(getByLabelText(/Organization name/i), 'new org');
await userEvent.click(getByRole('button', { name: /create organization/i }));

expect(fixtures.router.navigate).toHaveBeenCalledWith(`/org/${createdOrg.id}`);
});

it('constructs afterCreateOrganizationUrl from `:slug` ', async () => {
const { wrapper, fixtures, props } = await createFixtures(f => {
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.dev'],
});
});

const createdOrg = getCreatedOrg({
maxAllowedMemberships: 1,
});

fixtures.clerk.createOrganization.mockReturnValue(Promise.resolve(createdOrg));

props.setProps({ afterCreateOrganizationUrl: '/org/:slug' });
const { getByRole, userEvent, getByLabelText } = render(<CreateOrganization />, {
wrapper,
});
await userEvent.type(getByLabelText(/Organization name/i), 'new org');
await userEvent.click(getByRole('button', { name: /create organization/i }));

expect(fixtures.router.navigate).toHaveBeenCalledWith(`/org/${createdOrg.slug}`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export const OrganizationSwitcherPopover = React.forwardRef<HTMLDivElement, Orga
afterCreateOrganizationUrl,
navigateCreateOrganization,
navigateOrganizationProfile,
navigateAfterSwitchOrganization,
navigateAfterSelectPersonal,
navigateAfterSelectOrganization,
} = useOrganizationSwitcherContext();

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

const handleOrganizationClicked = (organization: OrganizationResource) => {
return card.runAsync(() => setActive({ organization, beforeEmit: navigateAfterSwitchOrganization })).then(close);
return card
.runAsync(() =>
setActive({
organization,
beforeEmit: () => navigateAfterSelectOrganization(organization),
}),
)
.then(close);
};

const handlePersonalWorkspaceClicked = () => {
return card
.runAsync(() => setActive({ organization: null, beforeEmit: navigateAfterSwitchOrganization }))
.runAsync(() => setActive({ organization: null, beforeEmit: () => navigateAfterSelectPersonal(user) }))
.then(close);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,56 @@ describe('OrganizationSwitcher', () => {
expect(queryByRole('button', { name: 'Create Organization' })).not.toBeInTheDocument();
});

it.todo('switches between active organizations when one is clicked');
it("switches between active organizations when one is clicked'", async () => {
const { wrapper, props, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.dev'],
organization_memberships: [
{ name: 'Org1', role: 'basic_member' },
{ name: 'Org2', role: 'admin' },
],
create_organization_enabled: false,
});
});
fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve());

props.setProps({ hidePersonal: true });
const { getByRole, getByText, userEvent } = render(<OrganizationSwitcher />, { wrapper });
await userEvent.click(getByRole('button'));
await userEvent.click(getByText('Org2'));

expect(fixtures.clerk.setActive).toHaveBeenCalledWith(
expect.objectContaining({
organization: expect.objectContaining({
name: 'Org2',
}),
}),
);
});

it("switches to personal workspace when clicked'", async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withOrganizations();
f.withUser({
email_addresses: ['test@clerk.dev'],
organization_memberships: [
{ name: 'Org1', role: 'basic_member' },
{ name: 'Org2', role: 'admin' },
],
});
});

fixtures.clerk.setActive.mockReturnValueOnce(Promise.resolve());
const { getByRole, getByText, userEvent } = render(<OrganizationSwitcher />, { wrapper });
await userEvent.click(getByRole('button'));
await userEvent.click(getByText(/Personal workspace/i));

expect(fixtures.clerk.setActive).toHaveBeenCalledWith(
expect.objectContaining({
organization: null,
}),
);
});
});
});
71 changes: 65 additions & 6 deletions packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { OrganizationResource, UserResource } from '@clerk/types';
import React from 'react';

import { buildAuthQueryString, buildURL, pickRedirectionProp } from '../../utils';
import { buildAuthQueryString, buildURL, createDynamicParamParser, pickRedirectionProp } from '../../utils';
import { useCoreClerk, useEnvironment, useOptions } from '../contexts';
import type { ParsedQs } from '../router';
import { useRouter } from '../router';
Expand All @@ -15,6 +16,8 @@ import type {
UserProfileCtx,
} from '../types';

const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ });

export const ComponentContext = React.createContext<AvailableComponentCtx | null>(null);

export type SignUpContextType = SignUpCtx & {
Expand Down Expand Up @@ -230,8 +233,50 @@ export const useOrganizationSwitcherContext = () => {
const navigateCreateOrganization = () => navigate(ctx.createOrganizationUrl || displayConfig.createOrganizationUrl);
const navigateOrganizationProfile = () =>
navigate(ctx.organizationProfileUrl || displayConfig.organizationProfileUrl);
const navigateAfterSwitchOrganization = () =>
ctx.afterSwitchOrganizationUrl ? navigate(ctx.afterSwitchOrganizationUrl) : Promise.resolve();

const navigateAfterSelectOrganizationOrPersonal = ({
organization,
user,
}: {
organization?: OrganizationResource;
user?: UserResource;
}) => {
if (typeof ctx.afterSelectPersonalUrl === 'function' && user) {
return navigate(ctx.afterSelectPersonalUrl(user));
}

if (typeof ctx.afterSelectOrganizationUrl === 'function' && organization) {
return navigate(ctx.afterSelectOrganizationUrl(organization));
}

if (ctx.afterSelectPersonalUrl && user) {
const parsedUrl = populateParamFromObject({
urlWithParam: ctx.afterSelectPersonalUrl as string,
entity: user,
});
return navigate(parsedUrl);
}

if (ctx.afterSelectOrganizationUrl && organization) {
const parsedUrl = populateParamFromObject({
urlWithParam: ctx.afterSelectOrganizationUrl as string,
entity: organization,
});
return navigate(parsedUrl);
}

// Continue to support afterSwitchOrganizationUrl
if (ctx.afterSwitchOrganizationUrl) {
return navigate(ctx.afterSwitchOrganizationUrl);
}

return Promise.resolve();
};

const navigateAfterSelectOrganization = (organization: OrganizationResource) =>
navigateAfterSelectOrganizationOrPersonal({ organization });

const navigateAfterSelectPersonal = (user: UserResource) => navigateAfterSelectOrganizationOrPersonal({ user });

return {
...ctx,
Expand All @@ -242,7 +287,8 @@ export const useOrganizationSwitcherContext = () => {
afterLeaveOrganizationUrl,
navigateOrganizationProfile,
navigateCreateOrganization,
navigateAfterSwitchOrganization,
navigateAfterSelectOrganization,
navigateAfterSelectPersonal,
componentName,
};
};
Expand Down Expand Up @@ -275,8 +321,21 @@ export const useCreateOrganizationContext = () => {
throw new Error('Clerk: useCreateOrganizationContext called outside CreateOrganization.');
}

const navigateAfterCreateOrganization = () =>
navigate(ctx.afterCreateOrganizationUrl || displayConfig.afterCreateOrganizationUrl);
const navigateAfterCreateOrganization = (organization: OrganizationResource) => {
if (typeof ctx.afterCreateOrganizationUrl === 'function') {
return navigate(ctx.afterCreateOrganizationUrl(organization));
}

if (ctx.afterCreateOrganizationUrl) {
const parsedUrl = populateParamFromObject({
urlWithParam: ctx.afterCreateOrganizationUrl,
entity: organization,
});
return navigate(parsedUrl);
}

return navigate(displayConfig.afterCreateOrganizationUrl);
};

return {
...ctx,
Expand Down
29 changes: 29 additions & 0 deletions packages/clerk-js/src/utils/__tests__/dynamicParamParser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe } from '@jest/globals';

import { createDynamicParamParser } from '../dynamicParamParser';

const entity = {
foo: 'foo_string',
bar: 'bar_string',
};

describe('createDynamicParamParser', () => {
const testCases = [
[':foo', entity, 'foo_string'],
['/:foo', entity, '/foo_string'],
['/some/:bar/any', entity, '/some/bar_string/any'],
['/:notValid', entity, '/:notValid'],
] as const;

it.each(testCases)(
'replaces the dynamic param with the value assigned to the key inside the object. Url=(%s), Object=(%s), result=(%s)',
(urlWithParam, obj, result) => {
expect(
createDynamicParamParser({ regex: /:(\w+)/ })({
urlWithParam,
entity: obj,
}),
).toEqual(result);
},
);
});
14 changes: 14 additions & 0 deletions packages/clerk-js/src/utils/dynamicParamParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const createDynamicParamParser =
({ regex }: { regex: RegExp }) =>
<T extends Record<any, any>>({ urlWithParam, entity }: { urlWithParam: string; entity: T }) => {
const match = regex.exec(urlWithParam);

if (match) {
const key = match[1];
if (key in entity) {
const value = entity[key] as string;
return urlWithParam.replace(match[0], value);
}
}
return urlWithParam;
};
1 change: 1 addition & 0 deletions packages/clerk-js/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './beforeUnloadTracker';
export * from './componentGuards';
export * from './cookies';
export * from './dynamicParamParser';
export * from './devBrowser';
export * from './email';
export * from './encoders';
Expand Down
Loading

0 comments on commit 7017b8e

Please sign in to comment.