diff --git a/.changeset/strange-owls-behave.md b/.changeset/strange-owls-behave.md new file mode 100644 index 0000000000..c30fea5ef9 --- /dev/null +++ b/.changeset/strange-owls-behave.md @@ -0,0 +1,19 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Construct urls based on context in +- 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}` diff --git a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationPage.tsx b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationPage.tsx index 812d66b196..85eba08e1f 100644 --- a/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationPage.tsx +++ b/packages/clerk-js/src/ui/components/CreateOrganization/CreateOrganizationPage.tsx @@ -1,3 +1,4 @@ +import type { OrganizationResource } from '@clerk/types'; import React from 'react'; import { QuestionMark } from '../../../ui/icons'; @@ -27,6 +28,7 @@ export const CreateOrganizationPage = withCardStateProvider(() => { const { setActive, closeCreateOrganization } = useCoreClerk(); const { mode, navigateAfterCreateOrganization, skipInvitationScreen } = useCreateOrganizationContext(); const { organization } = useCoreOrganization(); + const lastCreatedOrganizationRef = React.useRef(null); const wizard = useWizard({ onNextStep: () => card.setError(undefined) }); @@ -61,6 +63,7 @@ export const CreateOrganizationPage = withCardStateProvider(() => { await organization.setLogo({ file }); } + lastCreatedOrganizationRef.current = organization; await setActive({ organization }); if (skipInvitationScreen ?? organization.maxAllowedMemberships === 1) { @@ -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(); } diff --git a/packages/clerk-js/src/ui/components/CreateOrganization/__tests__/CreateOrganization.test.tsx b/packages/clerk-js/src/ui/components/CreateOrganization/__tests__/CreateOrganization.test.tsx index c03f587fef..b6ccde1118 100644 --- a/packages/clerk-js/src/ui/components/CreateOrganization/__tests__/CreateOrganization.test.tsx +++ b/packages/clerk-js/src/ui/components/CreateOrganization/__tests__/CreateOrganization.test.tsx @@ -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(, { + 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(, { + 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}`); + }); + }); }); diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index 51820cf05e..ff3892c814 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -48,7 +48,8 @@ export const OrganizationSwitcherPopover = React.forwardRef { - 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); }; diff --git a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx index 7deeaf8934..2f6e403e55 100644 --- a/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationSwitcher/__tests__/OrganizationSwitcher.test.tsx @@ -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(, { 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(, { wrapper }); + await userEvent.click(getByRole('button')); + await userEvent.click(getByText(/Personal workspace/i)); + + expect(fixtures.clerk.setActive).toHaveBeenCalledWith( + expect.objectContaining({ + organization: null, + }), + ); + }); }); }); diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index ebe1134114..624a455068 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -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'; @@ -15,6 +16,8 @@ import type { UserProfileCtx, } from '../types'; +const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ }); + export const ComponentContext = React.createContext(null); export type SignUpContextType = SignUpCtx & { @@ -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, @@ -242,7 +287,8 @@ export const useOrganizationSwitcherContext = () => { afterLeaveOrganizationUrl, navigateOrganizationProfile, navigateCreateOrganization, - navigateAfterSwitchOrganization, + navigateAfterSelectOrganization, + navigateAfterSelectPersonal, componentName, }; }; @@ -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, diff --git a/packages/clerk-js/src/utils/__tests__/dynamicParamParser.test.ts b/packages/clerk-js/src/utils/__tests__/dynamicParamParser.test.ts new file mode 100644 index 0000000000..3f9ceafd85 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/dynamicParamParser.test.ts @@ -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); + }, + ); +}); diff --git a/packages/clerk-js/src/utils/dynamicParamParser.ts b/packages/clerk-js/src/utils/dynamicParamParser.ts new file mode 100644 index 0000000000..9f254312d9 --- /dev/null +++ b/packages/clerk-js/src/utils/dynamicParamParser.ts @@ -0,0 +1,14 @@ +export const createDynamicParamParser = + ({ regex }: { regex: RegExp }) => + >({ 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; + }; diff --git a/packages/clerk-js/src/utils/index.ts b/packages/clerk-js/src/utils/index.ts index c6c9dbc2de..5392e17056 100644 --- a/packages/clerk-js/src/utils/index.ts +++ b/packages/clerk-js/src/utils/index.ts @@ -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'; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 5445475b1b..155edd9c5a 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -661,7 +661,9 @@ export type CreateOrganizationProps = { * Full URL or path to navigate after creating a new organization. * @default undefined */ - afterCreateOrganizationUrl?: string; + afterCreateOrganizationUrl?: + | ((organization: OrganizationResource) => string) + | LooseExtractedParams>; /** * Hides the screen for sending invitations after an organization is created. * @default undefined When left undefined Clerk will automatically hide the screen if @@ -730,6 +732,12 @@ export type UserButtonProps = { userProfileProps?: Pick; }; +type PrimitiveKeys = { + [K in keyof T]: T[K] extends string | boolean | number | null ? K : never; +}[keyof T]; + +type LooseExtractedParams = `:${T}` | (string & NonNullable); + export type OrganizationSwitcherProps = { /** Controls the default state of the OrganizationSwitcher @@ -746,13 +754,31 @@ export type OrganizationSwitcherProps = { /** * Full URL or path to navigate after a successful organization switch. * @default undefined + * @deprecated use `afterSelectOrganizationUrl` or `afterSelectPersonalUrl` */ afterSwitchOrganizationUrl?: string; /** * Full URL or path to navigate after creating a new organization. * @default undefined */ - afterCreateOrganizationUrl?: string; + afterCreateOrganizationUrl?: + | ((organization: OrganizationResource) => string) + | LooseExtractedParams>; + /** + * Full URL or path to navigate after a successful organization selection. + * Accepts a function that returns URL or path + * @default undefined` + */ + afterSelectOrganizationUrl?: + | ((organization: OrganizationResource) => string) + | LooseExtractedParams>; + + /** + * Full URL or path to navigate after a successful selection of personal workspace. + * Accepts a function that returns URL or path + * @default undefined` + */ + afterSelectPersonalUrl?: ((user: UserResource) => string) | LooseExtractedParams>; /** * Full URL or path to navigate to after the user leaves the currently active organization. * @default undefined