From 51861addff911615035fdc74718a1deff3f5cd62 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Tue, 24 Oct 2023 04:41:11 +0300 Subject: [PATCH] Custom Pages for `` and `` components (#1822) * feat(clerk-js,clerk-react,types): Introduce Custom Pages in UserProfile * fix(clerk-js): Fix top-level `localizationKeys` evaluation * fix(clerk-react): Fix issue with useCustomPages when making changes on the custom pages in dev * chore(clerk-js): Update bundlewatch.config.json * fix(clerk-react): Fix issue when changing the custom pages length dynamically * feat(clerk-react,clerk-js): Add support for custom pages in OrganizationProfile * fix(clerk-react,clerk-js): Resolve comments for custom pages * test(clerk-js): Add tests for OrganizationProfile custom pages * fix(clerk-js): Add navbar menu for mobile on custom pages * fix(clerk-react): Omit `customPages` property from React package components * refactor(clerk-js): Refactor the UserProfileRoutes and OrganizationProfileRoutes to be more readable * refactor(clerk-react,types): Apply minor refactors suggested by PR comments * refactor(clerk-react,types): Resolve PR comments * chore(clerk-js): Lint OrganizationProfileRoutes * fix(clerk-js): Fix custom icons props * fix(nextjs): Fix typings issue --- .changeset/proud-ways-lie.md | 39 ++ packages/clerk-js/bundlewatch.config.json | 2 +- .../ui/common/CustomPageContentContainer.tsx | 28 + .../OrganizationProfileNavbar.tsx | 34 +- .../OrganizationProfileRoutes.tsx | 178 ++--- .../__tests__/OrganizationProfile.test.tsx | 34 + .../UserProfile/UserProfileNavbar.tsx | 36 +- .../UserProfile/UserProfileRoutes.tsx | 197 ++++-- .../__tests__/UserProfile.test.tsx | 37 +- packages/clerk-js/src/ui/constants.ts | 9 + .../ui/contexts/ClerkUIComponentsContext.tsx | 30 +- packages/clerk-js/src/ui/elements/Navbar.tsx | 23 +- .../src/ui/utils/ExternalElementMounter.tsx | 28 + .../utils/__tests__/createCustomPages.test.ts | 616 ++++++++++++++++++ .../src/ui/utils/createCustomPages.tsx | 305 +++++++++ packages/clerk-js/src/ui/utils/index.ts | 2 + .../src/app-beta/client/ui-components.tsx | 21 +- .../react/src/components/uiComponents.tsx | 202 ++++-- packages/react/src/errors.ts | 23 +- packages/react/src/types.ts | 17 + packages/react/src/utils/index.ts | 2 + .../src/utils/useCustomElementPortal.tsx | 28 + packages/react/src/utils/useCustomPages.tsx | 169 +++++ packages/shared/src/utils/index.ts | 1 + .../shared/src/utils/logErrorInDevMode.ts | 7 + packages/types/src/appearance.ts | 6 +- packages/types/src/clerk.ts | 13 +- packages/types/src/customPages.ts | 8 + packages/types/src/index.ts | 1 + 29 files changed, 1812 insertions(+), 284 deletions(-) create mode 100644 .changeset/proud-ways-lie.md create mode 100644 packages/clerk-js/src/ui/common/CustomPageContentContainer.tsx create mode 100644 packages/clerk-js/src/ui/constants.ts create mode 100644 packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx create mode 100644 packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts create mode 100644 packages/clerk-js/src/ui/utils/createCustomPages.tsx create mode 100644 packages/react/src/utils/useCustomElementPortal.tsx create mode 100644 packages/react/src/utils/useCustomPages.tsx create mode 100644 packages/shared/src/utils/logErrorInDevMode.ts create mode 100644 packages/types/src/customPages.ts diff --git a/.changeset/proud-ways-lie.md b/.changeset/proud-ways-lie.md new file mode 100644 index 0000000000..d099191bd3 --- /dev/null +++ b/.changeset/proud-ways-lie.md @@ -0,0 +1,39 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Introduce customization in `UserProfile` and `OrganizationProfile` + +The `` component now allows the addition of custom pages and external links to the navigation sidebar. Custom pages can be created using the `` component, and external links can be added using the `` component. The default routes, such as `Account` and `Security`, can be reordered. + +Example React API usage: + +```tsx + + }> + + + } /> + + + +``` +Custom pages and links should be provided as children using the `` and `` components when using the `UserButton` component. + +The `` component now supports the addition of custom pages and external links to the navigation sidebar. Custom pages can be created using the `` component, and external links can be added using the `` component. The default routes, such as `Members` and `Settings`, can be reordered. + +Example React API usage: + +```tsx + + }> + + + } /> + + + +``` +Custom pages and links should be provided as children using the `` and `` components when using the `OrganizationSwitcher` component. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 4703390f12..9ff2876379 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "./dist/clerk.browser.js", "maxSize": "62kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "43kB" }, - { "path": "./dist/ui-common*.js", "maxSize": "75KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "76KB" }, { "path": "./dist/vendors*.js", "maxSize": "70KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, { "path": "./dist/impersonationfab*.js", "maxSize": "5KB" }, diff --git a/packages/clerk-js/src/ui/common/CustomPageContentContainer.tsx b/packages/clerk-js/src/ui/common/CustomPageContentContainer.tsx new file mode 100644 index 0000000000..e134a08f89 --- /dev/null +++ b/packages/clerk-js/src/ui/common/CustomPageContentContainer.tsx @@ -0,0 +1,28 @@ +import { Col, descriptors } from '../customizables'; +import { CardAlert, NavbarMenuButtonRow, useCardState, withCardStateProvider } from '../elements'; +import type { CustomPageContent } from '../utils'; +import { ExternalElementMounter } from '../utils'; + +export const CustomPageContentContainer = withCardStateProvider( + ({ mount, unmount }: Omit) => { + const card = useCardState(); + return ( + + {card.error} + + + + + + ); + }, +); diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx index 9306a562e4..7832af2bce 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx @@ -1,31 +1,14 @@ import React from 'react'; -import { useCoreOrganization } from '../../contexts'; -import type { NavbarRoute } from '../../elements'; +import { useCoreOrganization, useOrganizationProfileContext } from '../../contexts'; import { Breadcrumbs, NavBar, NavbarContextProvider, OrganizationPreview } from '../../elements'; -import { CogFilled, User } from '../../icons'; -import { localizationKeys } from '../../localization'; import type { PropsOfComponent } from '../../styledSystem'; -const organizationProfileRoutes: NavbarRoute[] = [ - { - name: localizationKeys('organizationProfile.start.headerTitle__members'), - id: 'members', - icon: User, - path: '/', - }, - { - name: localizationKeys('organizationProfile.start.headerTitle__settings'), - id: 'settings', - icon: CogFilled, - path: 'organization-settings', - }, -]; - export const OrganizationProfileNavbar = ( props: React.PropsWithChildren, 'contentRef'>>, ) => { const { organization } = useCoreOrganization(); + const { pages } = useOrganizationProfileContext(); if (!organization) { return null; @@ -41,7 +24,7 @@ export const OrganizationProfileNavbar = ( sx={t => ({ margin: `0 0 ${t.space.$4} ${t.space.$2}` })} /> } - routes={organizationProfileRoutes} + routes={pages.routes} contentRef={props.contentRef} /> {props.children} @@ -49,19 +32,12 @@ export const OrganizationProfileNavbar = ( ); }; -const pageToRootNavbarRouteMap = { - 'invite-members': organizationProfileRoutes.find(r => r.id === 'members'), - domain: organizationProfileRoutes.find(r => r.id === 'settings'), - profile: organizationProfileRoutes.find(r => r.id === 'settings'), - leave: organizationProfileRoutes.find(r => r.id === 'settings'), - delete: organizationProfileRoutes.find(r => r.id === 'settings'), -}; - export const OrganizationProfileBreadcrumbs = (props: Pick, 'title'>) => { + const { pages } = useOrganizationProfileContext(); return ( ); }; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index f84c667bcd..ed0c9c4cec 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -1,4 +1,7 @@ -import { Gate } from '../../common/Gate'; +import { Gate } from '../../common'; +import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; +import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID } from '../../constants'; +import { useOrganizationProfileContext } from '../../contexts'; import { ProfileCardContent } from '../../elements'; import { Route, Switch } from '../../router'; import type { PropsOfComponent } from '../../styledSystem'; @@ -13,100 +16,125 @@ import { VerifiedDomainPage } from './VerifiedDomainPage'; import { VerifyDomainPage } from './VerifyDomainPage'; export const OrganizationProfileRoutes = (props: PropsOfComponent) => { + const { pages } = useOrganizationProfileContext(); + const isMembersPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS; + const isSettingsPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS; + + const customPageRoutesWithContents = pages.contents?.map((customPage, index) => { + const shouldFirstCustomItemBeOnRoot = !isSettingsPageRoot && !isMembersPageRoot && index === 0; + return ( + + + + ); + }); + return ( - - - - - - - - + + {customPageRoutesWithContents} + + - + - + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + + + + - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - + + ); }; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx index 18cb3efe8d..3b654a948e 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/OrganizationProfile.test.tsx @@ -1,3 +1,4 @@ +import type { CustomPage } from '@clerk/types'; import { describe, it } from '@jest/globals'; import React from 'react'; @@ -19,4 +20,37 @@ describe('OrganizationProfile', () => { expect(getByText('Members')).toBeDefined(); expect(getByText('Settings')).toBeDefined(); }); + + it('includes custom nav items', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ email_addresses: ['test@clerk.dev'], organization_memberships: ['Org1'] }); + }); + + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'ExternalLink', + url: '/link', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + + props.setProps({ customPages }); + + const { getByText } = render(, { wrapper }); + expect(getByText('Org1')).toBeDefined(); + expect(getByText('Members')).toBeDefined(); + expect(getByText('Settings')).toBeDefined(); + expect(getByText('Custom1')).toBeDefined(); + expect(getByText('ExternalLink')).toBeDefined(); + }); }); diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx index 08bf8ac6e6..0b05702a4e 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx @@ -1,33 +1,17 @@ import React from 'react'; -import type { NavbarRoute } from '../../elements'; +import { useUserProfileContext } from '../../contexts'; import { Breadcrumbs, NavBar, NavbarContextProvider } from '../../elements'; -import { TickShield, User } from '../../icons'; -import { localizationKeys } from '../../localization'; import type { PropsOfComponent } from '../../styledSystem'; -const userProfileRoutes: NavbarRoute[] = [ - { - name: localizationKeys('userProfile.start.headerTitle__account'), - id: 'account', - icon: User, - path: '/', - }, - { - name: localizationKeys('userProfile.start.headerTitle__security'), - id: 'security', - icon: TickShield, - path: '', - }, -]; - export const UserProfileNavbar = ( props: React.PropsWithChildren, 'contentRef'>>, ) => { + const { pages } = useUserProfileContext(); return ( {props.children} @@ -35,22 +19,12 @@ export const UserProfileNavbar = ( ); }; -const pageToRootNavbarRouteMap = { - profile: userProfileRoutes.find(r => r.id === 'account'), - 'email-address': userProfileRoutes.find(r => r.id === 'account'), - 'phone-number': userProfileRoutes.find(r => r.id === 'account'), - 'connected-account': userProfileRoutes.find(r => r.id === 'account'), - 'web3-wallet': userProfileRoutes.find(r => r.id === 'account'), - username: userProfileRoutes.find(r => r.id === 'account'), - 'multi-factor': userProfileRoutes.find(r => r.id === 'security'), - password: userProfileRoutes.find(r => r.id === 'security'), -}; - export const UserProfileBreadcrumbs = (props: Pick, 'title'>) => { + const { pages } = useUserProfileContext(); return ( ); }; diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx index 0e5b2401bc..a9b293e3a3 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx @@ -1,3 +1,6 @@ +import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; +import { USER_PROFILE_NAVBAR_ROUTE_ID } from '../../constants'; +import { useUserProfileContext } from '../../contexts'; import { ProfileCardContent } from '../../elements'; import { Route, Switch } from '../../router'; import type { PropsOfComponent } from '../../styledSystem'; @@ -22,90 +25,142 @@ import { UsernamePage } from './UsernamePage'; import { Web3Page } from './Web3Page'; export const UserProfileRoutes = (props: PropsOfComponent) => { + const { pages } = useUserProfileContext(); + const isAccountPageRoot = + pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT || + pages.routes[0].id === USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY; + + const customPageRoutesWithContents = pages.contents?.map((customPage, index) => { + const shouldFirstCustomItemBeOnRoot = !isAccountPageRoot && index === 0; + return ( + + + + ); + }); + return ( - - - - - - - - - - - - - - - - - - - - - - - - - - + + {customPageRoutesWithContents} + + + - - + + + + + + + + + + + + - - - - - - + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + - - + + + + + + + + + - - + + + + + + + + + - - + + - - - - - - + {/**/} + + - - - - - - - - - + + - + - - - - - - {/**/} - - - - {/**/} - - - + + ); }; diff --git a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx index 9f6ca54a56..f7ee22f59c 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx @@ -1,10 +1,11 @@ +import type { CustomPage } from '@clerk/types'; import { describe, it } from '@jest/globals'; import React from 'react'; import { bindCreateFixtures, render, screen } from '../../../../testUtils'; import { UserProfile } from '../UserProfile'; -const { createFixtures } = bindCreateFixtures('SignIn'); +const { createFixtures } = bindCreateFixtures('UserProfile'); describe('UserProfile', () => { describe('Navigation', () => { @@ -19,5 +20,39 @@ describe('UserProfile', () => { const securityElements = screen.getAllByText(/Security/i); expect(securityElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true); }); + + it('includes custom nav items', async () => { + const { wrapper, props } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.dev'] }); + }); + + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'ExternalLink', + url: '/link', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + + props.setProps({ customPages }); + render(, { wrapper }); + const accountElements = screen.getAllByText(/Account/i); + expect(accountElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true); + const securityElements = screen.getAllByText(/Security/i); + expect(securityElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true); + const customElements = screen.getAllByText(/Custom1/i); + expect(customElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true); + const externalElements = screen.getAllByText(/ExternalLink/i); + expect(externalElements.some(el => el.tagName.toUpperCase() === 'BUTTON')).toBe(true); + }); }); }); diff --git a/packages/clerk-js/src/ui/constants.ts b/packages/clerk-js/src/ui/constants.ts new file mode 100644 index 0000000000..17d1dea512 --- /dev/null +++ b/packages/clerk-js/src/ui/constants.ts @@ -0,0 +1,9 @@ +export const USER_PROFILE_NAVBAR_ROUTE_ID = { + ACCOUNT: 'account', + SECURITY: 'security', +}; + +export const ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID = { + MEMBERS: 'members', + SETTINGS: 'settings', +}; diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 07ef3640e9..b7bbacb277 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -5,6 +5,7 @@ import React, { useMemo } from 'react'; import { SIGN_IN_INITIAL_VALUE_KEYS, SIGN_UP_INITIAL_VALUE_KEYS } from '../../core/constants'; import { buildAuthQueryString, buildURL, createDynamicParamParser, pickRedirectionProp } from '../../utils'; import { useCoreClerk, useEnvironment, useOptions } from '../contexts'; +import type { NavbarRoute } from '../elements'; import type { ParsedQs } from '../router'; import { useRouter } from '../router'; import type { @@ -18,6 +19,8 @@ import type { UserButtonCtx, UserProfileCtx, } from '../types'; +import type { CustomPageContent } from '../utils'; +import { createOrganizationProfileCustomPages, createUserProfileCustomPages } from '../utils'; const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ }); @@ -184,24 +187,31 @@ export const useSignInContext = (): SignInContextType => { }; }; +type PagesType = { + routes: NavbarRoute[]; + contents: CustomPageContent[]; + pageToRootNavbarRouteMap: Record; +}; + export type UserProfileContextType = UserProfileCtx & { queryParams: ParsedQs; authQueryString: string | null; + pages: PagesType; }; -// UserProfile does not accept any props except for -// `routing` and `path` -// TODO: remove if not needed during the components v2 overhaul export const useUserProfileContext = (): UserProfileContextType => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as UserProfileCtx; + const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}) as UserProfileCtx; const { queryParams } = useRouter(); if (componentName !== 'UserProfile') { throw new Error('Clerk: useUserProfileContext called outside of the mounted UserProfile component.'); } + const pages = createUserProfileCustomPages(customPages || []); + return { ...ctx, + pages, componentName, queryParams, authQueryString: '', @@ -398,8 +408,13 @@ export const useOrganizationListContext = () => { }; }; -export const useOrganizationProfileContext = () => { - const { componentName, ...ctx } = (React.useContext(ComponentContext) || {}) as OrganizationProfileCtx; +export type OrganizationProfileContextType = OrganizationProfileCtx & { + pages: PagesType; + navigateAfterLeaveOrganization: () => Promise; +}; + +export const useOrganizationProfileContext = (): OrganizationProfileContextType => { + const { componentName, customPages, ...ctx } = (React.useContext(ComponentContext) || {}) as OrganizationProfileCtx; const { navigate } = useRouter(); const { displayConfig } = useEnvironment(); @@ -407,11 +422,14 @@ export const useOrganizationProfileContext = () => { throw new Error('Clerk: useOrganizationProfileContext called outside OrganizationProfile.'); } + const pages = createOrganizationProfileCustomPages(customPages || []); + const navigateAfterLeaveOrganization = () => navigate(ctx.afterLeaveOrganizationUrl || displayConfig.afterLeaveOrganizationUrl); return { ...ctx, + pages, navigateAfterLeaveOrganization, componentName, }; diff --git a/packages/clerk-js/src/ui/elements/Navbar.tsx b/packages/clerk-js/src/ui/elements/Navbar.tsx index 4f348f2c4d..0148b538c2 100644 --- a/packages/clerk-js/src/ui/elements/Navbar.tsx +++ b/packages/clerk-js/src/ui/elements/Navbar.tsx @@ -1,5 +1,4 @@ import { createContextAndHook, useSafeLayoutEffect } from '@clerk/shared/react'; -import type { NavbarItemId } from '@clerk/types'; import React, { useEffect } from 'react'; import type { LocalizationKey } from '../customizables'; @@ -27,10 +26,11 @@ export const NavbarContextProvider = (props: React.PropsWithChildren `#cl-section-${id}`; export const NavBar = (props: NavBarProps) => { const { contentRef, routes, header } = props; - const [activeId, setActiveId] = React.useState(routes[0]['id']); + const [activeId, setActiveId] = React.useState(''); const { close } = useNavbarContext(); const { navigate } = useRouter(); const { navigateToFlowStart } = useNavigateToFlowStart(); const { t } = useLocalizations(); const router = useRouter(); + const handleNavigate = (route: NavbarRoute) => { + if (route?.external) { + return () => navigate(route.path); + } else { + return () => navigateAndScroll(route); + } + }; + const navigateAndScroll = async (route: NavbarRoute) => { if (contentRef.current) { setActiveId(route.id); @@ -74,7 +82,7 @@ export const NavBar = (props: NavBarProps) => { for (const entry of entries) { const id = entry.target?.id?.split('section-')[1]; if (entry.isIntersecting && id) { - return setActiveId(id as NavbarItemId); + return setActiveId(id); } } }; @@ -114,8 +122,9 @@ export const NavBar = (props: NavBarProps) => { const matchesPath = router.matches(route.path); if (isRoot || matchesPath) { setActiveId(route.id); + return false; } - return false; + return true; }); }, [router.currentPath]); @@ -128,7 +137,7 @@ export const NavBar = (props: NavBarProps) => { elementId={descriptors.navbarButton.setId(r.id as any)} iconElementDescriptor={descriptors.navbarButtonIcon} iconElementId={descriptors.navbarButtonIcon.setId(r.id) as any} - onClick={() => navigateAndScroll(r)} + onClick={handleNavigate(r)} icon={r.icon} isActive={activeId === r.id} > diff --git a/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx new file mode 100644 index 0000000000..db29be4590 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx @@ -0,0 +1,28 @@ +import { useEffect, useRef } from 'react'; + +type ExternalElementMounterProps = { + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +export const ExternalElementMounter = ({ mount, unmount, ...rest }: ExternalElementMounterProps) => { + const nodeRef = useRef(null); + + useEffect(() => { + let elRef: HTMLDivElement | undefined; + if (nodeRef.current) { + elRef = nodeRef.current; + mount(nodeRef.current); + } + return () => { + unmount(elRef); + }; + }, [nodeRef.current]); + + return ( +
+ ); +}; diff --git a/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts b/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts new file mode 100644 index 0000000000..0ddff30479 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts @@ -0,0 +1,616 @@ +import type { CustomPage } from '@clerk/types'; + +import { createOrganizationProfileCustomPages, createUserProfileCustomPages } from '../createCustomPages'; + +describe('createCustomPages', () => { + describe('createUserProfileCustomPages', () => { + it('should return the default pages if no custom pages are passed', () => { + const { routes, contents } = createUserProfileCustomPages([]); + expect(routes.length).toEqual(2); + expect(routes[0].id).toEqual('account'); + expect(routes[1].id).toEqual('security'); + expect(contents.length).toEqual(0); + }); + + it('should return the custom pages after the default pages', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].id).toEqual('account'); + expect(routes[1].id).toEqual('security'); + expect(routes[2].name).toEqual('Custom1'); + expect(routes[3].name).toEqual('Custom2'); + expect(contents.length).toEqual(2); + expect(contents[0].url).toEqual('custom1'); + expect(contents[1].url).toEqual('custom2'); + }); + + it('should reorder the default pages when their label is used to target them', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { label: 'security' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].name).toEqual('Custom1'); + expect(routes[1].id).toEqual('account'); + expect(routes[2].id).toEqual('security'); + expect(routes[3].name).toEqual('Custom2'); + expect(contents.length).toEqual(2); + expect(contents[0].url).toEqual('custom1'); + expect(contents[1].url).toEqual('custom2'); + }); + + it('ignores invalid entries', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { label: 'security' }, + { label: 'Aaaaaa' }, + { label: 'account', mount: () => undefined }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].name).toEqual('Custom1'); + expect(routes[1].id).toEqual('account'); + expect(routes[2].id).toEqual('security'); + expect(routes[3].name).toEqual('Custom2'); + }); + + it('sets the path of the first page to be the root (/)', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { label: 'security' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('account'); + expect(routes[2].path).toEqual('account'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('sets the path of both account and security pages to root (/) if account is first', () => { + const customPages: CustomPage[] = [ + { label: 'account' }, + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'security' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('custom1'); + expect(routes[2].path).toEqual('/'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('sets the path of both account and security pages to root (/) if security is first', () => { + const customPages: CustomPage[] = [ + { label: 'security' }, + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('custom1'); + expect(routes[2].path).toEqual('/'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('throws if the first item in the navbar is an external link', () => { + const customPages: CustomPage[] = [ + { + label: 'Link1', + url: '/link1', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'account' }, + { label: 'security' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + expect(() => createUserProfileCustomPages(customPages)).toThrow(); + }); + + it('adds an external link to the navbar routes', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link1', + url: '/link1', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].id).toEqual('account'); + expect(routes[1].id).toEqual('security'); + expect(routes[2].name).toEqual('Custom1'); + expect(routes[3].name).toEqual('Link1'); + expect(contents.length).toEqual(1); + expect(contents[0].url).toEqual('custom1'); + }); + + it('sanitizes the path for external links', () => { + const customPages: CustomPage[] = [ + { + label: 'Link1', + url: 'https://www.fullurl.com', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link2', + url: '/url-with-slash', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link3', + url: 'url-without-slash', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(5); + expect(routes[2].path).toEqual('https://www.fullurl.com'); + expect(routes[3].path).toEqual('/url-with-slash'); + expect(routes[4].path).toEqual('/url-without-slash'); + }); + + it('sanitizes the path for custom pages', () => { + const customPages: CustomPage[] = [ + { + label: 'Page1', + url: '/url-with-slash', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Page2', + url: 'url-without-slash', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createUserProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[2].path).toEqual('url-with-slash'); + expect(routes[3].path).toEqual('url-without-slash'); + }); + + it('throws when a custom page has an absolute URL', () => { + const customPages: CustomPage[] = [ + { + label: 'Page1', + url: 'https://www.fullurl.com', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + expect(() => createUserProfileCustomPages(customPages)).toThrow(); + }); + }); + + describe('createOrganizationProfileCustomPages', () => { + it('should return the default pages if no custom pages are passed', () => { + const { routes, contents } = createOrganizationProfileCustomPages([]); + expect(routes.length).toEqual(2); + expect(routes[0].id).toEqual('members'); + expect(routes[1].id).toEqual('settings'); + expect(contents.length).toEqual(0); + }); + + it('should return the custom pages after the default pages', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].id).toEqual('members'); + expect(routes[1].id).toEqual('settings'); + expect(routes[2].name).toEqual('Custom1'); + expect(routes[3].name).toEqual('Custom2'); + expect(contents.length).toEqual(2); + expect(contents[0].url).toEqual('custom1'); + expect(contents[1].url).toEqual('custom2'); + }); + + it('should reorder the default pages when their label is used to target them', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'members' }, + { label: 'settings' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].name).toEqual('Custom1'); + expect(routes[1].id).toEqual('members'); + expect(routes[2].id).toEqual('settings'); + expect(routes[3].name).toEqual('Custom2'); + expect(contents.length).toEqual(2); + expect(contents[0].url).toEqual('custom1'); + expect(contents[1].url).toEqual('custom2'); + }); + + it('ignores invalid entries', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'members' }, + { label: 'settings' }, + { label: 'Aaaaaa' }, + { label: 'members', mount: () => undefined }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].name).toEqual('Custom1'); + expect(routes[1].id).toEqual('members'); + expect(routes[2].id).toEqual('settings'); + expect(routes[3].name).toEqual('Custom2'); + }); + + it('sets the path of the first page to be the root (/)', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'members' }, + { label: 'settings' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('organization-members'); + expect(routes[2].path).toEqual('organization-settings'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('sets the path of members pages to root (/) if it is first', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'settings' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('custom1'); + expect(routes[2].path).toEqual('organization-settings'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('sets the path of settings pages to root (/) if it is first', () => { + const customPages: CustomPage[] = [ + { label: 'settings' }, + { label: 'members' }, + + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].path).toEqual('/'); + expect(routes[1].path).toEqual('organization-members'); + expect(routes[3].path).toEqual('custom2'); + }); + + it('throws if the first item in the navbar is an external link', () => { + const customPages: CustomPage[] = [ + { + label: 'Link1', + url: '/link1', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { label: 'members' }, + { label: 'settings' }, + { + label: 'Custom2', + url: 'custom2', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + expect(() => createOrganizationProfileCustomPages(customPages)).toThrow(); + }); + + it('adds an external link to the navbar routes', () => { + const customPages: CustomPage[] = [ + { + label: 'Custom1', + url: 'custom1', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link1', + url: '/link1', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes, contents } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[0].id).toEqual('members'); + expect(routes[1].id).toEqual('settings'); + expect(routes[2].name).toEqual('Custom1'); + expect(routes[3].name).toEqual('Link1'); + expect(contents.length).toEqual(1); + expect(contents[0].url).toEqual('custom1'); + }); + + it('sanitizes the path for external links', () => { + const customPages: CustomPage[] = [ + { + label: 'Link1', + url: 'https://www.fullurl.com', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link2', + url: '/url-with-slash', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Link3', + url: 'url-without-slash', + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(5); + expect(routes[2].path).toEqual('https://www.fullurl.com'); + expect(routes[3].path).toEqual('/url-with-slash'); + expect(routes[4].path).toEqual('/url-without-slash'); + }); + + it('sanitizes the path for custom pages', () => { + const customPages: CustomPage[] = [ + { + label: 'Page1', + url: '/url-with-slash', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + { + label: 'Page2', + url: 'url-without-slash', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + const { routes } = createOrganizationProfileCustomPages(customPages); + expect(routes.length).toEqual(4); + expect(routes[2].path).toEqual('url-with-slash'); + expect(routes[3].path).toEqual('url-without-slash'); + }); + + it('throws when a custom page has an absolute URL', () => { + const customPages: CustomPage[] = [ + { + label: 'Page1', + url: 'https://www.fullurl.com', + mount: () => undefined, + unmount: () => undefined, + mountIcon: () => undefined, + unmountIcon: () => undefined, + }, + ]; + expect(() => createOrganizationProfileCustomPages(customPages)).toThrow(); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx new file mode 100644 index 0000000000..cff378eaf3 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -0,0 +1,305 @@ +import { isDevelopmentEnvironment } from '@clerk/shared'; +import type { CustomPage } from '@clerk/types'; + +import { isValidUrl } from '../../utils'; +import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID, USER_PROFILE_NAVBAR_ROUTE_ID } from '../constants'; +import type { NavbarRoute } from '../elements'; +import { CogFilled, TickShield, User } from '../icons'; +import { localizationKeys } from '../localization'; +import { ExternalElementMounter } from './ExternalElementMounter'; + +export type CustomPageContent = { + url: string; + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +type ProfileReorderItem = { + label: 'account' | 'security' | 'members' | 'settings'; +}; + +type ProfileCustomPage = { + label: string; + url: string; + mountIcon: (el: HTMLDivElement) => void; + unmountIcon: (el?: HTMLDivElement) => void; + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +type ProfileCustomLink = { + label: string; + url: string; + mountIcon: (el: HTMLDivElement) => void; + unmountIcon: (el?: HTMLDivElement) => void; +}; + +type GetDefaultRoutesReturnType = { + INITIAL_ROUTES: NavbarRoute[]; + pageToRootNavbarRouteMap: Record; + validReorderItemLabels: string[]; +}; + +type CreateCustomPagesParams = { + customPages: CustomPage[]; + getDefaultRoutes: () => GetDefaultRoutesReturnType; + setFirstPathToRoot: (routes: NavbarRoute[]) => NavbarRoute[]; + excludedPathsFromDuplicateWarning: string[]; +}; + +export const createUserProfileCustomPages = (customPages: CustomPage[]) => { + return createCustomPages({ + customPages, + getDefaultRoutes: getUserProfileDefaultRoutes, + setFirstPathToRoot: setFirstPathToUserProfileRoot, + excludedPathsFromDuplicateWarning: ['/', 'account'], + }); +}; + +export const createOrganizationProfileCustomPages = (customPages: CustomPage[]) => { + return createCustomPages({ + customPages, + getDefaultRoutes: getOrganizationProfileDefaultRoutes, + setFirstPathToRoot: setFirstPathToOrganizationProfileRoot, + excludedPathsFromDuplicateWarning: [], + }); +}; + +const createCustomPages = ({ + customPages, + getDefaultRoutes, + setFirstPathToRoot, + excludedPathsFromDuplicateWarning, +}: CreateCustomPagesParams) => { + const { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels } = getDefaultRoutes(); + + if (isDevelopmentEnvironment()) { + checkForDuplicateUsageOfReorderingItems(customPages, validReorderItemLabels); + } + + const validCustomPages = customPages.filter(cp => { + if (!isValidPageItem(cp, validReorderItemLabels)) { + if (isDevelopmentEnvironment()) { + console.error('Clerk: Invalid custom page data: ', cp); + } + return false; + } + return true; + }); + + const { allRoutes, contents } = getRoutesAndContents({ + customPages: validCustomPages, + defaultRoutes: INITIAL_ROUTES, + }); + + assertExternalLinkAsRoot(allRoutes); + + const routes = setFirstPathToRoot(allRoutes); + + if (isDevelopmentEnvironment()) { + warnForDuplicatePaths(routes, excludedPathsFromDuplicateWarning); + } + + return { + routes, + contents, + pageToRootNavbarRouteMap, + }; +}; + +type GetRoutesAndContentsParams = { + customPages: CustomPage[]; + defaultRoutes: NavbarRoute[]; +}; + +const getRoutesAndContents = ({ customPages, defaultRoutes }: GetRoutesAndContentsParams) => { + let remainingDefaultRoutes: NavbarRoute[] = defaultRoutes.map(r => r); + const contents: CustomPageContent[] = []; + + const routesWithoutDefaults: NavbarRoute[] = customPages.map((cp, index) => { + if (isCustomLink(cp)) { + return { + name: cp.label, + id: `custom-page-${index}`, + icon: props => ( + + ), + path: sanitizeCustomLinkURL(cp.url), + external: true, + }; + } + if (isCustomPage(cp)) { + const pageURL = sanitizeCustomPageURL(cp.url); + contents.push({ url: pageURL, mount: cp.mount, unmount: cp.unmount }); + return { + name: cp.label, + id: `custom-page-${index}`, + icon: props => ( + + ), + path: pageURL, + }; + } + const reorderItem = defaultRoutes.find(r => r.id === cp.label) as NavbarRoute; + remainingDefaultRoutes = remainingDefaultRoutes.filter(({ id }) => id !== cp.label); + return { ...reorderItem }; + }); + + const allRoutes = [...remainingDefaultRoutes, ...routesWithoutDefaults]; + + return { allRoutes, contents }; +}; + +// Set the path of the first route to '/' or if the first route is account or security, set the path of both account and security to '/' +const setFirstPathToUserProfileRoot = (routes: NavbarRoute[]): NavbarRoute[] => { + if (routes[0].id === 'account' || routes[0].id === 'security') { + return routes.map(r => { + if (r.id === 'account' || r.id === 'security') { + return { ...r, path: '/' }; + } + return r; + }); + } else { + return routes.map((r, index) => (index === 0 ? { ...r, path: '/' } : r)); + } +}; + +const setFirstPathToOrganizationProfileRoot = (routes: NavbarRoute[]): NavbarRoute[] => { + return routes.map((r, index) => (index === 0 ? { ...r, path: '/' } : r)); +}; + +const checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[], validReorderItems: string[]) => { + const reorderItems = customPages.filter(cp => isReorderItem(cp, validReorderItems)); + reorderItems.reduce((acc, cp) => { + if (acc.includes(cp.label)) { + console.error( + `Clerk: The "${cp.label}" item is used more than once when reordering pages. This may cause unexpected behavior.`, + ); + } + return [...acc, cp.label]; + }, [] as string[]); +}; + +//path !== '/' && path !== 'account' +const warnForDuplicatePaths = (routes: NavbarRoute[], pathsToFilter: string[]) => { + const paths = routes + .filter(({ external, path }) => !external && pathsToFilter.every(p => p !== path)) + .map(({ path }) => path); + const duplicatePaths = paths.filter((p, index) => paths.indexOf(p) !== index); + duplicatePaths.forEach(p => { + console.error(`Clerk: Duplicate path "${p}" found in custom pages. This may cause unexpected behavior.`); + }); +}; + +const isValidPageItem = (cp: CustomPage, validReorderItems: string[]): cp is CustomPage => { + return isCustomPage(cp) || isCustomLink(cp) || isReorderItem(cp, validReorderItems); +}; + +const isCustomPage = (cp: CustomPage): cp is ProfileCustomPage => { + return !!cp.url && !!cp.label && !!cp.mount && !!cp.unmount && !!cp.mountIcon && !!cp.unmountIcon; +}; + +const isCustomLink = (cp: CustomPage): cp is ProfileCustomLink => { + return !!cp.url && !!cp.label && !cp.mount && !cp.unmount && !!cp.mountIcon && !!cp.unmountIcon; +}; + +const isReorderItem = (cp: CustomPage, validItems: string[]): cp is ProfileReorderItem => { + return ( + !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && validItems.some(v => v === cp.label) + ); +}; + +const sanitizeCustomPageURL = (url: string): string => { + if (!url) { + throw new Error('Clerk: URL is required for custom pages'); + } + if (isValidUrl(url)) { + throw new Error('Clerk: Absolute URLs are not supported for custom pages'); + } + return (url as string).charAt(0) === '/' && (url as string).length > 1 ? (url as string).substring(1) : url; +}; + +const sanitizeCustomLinkURL = (url: string): string => { + if (!url) { + throw new Error('Clerk: URL is required for custom links'); + } + if (isValidUrl(url)) { + return url; + } + return (url as string).charAt(0) === '/' ? url : `/${url}`; +}; + +const assertExternalLinkAsRoot = (routes: NavbarRoute[]) => { + if (routes[0].external) { + throw new Error('Clerk: The first route cannot be a custom external link component'); + } +}; + +const getUserProfileDefaultRoutes = (): GetDefaultRoutesReturnType => { + const INITIAL_ROUTES: NavbarRoute[] = [ + { + name: localizationKeys('userProfile.start.headerTitle__account'), + id: USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT, + icon: User, + path: 'account', + }, + { + name: localizationKeys('userProfile.start.headerTitle__security'), + id: USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY, + icon: TickShield, + path: 'account', + }, + ]; + + const pageToRootNavbarRouteMap: Record = { + profile: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'email-address': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'phone-number': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'connected-account': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'web3-wallet': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + username: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT) as NavbarRoute, + 'multi-factor': INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY) as NavbarRoute, + password: INITIAL_ROUTES.find(r => r.id === USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY) as NavbarRoute, + }; + + const validReorderItemLabels: string[] = INITIAL_ROUTES.map(r => r.id); + + return { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels }; +}; + +const getOrganizationProfileDefaultRoutes = (): GetDefaultRoutesReturnType => { + const INITIAL_ROUTES: NavbarRoute[] = [ + { + name: localizationKeys('organizationProfile.start.headerTitle__members'), + id: ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS, + icon: User, + path: 'organization-members', + }, + { + name: localizationKeys('organizationProfile.start.headerTitle__settings'), + id: ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS, + icon: CogFilled, + path: 'organization-settings', + }, + ]; + + const pageToRootNavbarRouteMap: Record = { + 'invite-members': INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS) as NavbarRoute, + domain: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute, + profile: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute, + leave: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute, + delete: INITIAL_ROUTES.find(r => r.id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS) as NavbarRoute, + }; + + const validReorderItemLabels: string[] = INITIAL_ROUTES.map(r => r.id); + + return { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels }; +}; diff --git a/packages/clerk-js/src/ui/utils/index.ts b/packages/clerk-js/src/ui/utils/index.ts index a86856b90e..6c88d4aa3e 100644 --- a/packages/clerk-js/src/ui/utils/index.ts +++ b/packages/clerk-js/src/ui/utils/index.ts @@ -21,3 +21,5 @@ export * from './getRelativeToNowDateKey'; export * from './mergeRefs'; export * from './createSlug'; export * from './passwordUtils'; +export * from './createCustomPages'; +export * from './ExternalElementMounter'; diff --git a/packages/nextjs/src/app-beta/client/ui-components.tsx b/packages/nextjs/src/app-beta/client/ui-components.tsx index f7c4a0d389..e0ef758fe5 100644 --- a/packages/nextjs/src/app-beta/client/ui-components.tsx +++ b/packages/nextjs/src/app-beta/client/ui-components.tsx @@ -1,11 +1,4 @@ 'use client'; -import { deprecated } from '@clerk/shared/deprecated'; - -deprecated( - '@clerk/nextjs/app-beta', - 'Use imports from `@clerk/nextjs` instead.\nFor more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware', -); - import { CreateOrganization as _CreateOrganization, OrganizationProfile as _OrganizationProfile, @@ -15,6 +8,12 @@ import { UserButton as _UserButton, UserProfile as _UserProfile, } from '@clerk/clerk-react'; +import { deprecated } from '@clerk/shared/deprecated'; + +deprecated( + '@clerk/nextjs/app-beta', + 'Use imports from `@clerk/nextjs` instead.\nFor more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware', +); /** * @deprecated Use imports from `@clerk/nextjs` instead. @@ -25,12 +24,12 @@ export const CreateOrganization = _CreateOrganization; * @deprecated Use imports from `@clerk/nextjs` instead. * For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware */ -export const OrganizationProfile = _OrganizationProfile; +export const OrganizationProfile: typeof _OrganizationProfile = _OrganizationProfile; /** * @deprecated Use imports from `@clerk/nextjs` instead. * For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware */ -export const OrganizationSwitcher = _OrganizationSwitcher; +export const OrganizationSwitcher: typeof _OrganizationSwitcher = _OrganizationSwitcher; /** * @deprecated Use imports from `@clerk/nextjs` instead. * For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware @@ -45,9 +44,9 @@ export const SignUp = _SignUp; * @deprecated Use imports from `@clerk/nextjs` instead. * For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware */ -export const UserButton = _UserButton; +export const UserButton: typeof _UserButton = _UserButton; /** * @deprecated Use imports from `@clerk/nextjs` instead. * For more details, consult the middleware documentation: https://clerk.com/docs/nextjs/middleware */ -export const UserProfile = _UserProfile; +export const UserProfile: typeof _UserProfile = _UserProfile; diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index ca9af9043d..42edc8f1ff 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -1,3 +1,4 @@ +import { logErrorInDevMode } from '@clerk/shared'; import type { CreateOrganizationProps, OrganizationListProps, @@ -8,11 +9,54 @@ import type { UserButtonProps, UserProfileProps, } from '@clerk/types'; -import React from 'react'; +import type { PropsWithChildren } from 'react'; +import React, { createElement } from 'react'; -import type { MountProps, WithClerkProp } from '../types'; +import { + organizationProfileLinkRenderedError, + organizationProfilePageRenderedError, + userProfileLinkRenderedError, + userProfilePageRenderedError, +} from '../errors'; +import type { + MountProps, + OrganizationProfileLinkProps, + OrganizationProfilePageProps, + UserProfileLinkProps, + UserProfilePageProps, + WithClerkProp, +} from '../types'; +import { useOrganizationProfileCustomPages, useUserProfileCustomPages } from '../utils'; import { withClerk } from './withClerk'; +type UserProfileExportType = typeof _UserProfile & { + Page: typeof UserProfilePage; + Link: typeof UserProfileLink; +}; + +type UserButtonExportType = typeof _UserButton & { + UserProfilePage: typeof UserProfilePage; + UserProfileLink: typeof UserProfileLink; +}; + +type UserButtonPropsWithoutCustomPages = Omit & { + userProfileProps?: Pick; +}; + +type OrganizationProfileExportType = typeof _OrganizationProfile & { + Page: typeof OrganizationProfilePage; + Link: typeof OrganizationProfileLink; +}; + +type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { + OrganizationProfilePage: typeof OrganizationProfilePage; + OrganizationProfileLink: typeof OrganizationProfileLink; +}; + +type OrganizationSwitcherPropsWithoutCustomPages = Omit & { + organizationProfileProps?: Pick; +}; + // README: should be a class pure component in order for mount and unmount // lifecycle props to be invoked correctly. Replacing the class component with a // functional component wrapped with a React.memo is not identical to the original @@ -45,7 +89,10 @@ class Portal extends React.PureComponent { private portalRef = React.createRef(); componentDidUpdate(prevProps: Readonly) { - if (prevProps.props.appearance !== this.props.props.appearance) { + if ( + prevProps.props.appearance !== this.props.props.appearance || + prevProps.props?.customPages?.length !== this.props.props?.customPages?.length + ) { this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); } } @@ -63,7 +110,12 @@ class Portal extends React.PureComponent { } render() { - return
; + return ( + <> +
+ {this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} + + ); } } @@ -89,38 +141,89 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp ); }, 'SignUp'); -export const UserProfile = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'UserProfile'); +export function UserProfilePage({ children }: PropsWithChildren) { + logErrorInDevMode(userProfilePageRenderedError); + return <>{children}; +} -export const UserButton = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'UserButton'); +export function UserProfileLink({ children }: PropsWithChildren) { + logErrorInDevMode(userProfileLinkRenderedError); + return <>{children}; +} -export const OrganizationProfile = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'OrganizationProfile'); +const _UserProfile = withClerk( + ({ clerk, ...props }: WithClerkProp>>) => { + const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); + return ( + + ); + }, + 'UserProfile', +); + +export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, { + Page: UserProfilePage, + Link: UserProfileLink, +}); + +const _UserButton = withClerk( + ({ clerk, ...props }: WithClerkProp>) => { + const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); + const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages }); + return ( + + ); + }, + 'UserButton', +); + +export const UserButton: UserButtonExportType = Object.assign(_UserButton, { + UserProfilePage, + UserProfileLink, +}); + +export function OrganizationProfilePage({ children }: PropsWithChildren) { + logErrorInDevMode(organizationProfilePageRenderedError); + return <>{children}; +} + +export function OrganizationProfileLink({ children }: PropsWithChildren) { + logErrorInDevMode(organizationProfileLinkRenderedError); + return <>{children}; +} + +const _OrganizationProfile = withClerk( + ({ clerk, ...props }: WithClerkProp>>) => { + const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children); + return ( + + ); + }, + 'OrganizationProfile', +); + +export const OrganizationProfile: OrganizationProfileExportType = Object.assign(_OrganizationProfile, { + Page: OrganizationProfilePage, + Link: OrganizationProfileLink, +}); export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( @@ -133,16 +236,27 @@ export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp< ); }, 'CreateOrganization'); -export const OrganizationSwitcher = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'OrganizationSwitcher'); +const _OrganizationSwitcher = withClerk( + ({ clerk, ...props }: WithClerkProp>) => { + const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children); + const organizationProfileProps = Object.assign(props.organizationProfileProps || {}, { customPages }); + return ( + + ); + }, + 'OrganizationSwitcher', +); + +export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, { + OrganizationProfilePage, + OrganizationProfileLink, +}); export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( diff --git a/packages/react/src/errors.ts b/packages/react/src/errors.ts index 4e567a9742..0e881c3de3 100644 --- a/packages/react/src/errors.ts +++ b/packages/react/src/errors.ts @@ -27,7 +27,26 @@ export const multipleChildrenInButtonComponent = (name: string) => `Clerk: You've passed multiple children components to <${name}/>. You can only pass a single child component or text.`; export const invalidStateError = - 'Invalid state. Feel free to submit a bug or reach out to support here: https://clerk.com/support'; + 'Clerk: Invalid state. Feel free to submit a bug or reach out to support here: https://clerk.com/support'; export const unsupportedNonBrowserDomainOrProxyUrlFunction = - 'Unsupported usage of domain or proxyUrl. The usage of domain or proxyUrl as function is not supported in non-browser environments.'; + 'Clerk: Unsupported usage of domain or proxyUrl. The usage of domain or proxyUrl as function is not supported in non-browser environments.'; + +export const userProfilePageRenderedError = + 'Clerk: component needs to be a direct child of `` or ``.'; +export const userProfileLinkRenderedError = + 'Clerk: component needs to be a direct child of `` or ``.'; + +export const organizationProfilePageRenderedError = + 'Clerk: component needs to be a direct child of `` or ``.'; +export const organizationProfileLinkRenderedError = + 'Clerk: component needs to be a direct child of `` or ``.'; + +export const customPagesIgnoredComponent = (componentName: string) => + `Clerk: <${componentName} /> can only accept <${componentName}.Page /> and <${componentName}.Link /> as its children. Any other provided component will be ignored.`; + +export const customPageWrongProps = (componentName: string) => + `Clerk: Missing props. <${componentName}.Page /> component requires the following props: url, label, labelIcon, alongside with children to be rendered inside the page.`; + +export const customLinkWrongProps = (componentName: string) => + `Clerk: Missing props. <${componentName}.Link /> component requires the following props: url, label and labelIcon.`; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 8077d32d43..dd9bb60028 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -12,6 +12,7 @@ import type { SignUpRedirectOptions, UserResource, } from '@clerk/types'; +import type React from 'react'; declare global { interface Window { @@ -51,6 +52,7 @@ export interface MountProps { unmount: (node: HTMLDivElement) => void; updateProps: (props: any) => void; props?: any; + customPagesPortals?: any[]; } export interface HeadlessBrowserClerk extends Clerk { @@ -88,3 +90,18 @@ export type SignInWithMetamaskButtonProps = Pick JSX.Element; + mount: (node: Element) => void; + unmount: () => void; + id: number; +}; + +// This function takes a component as prop, and returns functions that mount and unmount +// the given component into a given node +export const useCustomElementPortal = (elements: UseCustomElementPortalParams[]) => { + const initialState = Array(elements.length).fill(null); + const [nodes, setNodes] = useState<(Element | null)[]>(initialState); + + return elements.map((el, index) => ({ + id: el.id, + mount: (node: Element) => setNodes(prevState => prevState.map((n, i) => (i === index ? node : n))), + unmount: () => setNodes(prevState => prevState.map((n, i) => (i === index ? null : n))), + portal: () => <>{nodes[index] ? createPortal(el.component, nodes[index] as Element) : null}, + })); +}; diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx new file mode 100644 index 0000000000..7037a4751c --- /dev/null +++ b/packages/react/src/utils/useCustomPages.tsx @@ -0,0 +1,169 @@ +import { logErrorInDevMode } from '@clerk/shared'; +import type { CustomPage } from '@clerk/types'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { + OrganizationProfileLink, + OrganizationProfilePage, + UserProfileLink, + UserProfilePage, +} from '../components/uiComponents'; +import { customLinkWrongProps, customPagesIgnoredComponent, customPageWrongProps } from '../errors'; +import type { UserProfilePageProps } from '../types'; +import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal'; +import { useCustomElementPortal } from './useCustomElementPortal'; + +const isThatComponent = (v: any, component: React.ReactNode): v is React.ReactNode => { + return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === component; +}; + +export const useUserProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => { + const reorderItemsLabels = ['account', 'security']; + return useCustomPages({ + children, + reorderItemsLabels, + LinkComponent: UserProfileLink, + PageComponent: UserProfilePage, + componentName: 'UserProfile', + }); +}; + +export const useOrganizationProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => { + const reorderItemsLabels = ['members', 'settings']; + return useCustomPages({ + children, + reorderItemsLabels, + LinkComponent: OrganizationProfileLink, + PageComponent: OrganizationProfilePage, + componentName: 'OrganizationProfile', + }); +}; + +type UseCustomPagesParams = { + children: React.ReactNode | React.ReactNode[]; + LinkComponent: any; + PageComponent: any; + reorderItemsLabels: string[]; + componentName: string; +}; + +type CustomPageWithIdType = UserProfilePageProps & { children?: React.ReactNode }; + +const useCustomPages = ({ + children, + LinkComponent, + PageComponent, + reorderItemsLabels, + componentName, +}: UseCustomPagesParams) => { + const validChildren: CustomPageWithIdType[] = []; + + React.Children.forEach(children, child => { + if (!isThatComponent(child, PageComponent) && !isThatComponent(child, LinkComponent)) { + if (child) { + logErrorInDevMode(customPagesIgnoredComponent(componentName)); + } + return; + } + + const { props } = child as ReactElement; + + const { children, label, url, labelIcon } = props; + + if (isThatComponent(child, PageComponent)) { + if (isReorderItem(props, reorderItemsLabels)) { + // This is a reordering item + validChildren.push({ label }); + } else if (isCustomPage(props)) { + // this is a custom page + validChildren.push({ label, labelIcon, children, url }); + } else { + logErrorInDevMode(customPageWrongProps(componentName)); + return; + } + } + + if (isThatComponent(child, LinkComponent)) { + if (isExternalLink(props)) { + // This is an external link + validChildren.push({ label, labelIcon, url }); + } else { + logErrorInDevMode(customLinkWrongProps(componentName)); + return; + } + } + }); + + const customPageContents: UseCustomElementPortalParams[] = []; + const customPageLabelIcons: UseCustomElementPortalParams[] = []; + const customLinkLabelIcons: UseCustomElementPortalParams[] = []; + + validChildren.forEach((cp, index) => { + if (isCustomPage(cp)) { + customPageContents.push({ component: cp.children, id: index }); + customPageLabelIcons.push({ component: cp.labelIcon, id: index }); + return; + } + if (isExternalLink(cp)) { + customLinkLabelIcons.push({ component: cp.labelIcon, id: index }); + } + }); + + const customPageContentsPortals = useCustomElementPortal(customPageContents); + const customPageLabelIconsPortals = useCustomElementPortal(customPageLabelIcons); + const customLinkLabelIconsPortals = useCustomElementPortal(customLinkLabelIcons); + + const customPages: CustomPage[] = []; + const customPagesPortals: React.ComponentType[] = []; + + validChildren.forEach((cp, index) => { + if (isReorderItem(cp, reorderItemsLabels)) { + customPages.push({ label: cp.label }); + return; + } + if (isCustomPage(cp)) { + const { + portal: contentPortal, + mount, + unmount, + } = customPageContentsPortals.find(p => p.id === index) as UseCustomElementPortalReturn; + const { + portal: labelPortal, + mount: mountIcon, + unmount: unmountIcon, + } = customPageLabelIconsPortals.find(p => p.id === index) as UseCustomElementPortalReturn; + customPages.push({ label: cp.label, url: cp.url, mount, unmount, mountIcon, unmountIcon }); + customPagesPortals.push(contentPortal); + customPagesPortals.push(labelPortal); + return; + } + if (isExternalLink(cp)) { + const { + portal: labelPortal, + mount: mountIcon, + unmount: unmountIcon, + } = customLinkLabelIconsPortals.find(p => p.id === index) as UseCustomElementPortalReturn; + customPages.push({ label: cp.label, url: cp.url, mountIcon, unmountIcon }); + customPagesPortals.push(labelPortal); + return; + } + }); + + return { customPages, customPagesPortals }; +}; + +const isReorderItem = (childProps: any, validItems: string[]): boolean => { + const { children, label, url, labelIcon } = childProps; + return !children && !url && !labelIcon && validItems.some(v => v === label); +}; + +const isCustomPage = (childProps: any): boolean => { + const { children, label, url, labelIcon } = childProps; + return !!children && !!url && !!labelIcon && !!label; +}; + +const isExternalLink = (childProps: any): boolean => { + const { children, label, url, labelIcon } = childProps; + return !children && !!url && !!labelIcon && !!label; +}; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 7e6dcec81b..8838c7ed4b 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -3,3 +3,4 @@ export { isStaging } from './instance'; export { noop } from './noop'; export * from './runtimeEnvironment'; export * from './runWithExponentialBackOff'; +export { logErrorInDevMode } from './logErrorInDevMode'; diff --git a/packages/shared/src/utils/logErrorInDevMode.ts b/packages/shared/src/utils/logErrorInDevMode.ts new file mode 100644 index 0000000000..ea299ba832 --- /dev/null +++ b/packages/shared/src/utils/logErrorInDevMode.ts @@ -0,0 +1,7 @@ +import { isDevelopmentEnvironment } from './runtimeEnvironment'; + +export const logErrorInDevMode = (message: string) => { + if (isDevelopmentEnvironment()) { + console.error(message); + } +}; diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 657cd1cb4f..de45441929 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -94,8 +94,6 @@ export type ProfileSectionId = | 'organizationDomains'; export type ProfilePageId = 'account' | 'security' | 'organizationSettings' | 'organizationMembers'; -export type NavbarItemId = 'account' | 'security' | 'members' | 'settings'; - export type UserPreviewId = 'userButton' | 'personalWorkspace'; export type OrganizationPreviewId = 'organizationSwitcher' | 'organizationList'; @@ -391,8 +389,8 @@ export type ElementsConfig = { navbar: WithOptions; navbarButtons: WithOptions; - navbarButton: WithOptions; - navbarButtonIcon: WithOptions; + navbarButton: WithOptions; + navbarButtonIcon: WithOptions; navbarMobileMenuRow: WithOptions; navbarMobileMenuButton: WithOptions; navbarMobileMenuButtonIcon: WithOptions; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 004b0aea7c..044d1b936a 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -10,6 +10,7 @@ import type { UserProfileTheme, } from './appearance'; import type { ClientResource } from './client'; +import type { CustomPage } from './customPages'; import type { DisplayThemeJSON } from './json'; import type { LocalizationResource } from './localization'; import type { OAuthProvider, OAuthScope } from './oauth'; @@ -721,6 +722,10 @@ export type UserProfileProps = { * e.g. */ additionalOAuthScopes?: Partial>; + /* + * Provide custom pages and links to be rendered inside the UserProfile. + */ + customPages?: CustomPage[]; }; export type OrganizationProfileProps = { @@ -743,6 +748,10 @@ export type OrganizationProfileProps = { * prop of ClerkProvided (if one is provided) */ appearance?: OrganizationProfileTheme; + /* + * Provide custom pages and links to be rendered inside the OrganizationProfile. + */ + customPages?: CustomPage[]; }; export type CreateOrganizationProps = { @@ -831,7 +840,7 @@ export type UserButtonProps = { * Specify options for the underlying component. * e.g. */ - userProfileProps?: Pick; + userProfileProps?: Pick; }; type PrimitiveKeys = { @@ -921,7 +930,7 @@ export type OrganizationSwitcherProps = { * Specify options for the underlying component. * e.g. */ - organizationProfileProps?: Pick; + organizationProfileProps?: Pick; }; export type OrganizationListProps = { diff --git a/packages/types/src/customPages.ts b/packages/types/src/customPages.ts new file mode 100644 index 0000000000..e21b710d2b --- /dev/null +++ b/packages/types/src/customPages.ts @@ -0,0 +1,8 @@ +export type CustomPage = { + label: string; + url?: string; + mountIcon?: (el: HTMLDivElement) => void; + unmountIcon?: (el?: HTMLDivElement) => void; + mount?: (el: HTMLDivElement) => void; + unmount?: (el?: HTMLDivElement) => void; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a83ebbf2e0..de6d253f69 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -49,3 +49,4 @@ export * from './utils'; export * from './verification'; export * from './web3'; export * from './web3Wallet'; +export * from './customPages';