From c731a72a0ac29aec76817707af63ed3d8c3c8ea9 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Thu, 24 Aug 2023 16:58:01 +0300 Subject: [PATCH 01/16] feat(clerk-js,clerk-react,types): Introduce Custom Pages in UserProfile --- .changeset/proud-ways-lie.md | 7 + .../UserProfile/UserProfileNavbar.tsx | 34 +- .../UserProfile/UserProfileRoutes.tsx | 186 +++++++---- .../__tests__/UserProfile.test.tsx | 37 ++- .../ui/contexts/ClerkUIComponentsContext.tsx | 18 +- packages/clerk-js/src/ui/elements/Navbar.tsx | 23 +- .../src/ui/utils/ExternalElementMounter.tsx | 25 ++ .../utils/__tests__/createCustomPages.test.ts | 312 ++++++++++++++++++ .../src/ui/utils/createCustomPages.tsx | 217 ++++++++++++ packages/clerk-js/src/ui/utils/index.ts | 2 + .../react/src/components/uiComponents.tsx | 60 +++- packages/react/src/errors.ts | 14 + packages/react/src/types.ts | 14 + packages/react/src/utils/index.ts | 1 + .../src/utils/useCustomElementPortal.tsx | 24 ++ packages/react/src/utils/useCustomPages.tsx | 98 ++++++ packages/types/src/appearance.ts | 6 +- packages/types/src/clerk.ts | 13 +- packages/types/src/customPages.ts | 8 + packages/types/src/index.ts | 1 + 20 files changed, 976 insertions(+), 124 deletions(-) create mode 100644 .changeset/proud-ways-lie.md 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/types/src/customPages.ts diff --git a/.changeset/proud-ways-lie.md b/.changeset/proud-ways-lie.md new file mode 100644 index 00000000000..68c3dba82e4 --- /dev/null +++ b/.changeset/proud-ways-lie.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Introduce Custom Pages in UserProfile diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx index 08bf8ac6e67..4b491a22bad 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx @@ -1,33 +1,18 @@ 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: '', - }, -]; +import { pageToRootNavbarRouteMap } from '../../utils'; export const UserProfileNavbar = ( props: React.PropsWithChildren, 'contentRef'>>, ) => { + const { pages } = useUserProfileContext(); return ( {props.children} @@ -35,17 +20,6 @@ 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'>) => { return ( ) => { + const { pages } = useUserProfileContext(); return ( - - - - - - - - - - + + {/* Custom Pages */} + {pages.contents?.map((customPage, index) => ( + + - - + ))} + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + - - + + + + + + + + + - - + + + + + + + + + - - - - - - + + - - + {/**/} + + - - - - - - + + - + - - - - - - {/**/} - - - - {/**/} - - - + + ); }; 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 9f6ca54a566..ed6b04ce07c 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 @@ -2,9 +2,10 @@ import { describe, it } from '@jest/globals'; import React from 'react'; import { bindCreateFixtures, render, screen } from '../../../../testUtils'; +import type { CustomPage } from '../../../utils'; 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/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 07ef3640e9c..d9234d7b352 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 { createCustomPages } from '../utils'; const populateParamFromObject = createDynamicParamParser({ regex: /:(\w+)/ }); @@ -184,24 +187,31 @@ export const useSignInContext = (): SignInContextType => { }; }; +type PagesType = { + routes: NavbarRoute[]; + contents: CustomPageContent[]; + isAccountPageRoot: boolean; +}; + 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 = useMemo(() => createCustomPages(customPages || []), [customPages]); + return { ...ctx, + pages, componentName, queryParams, authQueryString: '', diff --git a/packages/clerk-js/src/ui/elements/Navbar.tsx b/packages/clerk-js/src/ui/elements/Navbar.tsx index 4f348f2c4db..0148b538c25 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 00000000000..e04299d3c5c --- /dev/null +++ b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx @@ -0,0 +1,25 @@ +import { useEffect, useRef } from 'react'; + +type ExternalElementMounterProps = { + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +export const ExternalElementMounter = ({ mount, unmount }: 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 00000000000..1eeb232ac38 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts @@ -0,0 +1,312 @@ +import type { CustomPage } from '../createCustomPages'; +import { createCustomPages } from '../createCustomPages'; + +describe('createCustomPages', () => { + it('should return the default pages if no custom pages are passed', () => { + const { routes, contents, isAccountPageRoot } = createCustomPages([]); + expect(routes.length).toEqual(2); + expect(routes[0].id).toEqual('account'); + expect(routes[1].id).toEqual('security'); + expect(contents.length).toEqual(0); + expect(isAccountPageRoot).toEqual(true); + }); + + 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, isAccountPageRoot } = createCustomPages(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'); + expect(isAccountPageRoot).toEqual(true); + }); + + 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, isAccountPageRoot } = createCustomPages(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'); + expect(isAccountPageRoot).toEqual(false); + }); + + 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 } = createCustomPages(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 } = createCustomPages(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 } = createCustomPages(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 } = createCustomPages(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(() => createCustomPages(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, isAccountPageRoot } = createCustomPages(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'); + expect(isAccountPageRoot).toEqual(true); + }); + + 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 } = createCustomPages(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 } = createCustomPages(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(() => createCustomPages(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 00000000000..07d419f0a37 --- /dev/null +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -0,0 +1,217 @@ +import { isDevelopmentEnvironment } from '@clerk/shared'; +import type { CustomPage } from '@clerk/types'; + +import { isValidUrl } from '../../utils'; +import type { NavbarRoute } from '../elements'; +import { TickShield, User } from '../icons'; +import { localizationKeys } from '../localization'; +import { ExternalElementMounter } from './ExternalElementMounter'; + +const CLERK_ACCOUNT_ROUTE: NavbarRoute = { + name: localizationKeys('userProfile.start.headerTitle__account'), + id: 'account', + icon: User, + path: 'account', +}; + +const CLERK_SECURITY_ROUTE: NavbarRoute = { + name: localizationKeys('userProfile.start.headerTitle__security'), + id: 'security', + icon: TickShield, + path: 'account', +}; + +export const pageToRootNavbarRouteMap = { + profile: CLERK_ACCOUNT_ROUTE, + 'email-address': CLERK_ACCOUNT_ROUTE, + 'phone-number': CLERK_ACCOUNT_ROUTE, + 'connected-account': CLERK_ACCOUNT_ROUTE, + 'web3-wallet': CLERK_ACCOUNT_ROUTE, + username: CLERK_ACCOUNT_ROUTE, + 'multi-factor': CLERK_SECURITY_ROUTE, + password: CLERK_SECURITY_ROUTE, +}; + +export type CustomPageContent = { + url: string; + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +type UserProfileReorderItem = { + label: 'account' | 'security'; +}; + +type UserProfileCustomPage = { + label: string; + url: string; + mountIcon: (el: HTMLDivElement) => void; + unmountIcon: (el?: HTMLDivElement) => void; + mount: (el: HTMLDivElement) => void; + unmount: (el?: HTMLDivElement) => void; +}; + +type UserProfileCustomLink = { + label: string; + url: string; + mountIcon: (el: HTMLDivElement) => void; + unmountIcon: (el?: HTMLDivElement) => void; +}; + +export const createCustomPages = (customPages: CustomPage[]) => { + if (isDevelopmentEnvironment()) { + checkForDuplicateUsageOfReorderingItems(customPages); + } + + const validCustomPages = customPages.filter(cp => { + if (!isValidPageItem(cp)) { + if (isDevelopmentEnvironment()) { + console.error('Invalid custom page data: ', cp); + } + return false; + } + return true; + }); + + const { allRoutes, contents } = getRoutesAndContents(validCustomPages); + + assertExternalLinkAsRoot(allRoutes); + + const routes = setFirstPathToRoot(allRoutes); + + if (isDevelopmentEnvironment()) { + warnForDuplicatePaths(routes); + } + + return { routes, contents, isAccountPageRoot: routes[0].id === 'account' || routes[0].id === 'security' }; +}; + +const getRoutesAndContents = (customPages: CustomPage[]) => { + let clerkDefaultRoutes: NavbarRoute[] = [{ ...CLERK_ACCOUNT_ROUTE }, { ...CLERK_SECURITY_ROUTE }]; + const CLERK_ROUTES = { + account: CLERK_ACCOUNT_ROUTE, + security: CLERK_SECURITY_ROUTE, + }; + const contents: CustomPageContent[] = []; + + const routesWithoutDefaults: NavbarRoute[] = customPages.map((cp, index) => { + if (isCustomLink(cp)) { + return { + name: cp.label, + id: `custom-page-${index}`, + icon: () => ( + + ), + 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: () => ( + + ), + path: pageURL, + }; + } + const reorderItem = CLERK_ROUTES[cp.label as 'account' | 'security']; + clerkDefaultRoutes = clerkDefaultRoutes.filter(({ id }) => id !== cp.label); + return { ...reorderItem }; + }); + + const allRoutes = [...clerkDefaultRoutes, ...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 setFirstPathToRoot = (routes: 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 checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[]) => { + const reorderItems = customPages.filter(cp => isAccountReorderItem(cp) || isSecurityReorderItem(cp)); + reorderItems.reduce((acc, cp) => { + if (acc.includes(cp.label)) { + console.error( + `The "${cp.label}" item is used more than once when reordering UserProfile pages. This may cause unexpected behavior.`, + ); + } + return [...acc, cp.label]; + }, [] as string[]); +}; + +const warnForDuplicatePaths = (routes: NavbarRoute[]) => { + const paths = routes + .filter(({ external, path }) => !external && path !== '/' && path !== 'account') + .map(({ path }) => path); + const duplicatePaths = paths.filter((p, index) => paths.indexOf(p) !== index); + duplicatePaths.forEach(p => { + console.error(`Duplicate path "${p}" found in custom pages. This may cause unexpected behavior.`); + }); +}; + +const isValidPageItem = (cp: CustomPage): cp is CustomPage => { + return isCustomPage(cp) || isCustomLink(cp) || isAccountReorderItem(cp) || isSecurityReorderItem(cp); +}; + +const isCustomPage = (cp: CustomPage): cp is UserProfileCustomPage => { + return !!cp.url && !!cp.label && !!cp.mount && !!cp.unmount && !!cp.mountIcon && !!cp.unmountIcon; +}; + +const isCustomLink = (cp: CustomPage): cp is UserProfileCustomLink => { + return !!cp.url && !!cp.label && !cp.mount && !cp.unmount && !!cp.mountIcon && !!cp.unmountIcon; +}; + +const isAccountReorderItem = (cp: CustomPage): cp is UserProfileReorderItem => { + return !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && cp.label === 'account'; +}; + +const isSecurityReorderItem = (cp: CustomPage): cp is UserProfileReorderItem => { + return !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && cp.label === 'security'; +}; + +const sanitizeCustomPageURL = (url: string): string => { + if (!url) { + throw new Error('URL is required for custom pages'); + } + if (isValidUrl(url)) { + throw new Error('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('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('The first route cannot be a component'); + } +}; diff --git a/packages/clerk-js/src/ui/utils/index.ts b/packages/clerk-js/src/ui/utils/index.ts index a86856b90ed..6c88d4aa3ee 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/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index ca9af9043dd..6185f1605be 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -1,3 +1,4 @@ +import { isDevelopmentEnvironment } from '@clerk/shared'; import type { CreateOrganizationProps, OrganizationListProps, @@ -8,9 +9,12 @@ 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 { userProfileLinkRenderedError, userProfilePageRenderedError } from '../errors'; +import type { MountProps, UserProfileLinkProps, UserProfilePageProps, WithClerkProp } from '../types'; +import { useCustomPages } from '../utils/useCustomPages'; import { withClerk } from './withClerk'; // README: should be a class pure component in order for mount and unmount @@ -63,7 +67,12 @@ class Portal extends React.PureComponent { } render() { - return
; + return ( + <> +
+ {this.props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} + + ); } } @@ -89,28 +98,65 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp ); }, 'SignUp'); -export const UserProfile = withClerk(({ clerk, ...props }: WithClerkProp) => { +export function UserProfilePage({ children }: PropsWithChildren) { + if (isDevelopmentEnvironment()) { + console.error(userProfilePageRenderedError); + } + return
{children}
; +} + +export function UserProfileLink({ children }: PropsWithChildren) { + if (isDevelopmentEnvironment()) { + console.error(userProfileLinkRenderedError); + } + return
{children}
; +} + +const _UserProfile = withClerk(({ clerk, ...props }: WithClerkProp>) => { + const { customPages, customPagesPortals } = useCustomPages(props.children); return ( ); }, 'UserProfile'); -export const UserButton = withClerk(({ clerk, ...props }: WithClerkProp) => { +type UserProfileExportType = typeof _UserProfile & { + Page: ({ children }: PropsWithChildren) => React.JSX.Element; + Link: ({ children }: PropsWithChildren) => React.JSX.Element; +}; +export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, { + Page: UserProfilePage, + Link: UserProfileLink, +}); + +const _UserButton = withClerk(({ clerk, ...props }: WithClerkProp>) => { + const { customPages, customPagesPortals } = useCustomPages(props.children); + const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages }); return ( ); }, 'UserButton'); +type UserButtonExportType = typeof _UserButton & { + UserProfilePage: ({ children }: PropsWithChildren) => React.JSX.Element; + UserProfileLink: ({ children }: PropsWithChildren) => React.JSX.Element; +}; +export const UserButton: UserButtonExportType = Object.assign(_UserButton, { + UserProfilePage: UserProfilePage, + UserProfileLink: UserProfileLink, +}); + export const OrganizationProfile = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( component needs to be a direct child of `` or ``.'; +export const userProfileLinkRenderedError = + ' component needs to be a direct child of `` or ``.'; + +export const customPagesIngoredComponent = + ' can only accept and as its children. Any other provided component will be ignored.'; + +export const userProfilePageWrongProps = + 'Missing props. component requires the following props: url, label, labelIcon, alongside with children to be rendered inside the page.'; + +export const userProfileLinkWrongProps = + 'Missing props. component requires the following props: url, label and labelIcon.'; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 8077d32d434..0b5485fe4dd 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,15 @@ export type SignInWithMetamaskButtonProps = Pick { + const [node, setNode] = useState(null); + + const mount = (node: Element) => { + setNode(node); + }; + const unmount = () => { + setNode(null); + }; + + // If mount has been called, CustomElementPortal returns a portal that renders `component` + // into the passed node + + // Otherwise, CustomElementPortal returns nothing + const CustomElementPortal = () => <>{node ? createPortal(component, node) : null}; + + return { CustomElementPortal, mount, unmount }; +}; diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx new file mode 100644 index 00000000000..a1ec050b14c --- /dev/null +++ b/packages/react/src/utils/useCustomPages.tsx @@ -0,0 +1,98 @@ +import { isDevelopmentEnvironment } from '@clerk/shared'; +import type { CustomPage } from '@clerk/types'; +import type { ReactElement } from 'react'; +import React from 'react'; + +import { UserProfileLink, UserProfilePage } from '../components/uiComponents'; +import { customPagesIngoredComponent, userProfileLinkWrongProps, userProfilePageWrongProps } from '../errors'; +import { useCustomElementPortal } from './useCustomElementPortal'; + +const errorInDevMode = (message: string) => { + if (isDevelopmentEnvironment()) { + console.error(message); + } +}; + +const isPageComponent = (v: any): v is React.ReactNode => { + return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === UserProfilePage; +}; + +const isLinkComponent = (v: any): v is React.ReactNode => { + return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === UserProfileLink; +}; + +export const useCustomPages = (userProfileChildren: React.ReactNode | React.ReactNode[]) => { + const customPages: CustomPage[] = []; + const customPagesPortals: React.ComponentType[] = []; + React.Children.forEach(userProfileChildren, child => { + if (!isPageComponent(child) && !isLinkComponent(child)) { + errorInDevMode(customPagesIngoredComponent); + return; + } + + const { props } = child as ReactElement; + + const { children, label, url, labelIcon } = props; + + if (isPageComponent(child)) { + if (isReorderItem(props)) { + // This is a reordering item + customPages.push({ label }); + } else if (isCustomPage(props)) { + // this is a custom page + const { CustomElementPortal, mount, unmount } = useCustomElementPortal(children); + const { + CustomElementPortal: labelPortal, + mount: mountIcon, + unmount: unmountIcon, + } = useCustomElementPortal(labelIcon); + customPages.push({ + url, + label, + mountIcon, + unmountIcon, + mount, + unmount, + }); + customPagesPortals.push(CustomElementPortal); + customPagesPortals.push(labelPortal); + } else { + errorInDevMode(userProfilePageWrongProps); + return; + } + } + + if (isLinkComponent(child)) { + if (isExternalLink(props)) { + // This is an external link + const { + CustomElementPortal: labelPortal, + mount: mountIcon, + unmount: unmountIcon, + } = useCustomElementPortal(labelIcon); + customPages.push({ label, url, mountIcon, unmountIcon }); + customPagesPortals.push(labelPortal); + } else { + errorInDevMode(userProfileLinkWrongProps); + return; + } + } + }); + + return { customPages, customPagesPortals }; +}; + +const isReorderItem = (childProps: any): boolean => { + const { children, label, url, labelIcon } = childProps; + return !children && !url && !labelIcon && (label === 'account' || label === 'security'); +}; + +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/types/src/appearance.ts b/packages/types/src/appearance.ts index 657cd1cb4f7..de454419291 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 004b0aea7c8..3ca594d57e9 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,16 @@ export type UserProfileProps = { * e.g. */ additionalOAuthScopes?: Partial>; + /* + * Provide addition custom route items and pages to be rendered inside the UserProfile. + * e.g. + * + * C
}> + *
Hello from custom page!
+ * + * + */ + customPages?: CustomPage[]; }; export type OrganizationProfileProps = { @@ -831,7 +842,7 @@ export type UserButtonProps = { * Specify options for the underlying component. * e.g. */ - userProfileProps?: Pick; + userProfileProps?: Pick; }; type PrimitiveKeys = { diff --git a/packages/types/src/customPages.ts b/packages/types/src/customPages.ts new file mode 100644 index 00000000000..e21b710d2bd --- /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 a83ebbf2e02..de6d253f698 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'; From b5d5e944261d92dd44eb3d00fede1d7e1ab12d2b Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Mon, 2 Oct 2023 19:14:55 +0300 Subject: [PATCH 02/16] fix(clerk-js): Fix top-level `localizationKeys` evaluation --- .../UserProfile/UserProfileNavbar.tsx | 4 +- .../ui/contexts/ClerkUIComponentsContext.tsx | 1 + .../src/ui/utils/createCustomPages.tsx | 88 +++++++++++-------- 3 files changed, 55 insertions(+), 38 deletions(-) diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx index 4b491a22bad..0b05702a4e7 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileNavbar.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { useUserProfileContext } from '../../contexts'; import { Breadcrumbs, NavBar, NavbarContextProvider } from '../../elements'; import type { PropsOfComponent } from '../../styledSystem'; -import { pageToRootNavbarRouteMap } from '../../utils'; export const UserProfileNavbar = ( props: React.PropsWithChildren, 'contentRef'>>, @@ -21,10 +20,11 @@ export const UserProfileNavbar = ( }; export const UserProfileBreadcrumbs = (props: Pick, 'title'>) => { + const { pages } = useUserProfileContext(); return ( ); }; diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index d9234d7b352..ce3b93d7fea 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -191,6 +191,7 @@ type PagesType = { routes: NavbarRoute[]; contents: CustomPageContent[]; isAccountPageRoot: boolean; + pageToRootNavbarRouteMap: Record; }; export type UserProfileContextType = UserProfileCtx & { diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index 07d419f0a37..15ece3dc4f4 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -7,31 +7,6 @@ import { TickShield, User } from '../icons'; import { localizationKeys } from '../localization'; import { ExternalElementMounter } from './ExternalElementMounter'; -const CLERK_ACCOUNT_ROUTE: NavbarRoute = { - name: localizationKeys('userProfile.start.headerTitle__account'), - id: 'account', - icon: User, - path: 'account', -}; - -const CLERK_SECURITY_ROUTE: NavbarRoute = { - name: localizationKeys('userProfile.start.headerTitle__security'), - id: 'security', - icon: TickShield, - path: 'account', -}; - -export const pageToRootNavbarRouteMap = { - profile: CLERK_ACCOUNT_ROUTE, - 'email-address': CLERK_ACCOUNT_ROUTE, - 'phone-number': CLERK_ACCOUNT_ROUTE, - 'connected-account': CLERK_ACCOUNT_ROUTE, - 'web3-wallet': CLERK_ACCOUNT_ROUTE, - username: CLERK_ACCOUNT_ROUTE, - 'multi-factor': CLERK_SECURITY_ROUTE, - password: CLERK_SECURITY_ROUTE, -}; - export type CustomPageContent = { url: string; mount: (el: HTMLDivElement) => void; @@ -73,7 +48,12 @@ export const createCustomPages = (customPages: CustomPage[]) => { return true; }); - const { allRoutes, contents } = getRoutesAndContents(validCustomPages); + const { USER_PROFILE_ROUTES, pageToRootNavbarRouteMap } = getUserProfileDefaultRoutes(); + + const { allRoutes, contents } = getRoutesAndContents({ + customPages: validCustomPages, + defaultRoutes: USER_PROFILE_ROUTES, + }); assertExternalLinkAsRoot(allRoutes); @@ -83,15 +63,21 @@ export const createCustomPages = (customPages: CustomPage[]) => { warnForDuplicatePaths(routes); } - return { routes, contents, isAccountPageRoot: routes[0].id === 'account' || routes[0].id === 'security' }; + return { + routes, + contents, + isAccountPageRoot: routes[0].id === 'account' || routes[0].id === 'security', + pageToRootNavbarRouteMap, + }; }; -const getRoutesAndContents = (customPages: CustomPage[]) => { - let clerkDefaultRoutes: NavbarRoute[] = [{ ...CLERK_ACCOUNT_ROUTE }, { ...CLERK_SECURITY_ROUTE }]; - const CLERK_ROUTES = { - account: CLERK_ACCOUNT_ROUTE, - security: CLERK_SECURITY_ROUTE, - }; +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) => { @@ -124,12 +110,12 @@ const getRoutesAndContents = (customPages: CustomPage[]) => { path: pageURL, }; } - const reorderItem = CLERK_ROUTES[cp.label as 'account' | 'security']; - clerkDefaultRoutes = clerkDefaultRoutes.filter(({ id }) => id !== cp.label); + const reorderItem = defaultRoutes.find(r => r.id === cp.label) as NavbarRoute; + remainingDefaultRoutes = remainingDefaultRoutes.filter(({ id }) => id !== cp.label); return { ...reorderItem }; }); - const allRoutes = [...clerkDefaultRoutes, ...routesWithoutDefaults]; + const allRoutes = [...remainingDefaultRoutes, ...routesWithoutDefaults]; return { allRoutes, contents }; }; @@ -215,3 +201,33 @@ const assertExternalLinkAsRoot = (routes: NavbarRoute[]) => { throw new Error('The first route cannot be a component'); } }; + +const getUserProfileDefaultRoutes = () => { + const USER_PROFILE_ROUTES: NavbarRoute[] = [ + { + name: localizationKeys('userProfile.start.headerTitle__account'), + id: 'account', + icon: User, + path: 'account', + }, + { + name: localizationKeys('userProfile.start.headerTitle__security'), + id: 'security', + icon: TickShield, + path: 'account', + }, + ]; + + const pageToRootNavbarRouteMap = { + profile: USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'email-address': USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'phone-number': USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'connected-account': USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'web3-wallet': USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, + username: USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'multi-factor': USER_PROFILE_ROUTES.find(r => r.id === 'security') as NavbarRoute, + password: USER_PROFILE_ROUTES.find(r => r.id === 'security') as NavbarRoute, + }; + + return { USER_PROFILE_ROUTES, pageToRootNavbarRouteMap }; +}; From ee3d7354d615c7d2277203b4b45e73708826eaf3 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Tue, 3 Oct 2023 00:27:07 +0300 Subject: [PATCH 03/16] fix(clerk-react): Fix issue with useCustomPages when making changes on the custom pages in dev --- .../react/src/components/uiComponents.tsx | 5 +- .../src/utils/useCustomElementPortal.tsx | 40 +++++--- packages/react/src/utils/useCustomPages.tsx | 94 +++++++++++++------ 3 files changed, 97 insertions(+), 42 deletions(-) diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 6185f1605be..78c540d506e 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -49,7 +49,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 }); } } diff --git a/packages/react/src/utils/useCustomElementPortal.tsx b/packages/react/src/utils/useCustomElementPortal.tsx index 5d031e7c8cb..147dd2d8e04 100644 --- a/packages/react/src/utils/useCustomElementPortal.tsx +++ b/packages/react/src/utils/useCustomElementPortal.tsx @@ -1,24 +1,36 @@ import React, { useState } from 'react'; import { createPortal } from 'react-dom'; +export type UseCustomElementPortalParams = { + component: React.ReactNode; + id: number; +}; + +export type UseCustomElementPortalReturn = { + portal: () => 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 [nodes, setNodes] = useState<(Element | null)[]>(Array(elements.length).fill(null)); -export const useCustomElementPortal = (component: JSX.Element) => { - const [node, setNode] = useState(null); - - const mount = (node: Element) => { - setNode(node); - }; - const unmount = () => { - setNode(null); - }; + const portals: UseCustomElementPortalReturn[] = []; - // If mount has been called, CustomElementPortal returns a portal that renders `component` - // into the passed node + elements.forEach((el, index) => { + const mount = (node: Element) => { + setNodes(prevState => prevState.map((n, i) => (i === index ? node : n))); + }; + const unmount = () => { + setNodes(prevState => prevState.map((n, i) => (i === index ? null : n))); + }; - // Otherwise, CustomElementPortal returns nothing - const CustomElementPortal = () => <>{node ? createPortal(component, node) : null}; + const portal = () => <>{nodes[index] ? createPortal(el.component, nodes[index] as Element) : null}; + portals.push({ portal, mount, unmount, id: el.id }); + }); - return { CustomElementPortal, mount, unmount }; + return portals; }; diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx index a1ec050b14c..1b6096d519c 100644 --- a/packages/react/src/utils/useCustomPages.tsx +++ b/packages/react/src/utils/useCustomPages.tsx @@ -5,6 +5,8 @@ import React from 'react'; import { UserProfileLink, UserProfilePage } from '../components/uiComponents'; import { customPagesIngoredComponent, userProfileLinkWrongProps, userProfilePageWrongProps } from '../errors'; +import type { UserProfilePageProps } from '../types'; +import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal'; import { useCustomElementPortal } from './useCustomElementPortal'; const errorInDevMode = (message: string) => { @@ -21,12 +23,16 @@ const isLinkComponent = (v: any): v is React.ReactNode => { return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === UserProfileLink; }; +type CustomPageWithIdType = UserProfilePageProps & { children?: React.ReactNode }; + export const useCustomPages = (userProfileChildren: React.ReactNode | React.ReactNode[]) => { - const customPages: CustomPage[] = []; - const customPagesPortals: React.ComponentType[] = []; + const validUserProfileChildren: CustomPageWithIdType[] = []; + React.Children.forEach(userProfileChildren, child => { if (!isPageComponent(child) && !isLinkComponent(child)) { - errorInDevMode(customPagesIngoredComponent); + if (child) { + errorInDevMode(customPagesIngoredComponent); + } return; } @@ -37,25 +43,10 @@ export const useCustomPages = (userProfileChildren: React.ReactNode | React.Reac if (isPageComponent(child)) { if (isReorderItem(props)) { // This is a reordering item - customPages.push({ label }); + validUserProfileChildren.push({ label }); } else if (isCustomPage(props)) { // this is a custom page - const { CustomElementPortal, mount, unmount } = useCustomElementPortal(children); - const { - CustomElementPortal: labelPortal, - mount: mountIcon, - unmount: unmountIcon, - } = useCustomElementPortal(labelIcon); - customPages.push({ - url, - label, - mountIcon, - unmountIcon, - mount, - unmount, - }); - customPagesPortals.push(CustomElementPortal); - customPagesPortals.push(labelPortal); + validUserProfileChildren.push({ label, labelIcon, children, url }); } else { errorInDevMode(userProfilePageWrongProps); return; @@ -65,13 +56,7 @@ export const useCustomPages = (userProfileChildren: React.ReactNode | React.Reac if (isLinkComponent(child)) { if (isExternalLink(props)) { // This is an external link - const { - CustomElementPortal: labelPortal, - mount: mountIcon, - unmount: unmountIcon, - } = useCustomElementPortal(labelIcon); - customPages.push({ label, url, mountIcon, unmountIcon }); - customPagesPortals.push(labelPortal); + validUserProfileChildren.push({ label, labelIcon, url }); } else { errorInDevMode(userProfileLinkWrongProps); return; @@ -79,6 +64,61 @@ export const useCustomPages = (userProfileChildren: React.ReactNode | React.Reac } }); + const customPageContents: UseCustomElementPortalParams[] = []; + const customPageLabelIcons: UseCustomElementPortalParams[] = []; + const customLinkLabelIcons: UseCustomElementPortalParams[] = []; + + validUserProfileChildren.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[] = []; + + validUserProfileChildren.forEach((cp, index) => { + if (isReorderItem(cp)) { + 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 }; }; From 02c870c213717486c3ed1d269d72f746ca710061 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Tue, 3 Oct 2023 18:19:35 +0300 Subject: [PATCH 04/16] chore(clerk-js): Update bundlewatch.config.json --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 4703390f128..9ff28763793 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" }, From 0bd423c9218888bd67016d4ce72f5ca6919d49b9 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Wed, 4 Oct 2023 01:31:41 +0300 Subject: [PATCH 05/16] fix(clerk-react): Fix issue when changing the custom pages length dynamically --- packages/react/src/utils/useCustomElementPortal.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react/src/utils/useCustomElementPortal.tsx b/packages/react/src/utils/useCustomElementPortal.tsx index 147dd2d8e04..2a584c2c180 100644 --- a/packages/react/src/utils/useCustomElementPortal.tsx +++ b/packages/react/src/utils/useCustomElementPortal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; export type UseCustomElementPortalParams = { @@ -16,7 +16,11 @@ export type UseCustomElementPortalReturn = { // 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 [nodes, setNodes] = useState<(Element | null)[]>(Array(elements.length).fill(null)); + const [nodes, setNodes] = useState<(Element | null)[]>([]); + + useEffect(() => { + setNodes(Array(elements.length).fill(null)); + }, [elements.length]); const portals: UseCustomElementPortalReturn[] = []; From 94d18d2c6c85def1b72f9957b9e8910957f354d0 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Thu, 5 Oct 2023 12:12:48 +0300 Subject: [PATCH 06/16] feat(clerk-react,clerk-js): Add support for custom pages in OrganizationProfile --- .changeset/proud-ways-lie.md | 2 +- .../OrganizationProfileNavbar.tsx | 34 +---- .../OrganizationProfileRoutes.tsx | 128 +++++++++------- .../UserProfile/UserProfileRoutes.tsx | 9 +- .../ui/contexts/ClerkUIComponentsContext.tsx | 17 ++- .../src/ui/utils/ExternalElementMounter.tsx | 8 +- .../utils/__tests__/createCustomPages.test.ts | 35 ++--- .../src/ui/utils/createCustomPages.tsx | 140 +++++++++++++----- .../react/src/components/uiComponents.tsx | 105 +++++++++---- packages/react/src/errors.ts | 17 ++- packages/react/src/types.ts | 3 + packages/react/src/utils/index.ts | 1 + .../src/utils/useCustomElementPortal.tsx | 9 +- packages/react/src/utils/useCustomPages.tsx | 87 +++++++---- packages/types/src/clerk.ts | 12 +- 15 files changed, 393 insertions(+), 214 deletions(-) diff --git a/.changeset/proud-ways-lie.md b/.changeset/proud-ways-lie.md index 68c3dba82e4..a659759959b 100644 --- a/.changeset/proud-ways-lie.md +++ b/.changeset/proud-ways-lie.md @@ -4,4 +4,4 @@ '@clerk/types': minor --- -Introduce Custom Pages in UserProfile +Introduce Custom Pages in UserProfile and OrganizationProfile diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx index 9306a562e48..7832af2bce0 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 f84c667bcdc..f40aebb291b 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -1,7 +1,9 @@ import { Gate } from '../../common/Gate'; +import { useOrganizationProfileContext } from '../../contexts'; import { ProfileCardContent } from '../../elements'; import { Route, Switch } from '../../router'; import type { PropsOfComponent } from '../../styledSystem'; +import { ExternalElementMounter } from '../../utils'; import { DeleteOrganizationPage, LeaveOrganizationPage } from './ActionConfirmationPage'; import { AddDomainPage } from './AddDomainPage'; import { InviteMembersPage } from './InviteMembersPage'; @@ -13,100 +15,122 @@ import { VerifiedDomainPage } from './VerifiedDomainPage'; import { VerifyDomainPage } from './VerifyDomainPage'; export const OrganizationProfileRoutes = (props: PropsOfComponent) => { + const { pages } = useOrganizationProfileContext(); + const isMembersPageRoot = pages.routes[0].id === 'members'; + const isSettingsPageRoot = pages.routes[0].id === 'settings'; + const isPredefinedPageRoot = isSettingsPageRoot || isMembersPageRoot; + return ( - - + + {/* Custom Pages */} + {pages.contents?.map((customPage, index) => ( - + + ))} + + + + + - - - - - + + + + - + - - + - + - - + - + - - + + + - - - - - - - + + + + - + - - + + + + - - - - - - + + + + + + + + - - - - - + + ); }; diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx index 8b016e2e1d9..0d7222f0756 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx @@ -25,15 +25,16 @@ import { Web3Page } from './Web3Page'; export const UserProfileRoutes = (props: PropsOfComponent) => { const { pages } = useUserProfileContext(); + const isAccountPageRoot = pages.routes[0].id === 'account' || pages.routes[0].id === 'security'; return ( {/* Custom Pages */} {pages.contents?.map((customPage, index) => ( ))} - + { type PagesType = { routes: NavbarRoute[]; contents: CustomPageContent[]; - isAccountPageRoot: boolean; pageToRootNavbarRouteMap: Record; }; @@ -208,7 +207,7 @@ export const useUserProfileContext = (): UserProfileContextType => { throw new Error('Clerk: useUserProfileContext called outside of the mounted UserProfile component.'); } - const pages = useMemo(() => createCustomPages(customPages || []), [customPages]); + const pages = useMemo(() => createUserProfileCustomPages(customPages || []), [customPages]); return { ...ctx, @@ -409,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(); @@ -418,11 +422,14 @@ export const useOrganizationProfileContext = () => { throw new Error('Clerk: useOrganizationProfileContext called outside OrganizationProfile.'); } + const pages = useMemo(() => createOrganizationProfileCustomPages(customPages || []), [customPages]); + const navigateAfterLeaveOrganization = () => navigate(ctx.afterLeaveOrganizationUrl || displayConfig.afterLeaveOrganizationUrl); return { ...ctx, + pages, navigateAfterLeaveOrganization, componentName, }; diff --git a/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx index e04299d3c5c..a6c63253755 100644 --- a/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx +++ b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx @@ -7,6 +7,7 @@ type ExternalElementMounterProps = { export const ExternalElementMounter = ({ mount, unmount }: ExternalElementMounterProps) => { const nodeRef = useRef(null); + useEffect(() => { let elRef: HTMLDivElement | undefined; if (nodeRef.current) { @@ -17,9 +18,6 @@ export const ExternalElementMounter = ({ mount, unmount }: ExternalElementMounte unmount(elRef); }; }, [nodeRef.current]); - return ( - <> -
- - ); + + 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 index 1eeb232ac38..036c8f6a1ff 100644 --- a/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts +++ b/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts @@ -1,14 +1,14 @@ -import type { CustomPage } from '../createCustomPages'; -import { createCustomPages } from '../createCustomPages'; +import type { CustomPage } from '@clerk/types'; -describe('createCustomPages', () => { +import { createUserProfileCustomPages } from '../createCustomPages'; + +describe('createUserProfileCustomPages', () => { it('should return the default pages if no custom pages are passed', () => { - const { routes, contents, isAccountPageRoot } = createCustomPages([]); + 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); - expect(isAccountPageRoot).toEqual(true); }); it('should return the custom pages after the default pages', () => { @@ -30,7 +30,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - const { routes, contents, isAccountPageRoot } = createCustomPages(customPages); + const { routes, contents } = createUserProfileCustomPages(customPages); expect(routes.length).toEqual(4); expect(routes[0].id).toEqual('account'); expect(routes[1].id).toEqual('security'); @@ -39,7 +39,6 @@ describe('createCustomPages', () => { expect(contents.length).toEqual(2); expect(contents[0].url).toEqual('custom1'); expect(contents[1].url).toEqual('custom2'); - expect(isAccountPageRoot).toEqual(true); }); it('should reorder the default pages when their label is used to target them', () => { @@ -63,7 +62,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - const { routes, contents, isAccountPageRoot } = createCustomPages(customPages); + const { routes, contents } = createUserProfileCustomPages(customPages); expect(routes.length).toEqual(4); expect(routes[0].name).toEqual('Custom1'); expect(routes[1].id).toEqual('account'); @@ -72,7 +71,6 @@ describe('createCustomPages', () => { expect(contents.length).toEqual(2); expect(contents[0].url).toEqual('custom1'); expect(contents[1].url).toEqual('custom2'); - expect(isAccountPageRoot).toEqual(false); }); it('ignores invalid entries', () => { @@ -98,7 +96,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - const { routes } = createCustomPages(customPages); + const { routes } = createUserProfileCustomPages(customPages); expect(routes.length).toEqual(4); expect(routes[0].name).toEqual('Custom1'); expect(routes[1].id).toEqual('account'); @@ -127,7 +125,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - const { routes } = createCustomPages(customPages); + const { routes } = createUserProfileCustomPages(customPages); expect(routes.length).toEqual(4); expect(routes[0].path).toEqual('/'); expect(routes[1].path).toEqual('account'); @@ -156,7 +154,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - const { routes } = createCustomPages(customPages); + const { routes } = createUserProfileCustomPages(customPages); expect(routes.length).toEqual(4); expect(routes[0].path).toEqual('/'); expect(routes[1].path).toEqual('custom1'); @@ -185,7 +183,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - const { routes } = createCustomPages(customPages); + const { routes } = createUserProfileCustomPages(customPages); expect(routes.length).toEqual(4); expect(routes[0].path).toEqual('/'); expect(routes[1].path).toEqual('custom1'); @@ -212,7 +210,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - expect(() => createCustomPages(customPages)).toThrow(); + expect(() => createUserProfileCustomPages(customPages)).toThrow(); }); it('adds an external link to the navbar routes', () => { @@ -232,7 +230,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - const { routes, contents, isAccountPageRoot } = createCustomPages(customPages); + const { routes, contents } = createUserProfileCustomPages(customPages); expect(routes.length).toEqual(4); expect(routes[0].id).toEqual('account'); expect(routes[1].id).toEqual('security'); @@ -240,7 +238,6 @@ describe('createCustomPages', () => { expect(routes[3].name).toEqual('Link1'); expect(contents.length).toEqual(1); expect(contents[0].url).toEqual('custom1'); - expect(isAccountPageRoot).toEqual(true); }); it('sanitizes the path for external links', () => { @@ -264,7 +261,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - const { routes } = createCustomPages(customPages); + 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'); @@ -290,7 +287,7 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - const { routes } = createCustomPages(customPages); + 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'); @@ -307,6 +304,6 @@ describe('createCustomPages', () => { unmountIcon: () => undefined, }, ]; - expect(() => createCustomPages(customPages)).toThrow(); + expect(() => createUserProfileCustomPages(customPages)).toThrow(); }); }); diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index 15ece3dc4f4..74d29f69f51 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -3,7 +3,7 @@ import type { CustomPage } from '@clerk/types'; import { isValidUrl } from '../../utils'; import type { NavbarRoute } from '../elements'; -import { TickShield, User } from '../icons'; +import { CogFilled, TickShield, User } from '../icons'; import { localizationKeys } from '../localization'; import { ExternalElementMounter } from './ExternalElementMounter'; @@ -33,13 +33,51 @@ type UserProfileCustomLink = { unmountIcon: (el?: HTMLDivElement) => void; }; -export const createCustomPages = (customPages: CustomPage[]) => { +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); + checkForDuplicateUsageOfReorderingItems(customPages, validReorderItemLabels); } const validCustomPages = customPages.filter(cp => { - if (!isValidPageItem(cp)) { + if (!isValidPageItem(cp, validReorderItemLabels)) { if (isDevelopmentEnvironment()) { console.error('Invalid custom page data: ', cp); } @@ -48,11 +86,9 @@ export const createCustomPages = (customPages: CustomPage[]) => { return true; }); - const { USER_PROFILE_ROUTES, pageToRootNavbarRouteMap } = getUserProfileDefaultRoutes(); - const { allRoutes, contents } = getRoutesAndContents({ customPages: validCustomPages, - defaultRoutes: USER_PROFILE_ROUTES, + defaultRoutes: INITIAL_ROUTES, }); assertExternalLinkAsRoot(allRoutes); @@ -60,13 +96,12 @@ export const createCustomPages = (customPages: CustomPage[]) => { const routes = setFirstPathToRoot(allRoutes); if (isDevelopmentEnvironment()) { - warnForDuplicatePaths(routes); + warnForDuplicatePaths(routes, excludedPathsFromDuplicateWarning); } return { routes, contents, - isAccountPageRoot: routes[0].id === 'account' || routes[0].id === 'security', pageToRootNavbarRouteMap, }; }; @@ -121,7 +156,7 @@ const getRoutesAndContents = ({ customPages, defaultRoutes }: GetRoutesAndConten }; // 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 setFirstPathToRoot = (routes: NavbarRoute[]) => { +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') { @@ -134,8 +169,12 @@ const setFirstPathToRoot = (routes: NavbarRoute[]) => { } }; -const checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[]) => { - const reorderItems = customPages.filter(cp => isAccountReorderItem(cp) || isSecurityReorderItem(cp)); +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( @@ -145,10 +184,10 @@ const checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[]) => { return [...acc, cp.label]; }, [] as string[]); }; - -const warnForDuplicatePaths = (routes: NavbarRoute[]) => { +//path !== '/' && path !== 'account' +const warnForDuplicatePaths = (routes: NavbarRoute[], pathsToFilter: string[]) => { const paths = routes - .filter(({ external, path }) => !external && path !== '/' && path !== 'account') + .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 => { @@ -156,8 +195,8 @@ const warnForDuplicatePaths = (routes: NavbarRoute[]) => { }); }; -const isValidPageItem = (cp: CustomPage): cp is CustomPage => { - return isCustomPage(cp) || isCustomLink(cp) || isAccountReorderItem(cp) || isSecurityReorderItem(cp); +const isValidPageItem = (cp: CustomPage, validReorderItems: string[]): cp is CustomPage => { + return isCustomPage(cp) || isCustomLink(cp) || isReorderItem(cp, validReorderItems); }; const isCustomPage = (cp: CustomPage): cp is UserProfileCustomPage => { @@ -168,12 +207,10 @@ const isCustomLink = (cp: CustomPage): cp is UserProfileCustomLink => { return !!cp.url && !!cp.label && !cp.mount && !cp.unmount && !!cp.mountIcon && !!cp.unmountIcon; }; -const isAccountReorderItem = (cp: CustomPage): cp is UserProfileReorderItem => { - return !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && cp.label === 'account'; -}; - -const isSecurityReorderItem = (cp: CustomPage): cp is UserProfileReorderItem => { - return !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && cp.label === 'security'; +const isReorderItem = (cp: CustomPage, validItems: string[]): cp is UserProfileReorderItem => { + return ( + !cp.url && !cp.mount && !cp.unmount && !cp.mountIcon && !cp.unmountIcon && validItems.some(v => v === cp.label) + ); }; const sanitizeCustomPageURL = (url: string): string => { @@ -198,12 +235,12 @@ const sanitizeCustomLinkURL = (url: string): string => { const assertExternalLinkAsRoot = (routes: NavbarRoute[]) => { if (routes[0].external) { - throw new Error('The first route cannot be a component'); + throw new Error('The first route cannot be a custom external link component'); } }; -const getUserProfileDefaultRoutes = () => { - const USER_PROFILE_ROUTES: NavbarRoute[] = [ +const getUserProfileDefaultRoutes = (): GetDefaultRoutesReturnType => { + const INITIAL_ROUTES: NavbarRoute[] = [ { name: localizationKeys('userProfile.start.headerTitle__account'), id: 'account', @@ -218,16 +255,47 @@ const getUserProfileDefaultRoutes = () => { }, ]; - const pageToRootNavbarRouteMap = { - profile: USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'email-address': USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'phone-number': USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'connected-account': USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'web3-wallet': USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, - username: USER_PROFILE_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'multi-factor': USER_PROFILE_ROUTES.find(r => r.id === 'security') as NavbarRoute, - password: USER_PROFILE_ROUTES.find(r => r.id === 'security') as NavbarRoute, + const pageToRootNavbarRouteMap: Record = { + profile: INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'email-address': INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'phone-number': INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'connected-account': INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'web3-wallet': INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, + username: INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, + 'multi-factor': INITIAL_ROUTES.find(r => r.id === 'security') as NavbarRoute, + password: INITIAL_ROUTES.find(r => r.id === 'security') as NavbarRoute, }; - return { USER_PROFILE_ROUTES, pageToRootNavbarRouteMap }; + 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: 'members', + icon: User, + path: 'organization-members', + }, + { + name: localizationKeys('organizationProfile.start.headerTitle__settings'), + id: 'settings', + icon: CogFilled, + path: 'organization-settings', + }, + ]; + + const pageToRootNavbarRouteMap: Record = { + 'invite-members': INITIAL_ROUTES.find(r => r.id === 'members') as NavbarRoute, + domain: INITIAL_ROUTES.find(r => r.id === 'settings') as NavbarRoute, + profile: INITIAL_ROUTES.find(r => r.id === 'settings') as NavbarRoute, + leave: INITIAL_ROUTES.find(r => r.id === 'settings') as NavbarRoute, + delete: INITIAL_ROUTES.find(r => r.id === 'settings') as NavbarRoute, + }; + + const validReorderItemLabels: string[] = INITIAL_ROUTES.map(r => r.id); + + return { INITIAL_ROUTES, pageToRootNavbarRouteMap, validReorderItemLabels }; }; diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 78c540d506e..db420355436 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -12,9 +12,21 @@ import type { import type { PropsWithChildren } from 'react'; import React, { createElement } from 'react'; -import { userProfileLinkRenderedError, userProfilePageRenderedError } from '../errors'; -import type { MountProps, UserProfileLinkProps, UserProfilePageProps, WithClerkProp } from '../types'; -import { useCustomPages } from '../utils/useCustomPages'; +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'; // README: should be a class pure component in order for mount and unmount @@ -116,7 +128,7 @@ export function UserProfileLink({ children }: PropsWithChildren>) => { - const { customPages, customPagesPortals } = useCustomPages(props.children); + const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); return ( >) => { - const { customPages, customPagesPortals } = useCustomPages(props.children); + const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); const userProfileProps = Object.assign(props.userProfileProps || {}, { customPages }); return ( ) => { - return ( - - ); -}, 'OrganizationProfile'); +export function OrganizationProfilePage({ children }: PropsWithChildren) { + if (isDevelopmentEnvironment()) { + console.error(organizationProfilePageRenderedError); + } + return
{children}
; +} + +export function OrganizationProfileLink({ children }: PropsWithChildren) { + if (isDevelopmentEnvironment()) { + console.error(organizationProfileLinkRenderedError); + } + return
{children}
; +} + +const _OrganizationProfile = withClerk( + ({ clerk, ...props }: WithClerkProp>) => { + const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children); + return ( + + ); + }, + 'OrganizationProfile', +); + +type OrganizationProfileExportType = typeof _OrganizationProfile & { + Page: ({ children }: PropsWithChildren) => React.JSX.Element; + Link: ({ children }: PropsWithChildren) => React.JSX.Element; +}; +export const OrganizationProfile: OrganizationProfileExportType = Object.assign(_OrganizationProfile, { + Page: OrganizationProfilePage, + Link: OrganizationProfileLink, +}); export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( @@ -182,16 +222,31 @@ 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', +); + +type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { + OrganizationProfilePage: ({ children }: PropsWithChildren) => React.JSX.Element; + OrganizationProfileLink: ({ children }: PropsWithChildren) => React.JSX.Element; +}; +export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, { + OrganizationProfilePage: OrganizationProfilePage, + OrganizationProfileLink: OrganizationProfileLink, +}); export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( diff --git a/packages/react/src/errors.ts b/packages/react/src/errors.ts index 2f93a4e659a..bbeef3cd16f 100644 --- a/packages/react/src/errors.ts +++ b/packages/react/src/errors.ts @@ -37,11 +37,16 @@ export const userProfilePageRenderedError = export const userProfileLinkRenderedError = ' component needs to be a direct child of `` or ``.'; -export const customPagesIngoredComponent = - ' can only accept and as its children. Any other provided component will be ignored.'; +export const organizationProfilePageRenderedError = + ' component needs to be a direct child of `` or ``.'; +export const organizationProfileLinkRenderedError = + ' component needs to be a direct child of `` or ``.'; -export const userProfilePageWrongProps = - 'Missing props. component requires the following props: url, label, labelIcon, alongside with children to be rendered inside the page.'; +export const customPagesIgnoredComponent = (componentName: string) => + `<${componentName} /> can only accept <${componentName}.Page /> and <${componentName}.Link /> as its children. Any other provided component will be ignored.`; -export const userProfileLinkWrongProps = - 'Missing props. component requires the following props: url, label and labelIcon.'; +export const customPageWrongProps = (componentName: string) => + `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) => + `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 0b5485fe4dd..f42eb45df31 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -102,3 +102,6 @@ export type UserProfileLinkProps = { label: string; labelIcon: React.ReactElement; }; + +export type OrganizationProfilePageProps = UserProfilePageProps; +export type OrganizationProfileLinkProps = UserProfileLinkProps; diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index f3f1850bcf5..04a02b30856 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -4,3 +4,4 @@ export * from './isConstructor'; export { loadClerkJsScript } from './loadClerkJsScript'; export * from './useMaxAllowedInstancesGuard'; export * from './useCustomElementPortal'; +export * from './useCustomPages'; diff --git a/packages/react/src/utils/useCustomElementPortal.tsx b/packages/react/src/utils/useCustomElementPortal.tsx index 2a584c2c180..911977bdc25 100644 --- a/packages/react/src/utils/useCustomElementPortal.tsx +++ b/packages/react/src/utils/useCustomElementPortal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { createPortal } from 'react-dom'; export type UseCustomElementPortalParams = { @@ -16,11 +16,8 @@ export type UseCustomElementPortalReturn = { // 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 [nodes, setNodes] = useState<(Element | null)[]>([]); - - useEffect(() => { - setNodes(Array(elements.length).fill(null)); - }, [elements.length]); + const initialState = Array(elements.length).fill(null); + const [nodes, setNodes] = useState<(Element | null)[]>(initialState); const portals: UseCustomElementPortalReturn[] = []; diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx index 1b6096d519c..3de7414af0a 100644 --- a/packages/react/src/utils/useCustomPages.tsx +++ b/packages/react/src/utils/useCustomPages.tsx @@ -3,8 +3,13 @@ import type { CustomPage } from '@clerk/types'; import type { ReactElement } from 'react'; import React from 'react'; -import { UserProfileLink, UserProfilePage } from '../components/uiComponents'; -import { customPagesIngoredComponent, userProfileLinkWrongProps, userProfilePageWrongProps } from '../errors'; +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'; @@ -15,23 +20,55 @@ const errorInDevMode = (message: string) => { } }; -const isPageComponent = (v: any): v is React.ReactNode => { - return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === UserProfilePage; +const isThatComponent = (v: any, component: React.ReactNode): v is React.ReactNode => { + return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === component; }; -const isLinkComponent = (v: any): v is React.ReactNode => { - return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === UserProfileLink; +export const useUserProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => { + const reorderItemsLabels = ['account', 'security']; + return useCustomPages({ + children, + reorderItemsLabels, + LinkComponent: UserProfileLink, + PageComponent: UserProfilePage, + componentName: 'UserProfile', + }); }; -type CustomPageWithIdType = UserProfilePageProps & { children?: React.ReactNode }; +export const useOrganizationProfileCustomPages = (children: React.ReactNode | React.ReactNode[]) => { + const reorderItemsLabels = ['members', 'settings']; + return useCustomPages({ + children, + reorderItemsLabels, + LinkComponent: OrganizationProfileLink, + PageComponent: OrganizationProfilePage, + componentName: 'OrganizationProfile', + }); +}; -export const useCustomPages = (userProfileChildren: React.ReactNode | React.ReactNode[]) => { - const validUserProfileChildren: CustomPageWithIdType[] = []; +type UseCustomPagesParams = { + children: React.ReactNode | React.ReactNode[]; + LinkComponent: any; + PageComponent: any; + reorderItemsLabels: string[]; + componentName: string; +}; + +type CustomPageWithIdType = UserProfilePageProps & { children?: React.ReactNode }; - React.Children.forEach(userProfileChildren, child => { - if (!isPageComponent(child) && !isLinkComponent(child)) { +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) { - errorInDevMode(customPagesIngoredComponent); + errorInDevMode(customPagesIgnoredComponent(componentName)); } return; } @@ -40,25 +77,25 @@ export const useCustomPages = (userProfileChildren: React.ReactNode | React.Reac const { children, label, url, labelIcon } = props; - if (isPageComponent(child)) { - if (isReorderItem(props)) { + if (isThatComponent(child, PageComponent)) { + if (isReorderItem(props, reorderItemsLabels)) { // This is a reordering item - validUserProfileChildren.push({ label }); + validChildren.push({ label }); } else if (isCustomPage(props)) { // this is a custom page - validUserProfileChildren.push({ label, labelIcon, children, url }); + validChildren.push({ label, labelIcon, children, url }); } else { - errorInDevMode(userProfilePageWrongProps); + errorInDevMode(customPageWrongProps(componentName)); return; } } - if (isLinkComponent(child)) { + if (isThatComponent(child, LinkComponent)) { if (isExternalLink(props)) { // This is an external link - validUserProfileChildren.push({ label, labelIcon, url }); + validChildren.push({ label, labelIcon, url }); } else { - errorInDevMode(userProfileLinkWrongProps); + errorInDevMode(customLinkWrongProps(componentName)); return; } } @@ -68,7 +105,7 @@ export const useCustomPages = (userProfileChildren: React.ReactNode | React.Reac const customPageLabelIcons: UseCustomElementPortalParams[] = []; const customLinkLabelIcons: UseCustomElementPortalParams[] = []; - validUserProfileChildren.forEach((cp, index) => { + validChildren.forEach((cp, index) => { if (isCustomPage(cp)) { customPageContents.push({ component: cp.children, id: index }); customPageLabelIcons.push({ component: cp.labelIcon, id: index }); @@ -86,8 +123,8 @@ export const useCustomPages = (userProfileChildren: React.ReactNode | React.Reac const customPages: CustomPage[] = []; const customPagesPortals: React.ComponentType[] = []; - validUserProfileChildren.forEach((cp, index) => { - if (isReorderItem(cp)) { + validChildren.forEach((cp, index) => { + if (isReorderItem(cp, reorderItemsLabels)) { customPages.push({ label: cp.label }); return; } @@ -122,9 +159,9 @@ export const useCustomPages = (userProfileChildren: React.ReactNode | React.Reac return { customPages, customPagesPortals }; }; -const isReorderItem = (childProps: any): boolean => { +const isReorderItem = (childProps: any, validItems: string[]): boolean => { const { children, label, url, labelIcon } = childProps; - return !children && !url && !labelIcon && (label === 'account' || label === 'security'); + return !children && !url && !labelIcon && validItems.some(v => v === label); }; const isCustomPage = (childProps: any): boolean => { diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 3ca594d57e9..23c4c460a16 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -754,6 +754,16 @@ export type OrganizationProfileProps = { * prop of ClerkProvided (if one is provided) */ appearance?: OrganizationProfileTheme; + /* + * Provide addition custom route items and pages to be rendered inside the OrganizationProfile. + * e.g. + * + * C
}> + *
Hello from custom page!
+ * + * + */ + customPages?: CustomPage[]; }; export type CreateOrganizationProps = { @@ -932,7 +942,7 @@ export type OrganizationSwitcherProps = { * Specify options for the underlying component. * e.g. */ - organizationProfileProps?: Pick; + organizationProfileProps?: Pick; }; export type OrganizationListProps = { From 66c0af474b4b977209fb95dc6a567397356f9285 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Thu, 5 Oct 2023 17:08:59 +0300 Subject: [PATCH 07/16] fix(clerk-react,clerk-js): Resolve comments for custom pages --- .../ui/contexts/ClerkUIComponentsContext.tsx | 4 +- .../src/ui/utils/createCustomPages.tsx | 16 +++--- .../react/src/components/uiComponents.tsx | 55 +++++++++---------- packages/react/src/errors.ts | 18 +++--- packages/react/src/types.ts | 4 +- packages/react/src/utils/errorInDevMode.ts | 7 +++ packages/react/src/utils/index.ts | 1 + packages/react/src/utils/useCustomPages.tsx | 8 +-- 8 files changed, 55 insertions(+), 58 deletions(-) create mode 100644 packages/react/src/utils/errorInDevMode.ts diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index ff6755a20e1..b7bbacb2775 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -207,7 +207,7 @@ export const useUserProfileContext = (): UserProfileContextType => { throw new Error('Clerk: useUserProfileContext called outside of the mounted UserProfile component.'); } - const pages = useMemo(() => createUserProfileCustomPages(customPages || []), [customPages]); + const pages = createUserProfileCustomPages(customPages || []); return { ...ctx, @@ -422,7 +422,7 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType throw new Error('Clerk: useOrganizationProfileContext called outside OrganizationProfile.'); } - const pages = useMemo(() => createOrganizationProfileCustomPages(customPages || []), [customPages]); + const pages = createOrganizationProfileCustomPages(customPages || []); const navigateAfterLeaveOrganization = () => navigate(ctx.afterLeaveOrganizationUrl || displayConfig.afterLeaveOrganizationUrl); diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index 74d29f69f51..c6b355a5c45 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -79,7 +79,7 @@ const createCustomPages = ({ const validCustomPages = customPages.filter(cp => { if (!isValidPageItem(cp, validReorderItemLabels)) { if (isDevelopmentEnvironment()) { - console.error('Invalid custom page data: ', cp); + console.error('Clerk: Invalid custom page data: ', cp); } return false; } @@ -112,7 +112,7 @@ type GetRoutesAndContentsParams = { }; const getRoutesAndContents = ({ customPages, defaultRoutes }: GetRoutesAndContentsParams) => { - let remainingDefaultRoutes: NavbarRoute[] = defaultRoutes.map(r => ({ ...r })); + let remainingDefaultRoutes: NavbarRoute[] = defaultRoutes.map(r => r); const contents: CustomPageContent[] = []; const routesWithoutDefaults: NavbarRoute[] = customPages.map((cp, index) => { @@ -178,7 +178,7 @@ const checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[], vali reorderItems.reduce((acc, cp) => { if (acc.includes(cp.label)) { console.error( - `The "${cp.label}" item is used more than once when reordering UserProfile pages. This may cause unexpected behavior.`, + `Clerk: The "${cp.label}" item is used more than once when reordering UserProfile pages. This may cause unexpected behavior.`, ); } return [...acc, cp.label]; @@ -191,7 +191,7 @@ const warnForDuplicatePaths = (routes: NavbarRoute[], pathsToFilter: string[]) = .map(({ path }) => path); const duplicatePaths = paths.filter((p, index) => paths.indexOf(p) !== index); duplicatePaths.forEach(p => { - console.error(`Duplicate path "${p}" found in custom pages. This may cause unexpected behavior.`); + console.error(`Clerk: Duplicate path "${p}" found in custom pages. This may cause unexpected behavior.`); }); }; @@ -215,17 +215,17 @@ const isReorderItem = (cp: CustomPage, validItems: string[]): cp is UserProfileR const sanitizeCustomPageURL = (url: string): string => { if (!url) { - throw new Error('URL is required for custom pages'); + throw new Error('Clerk: URL is required for custom pages'); } if (isValidUrl(url)) { - throw new Error('Absolute URLs are not supported for custom pages'); + 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('URL is required for custom links'); + throw new Error('Clerk: URL is required for custom links'); } if (isValidUrl(url)) { return url; @@ -235,7 +235,7 @@ const sanitizeCustomLinkURL = (url: string): string => { const assertExternalLinkAsRoot = (routes: NavbarRoute[]) => { if (routes[0].external) { - throw new Error('The first route cannot be a custom external link component'); + throw new Error('Clerk: The first route cannot be a custom external link component'); } }; diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index db420355436..2ca0739951f 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -1,4 +1,3 @@ -import { isDevelopmentEnvironment } from '@clerk/shared'; import type { CreateOrganizationProps, OrganizationListProps, @@ -26,9 +25,29 @@ import type { UserProfilePageProps, WithClerkProp, } from '../types'; -import { useOrganizationProfileCustomPages, useUserProfileCustomPages } from '../utils'; +import { errorInDevMode, 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 OrganizationProfileExportType = typeof _OrganizationProfile & { + Page: typeof OrganizationProfilePage; + Link: typeof OrganizationProfileLink; +}; + +type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { + OrganizationProfilePage: typeof OrganizationProfilePage; + OrganizationProfileLink: typeof OrganizationProfileLink; +}; + // 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 @@ -114,16 +133,12 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp }, 'SignUp'); export function UserProfilePage({ children }: PropsWithChildren) { - if (isDevelopmentEnvironment()) { - console.error(userProfilePageRenderedError); - } + errorInDevMode(userProfilePageRenderedError); return
{children}
; } export function UserProfileLink({ children }: PropsWithChildren) { - if (isDevelopmentEnvironment()) { - console.error(userProfileLinkRenderedError); - } + errorInDevMode(userProfileLinkRenderedError); return
{children}
; } @@ -140,10 +155,6 @@ const _UserProfile = withClerk(({ clerk, ...props }: WithClerkProp) => React.JSX.Element; - Link: ({ children }: PropsWithChildren) => React.JSX.Element; -}; export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, { Page: UserProfilePage, Link: UserProfileLink, @@ -163,26 +174,18 @@ const _UserButton = withClerk(({ clerk, ...props }: WithClerkProp) => React.JSX.Element; - UserProfileLink: ({ children }: PropsWithChildren) => React.JSX.Element; -}; export const UserButton: UserButtonExportType = Object.assign(_UserButton, { UserProfilePage: UserProfilePage, UserProfileLink: UserProfileLink, }); export function OrganizationProfilePage({ children }: PropsWithChildren) { - if (isDevelopmentEnvironment()) { - console.error(organizationProfilePageRenderedError); - } + errorInDevMode(organizationProfilePageRenderedError); return
{children}
; } export function OrganizationProfileLink({ children }: PropsWithChildren) { - if (isDevelopmentEnvironment()) { - console.error(organizationProfileLinkRenderedError); - } + errorInDevMode(organizationProfileLinkRenderedError); return
{children}
; } @@ -202,10 +205,6 @@ const _OrganizationProfile = withClerk( 'OrganizationProfile', ); -type OrganizationProfileExportType = typeof _OrganizationProfile & { - Page: ({ children }: PropsWithChildren) => React.JSX.Element; - Link: ({ children }: PropsWithChildren) => React.JSX.Element; -}; export const OrganizationProfile: OrganizationProfileExportType = Object.assign(_OrganizationProfile, { Page: OrganizationProfilePage, Link: OrganizationProfileLink, @@ -239,10 +238,6 @@ const _OrganizationSwitcher = withClerk( 'OrganizationSwitcher', ); -type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { - OrganizationProfilePage: ({ children }: PropsWithChildren) => React.JSX.Element; - OrganizationProfileLink: ({ children }: PropsWithChildren) => React.JSX.Element; -}; export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, { OrganizationProfilePage: OrganizationProfilePage, OrganizationProfileLink: OrganizationProfileLink, diff --git a/packages/react/src/errors.ts b/packages/react/src/errors.ts index bbeef3cd16f..0e881c3de35 100644 --- a/packages/react/src/errors.ts +++ b/packages/react/src/errors.ts @@ -27,26 +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 = - ' component needs to be a direct child of `` or ``.'; + 'Clerk: component needs to be a direct child of `` or ``.'; export const userProfileLinkRenderedError = - ' component needs to be a direct child of `` or ``.'; + 'Clerk: component needs to be a direct child of `` or ``.'; export const organizationProfilePageRenderedError = - ' component needs to be a direct child of `` or ``.'; + 'Clerk: component needs to be a direct child of `` or ``.'; export const organizationProfileLinkRenderedError = - ' component needs to be a direct child of `` or ``.'; + 'Clerk: component needs to be a direct child of `` or ``.'; export const customPagesIgnoredComponent = (componentName: string) => - `<${componentName} /> can only accept <${componentName}.Page /> and <${componentName}.Link /> as its children. Any other provided component will be ignored.`; + `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) => - `Missing props. <${componentName}.Page /> component requires the following props: url, label, labelIcon, alongside with children to be rendered inside the page.`; + `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) => - `Missing props. <${componentName}.Link /> component requires the following props: url, label and labelIcon.`; + `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 f42eb45df31..dd9bb60028b 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -94,13 +94,13 @@ export type RedirectToSignUpProps = SignUpRedirectOptions; export type UserProfilePageProps = { url?: string; label: string; - labelIcon?: React.ReactElement; + labelIcon?: React.ReactNode; }; export type UserProfileLinkProps = { url: string; label: string; - labelIcon: React.ReactElement; + labelIcon: React.ReactNode; }; export type OrganizationProfilePageProps = UserProfilePageProps; diff --git a/packages/react/src/utils/errorInDevMode.ts b/packages/react/src/utils/errorInDevMode.ts new file mode 100644 index 00000000000..dfa5fd09ba0 --- /dev/null +++ b/packages/react/src/utils/errorInDevMode.ts @@ -0,0 +1,7 @@ +import { isDevelopmentEnvironment } from '@clerk/shared'; + +export const errorInDevMode = (message: string) => { + if (isDevelopmentEnvironment()) { + console.error(message); + } +}; diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index 04a02b30856..590e8b08017 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -5,3 +5,4 @@ export { loadClerkJsScript } from './loadClerkJsScript'; export * from './useMaxAllowedInstancesGuard'; export * from './useCustomElementPortal'; export * from './useCustomPages'; +export * from './errorInDevMode'; diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx index 3de7414af0a..c92812ff269 100644 --- a/packages/react/src/utils/useCustomPages.tsx +++ b/packages/react/src/utils/useCustomPages.tsx @@ -1,4 +1,3 @@ -import { isDevelopmentEnvironment } from '@clerk/shared'; import type { CustomPage } from '@clerk/types'; import type { ReactElement } from 'react'; import React from 'react'; @@ -11,15 +10,10 @@ import { } from '../components/uiComponents'; import { customLinkWrongProps, customPagesIgnoredComponent, customPageWrongProps } from '../errors'; import type { UserProfilePageProps } from '../types'; +import { errorInDevMode } from './errorInDevMode'; import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal'; import { useCustomElementPortal } from './useCustomElementPortal'; -const errorInDevMode = (message: string) => { - if (isDevelopmentEnvironment()) { - console.error(message); - } -}; - const isThatComponent = (v: any, component: React.ReactNode): v is React.ReactNode => { return !!v && React.isValidElement(v) && (v as React.ReactElement)?.type === component; }; From 436eef32186d5bacee0728f3614673e93f6fa512 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Thu, 5 Oct 2023 18:49:29 +0300 Subject: [PATCH 08/16] test(clerk-js): Add tests for OrganizationProfile custom pages --- .../__tests__/OrganizationProfile.test.tsx | 34 + .../__tests__/UserProfile.test.tsx | 2 +- .../utils/__tests__/createCustomPages.test.ts | 891 ++++++++++++------ 3 files changed, 634 insertions(+), 293 deletions(-) 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 18cb3efe8d1..3b654a948e1 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/__tests__/UserProfile.test.tsx b/packages/clerk-js/src/ui/components/UserProfile/__tests__/UserProfile.test.tsx index ed6b04ce07c..f7ee22f59c1 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,8 +1,8 @@ +import type { CustomPage } from '@clerk/types'; import { describe, it } from '@jest/globals'; import React from 'react'; import { bindCreateFixtures, render, screen } from '../../../../testUtils'; -import type { CustomPage } from '../../../utils'; import { UserProfile } from '../UserProfile'; const { createFixtures } = bindCreateFixtures('UserProfile'); diff --git a/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts b/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts index 036c8f6a1ff..0ddff304799 100644 --- a/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts +++ b/packages/clerk-js/src/ui/utils/__tests__/createCustomPages.test.ts @@ -1,309 +1,616 @@ import type { CustomPage } from '@clerk/types'; -import { createUserProfileCustomPages } from '../createCustomPages'; +import { createOrganizationProfileCustomPages, createUserProfileCustomPages } from '../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); - }); +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 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('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('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 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 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('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('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('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 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('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(); + }); }); - 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(); + }); }); }); From 9dd53b7b8c9080200433eddcfe0e8ac92237f208 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Fri, 6 Oct 2023 13:00:44 +0300 Subject: [PATCH 09/16] fix(clerk-js): Add navbar menu for mobile on custom pages --- .../ui/common/CustomPageContentContainer.tsx | 28 +++++++++++++++++++ .../OrganizationProfileRoutes.tsx | 4 +-- .../UserProfile/UserProfileRoutes.tsx | 4 +-- 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 packages/clerk-js/src/ui/common/CustomPageContentContainer.tsx 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 00000000000..e134a08f89e --- /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/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index f40aebb291b..813560c1939 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -1,9 +1,9 @@ +import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; import { Gate } from '../../common/Gate'; import { useOrganizationProfileContext } from '../../contexts'; import { ProfileCardContent } from '../../elements'; import { Route, Switch } from '../../router'; import type { PropsOfComponent } from '../../styledSystem'; -import { ExternalElementMounter } from '../../utils'; import { DeleteOrganizationPage, LeaveOrganizationPage } from './ActionConfirmationPage'; import { AddDomainPage } from './AddDomainPage'; import { InviteMembersPage } from './InviteMembersPage'; @@ -30,7 +30,7 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent - diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx index 0d7222f0756..f064b6b5521 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx @@ -1,8 +1,8 @@ +import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; import { useUserProfileContext } from '../../contexts'; import { ProfileCardContent } from '../../elements'; import { Route, Switch } from '../../router'; import type { PropsOfComponent } from '../../styledSystem'; -import { ExternalElementMounter } from '../../utils'; import { ConnectedAccountsPage } from './ConnectedAccountsPage'; import { DeletePage } from './DeletePage'; import { EmailPage } from './EmailPage'; @@ -36,7 +36,7 @@ export const UserProfileRoutes = (props: PropsOfComponent - From ef379a85a6c6c5cc6b3c09e21edcbdabb0befe73 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Mon, 9 Oct 2023 14:48:55 +0300 Subject: [PATCH 10/16] fix(clerk-react): Omit `customPages` property from React package components --- .../react/src/components/uiComponents.tsx | 68 +++++++++++-------- 1 file changed, 41 insertions(+), 27 deletions(-) diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 2ca0739951f..b5303a816dd 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -38,6 +38,10 @@ type UserButtonExportType = typeof _UserButton & { UserProfileLink: typeof UserProfileLink; }; +type UserButtonPropsWithoutCustomPages = Omit & { + userProfileProps?: Pick; +}; + type OrganizationProfileExportType = typeof _OrganizationProfile & { Page: typeof OrganizationProfilePage; Link: typeof OrganizationProfileLink; @@ -48,6 +52,10 @@ type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { 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 @@ -142,37 +150,43 @@ export function UserProfileLink({ children }: PropsWithChildren{children}; } -const _UserProfile = withClerk(({ clerk, ...props }: WithClerkProp>) => { - const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); - return ( - - ); -}, 'UserProfile'); +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'); +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: UserProfilePage, @@ -190,7 +204,7 @@ export function OrganizationProfileLink({ children }: PropsWithChildren>) => { + ({ clerk, ...props }: WithClerkProp>>) => { const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children); return ( >) => { + ({ clerk, ...props }: WithClerkProp>) => { const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children); const organizationProfileProps = Object.assign(props.organizationProfileProps || {}, { customPages }); return ( From 6b4e6a89dcd2d655b75b694826ef9af26bf7535f Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Thu, 12 Oct 2023 17:11:54 +0300 Subject: [PATCH 11/16] refactor(clerk-js): Refactor the UserProfileRoutes and OrganizationProfileRoutes to be more readable --- .../OrganizationProfileRoutes.tsx | 31 ++++++++++--------- .../UserProfile/UserProfileRoutes.tsx | 31 +++++++++++-------- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index 813560c1939..8a145de4d90 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -18,24 +18,27 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent { + const shouldFirstCustomItemBeOnRoot = !isSettingsPageRoot && !isMembersPageRoot && index === 0; + return ( + + + + ); + }); return ( - {/* Custom Pages */} - {pages.contents?.map((customPage, index) => ( - - - - ))} + {customPageRoutesWithContents} diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx index f064b6b5521..3f3c41fef97 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx @@ -26,22 +26,27 @@ import { Web3Page } from './Web3Page'; export const UserProfileRoutes = (props: PropsOfComponent) => { const { pages } = useUserProfileContext(); const isAccountPageRoot = pages.routes[0].id === 'account' || pages.routes[0].id === 'security'; + + const customPageRoutesWithContents = pages.contents?.map((customPage, index) => { + const shouldFirstCustomItemBeOnRoot = !isAccountPageRoot && index === 0; + return ( + + + + ); + }); + return ( - {/* Custom Pages */} - {pages.contents?.map((customPage, index) => ( - - - - ))} + {customPageRoutesWithContents} Date: Thu, 12 Oct 2023 17:37:46 +0300 Subject: [PATCH 12/16] refactor(clerk-react,types): Apply minor refactors suggested by PR comments --- .../src/ui/utils/createCustomPages.tsx | 14 +++++----- .../react/src/components/uiComponents.tsx | 26 +++++++++---------- packages/react/src/utils/index.ts | 2 +- ...errorInDevMode.ts => logErrorInDevMode.ts} | 2 +- .../src/utils/useCustomElementPortal.tsx | 21 +++++---------- packages/react/src/utils/useCustomPages.tsx | 8 +++--- packages/types/src/clerk.ts | 16 ++---------- 7 files changed, 34 insertions(+), 55 deletions(-) rename packages/react/src/utils/{errorInDevMode.ts => logErrorInDevMode.ts} (69%) diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index c6b355a5c45..e72a909846f 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -13,11 +13,11 @@ export type CustomPageContent = { unmount: (el?: HTMLDivElement) => void; }; -type UserProfileReorderItem = { +type ProfileReorderItem = { label: 'account' | 'security'; }; -type UserProfileCustomPage = { +type ProfileCustomPage = { label: string; url: string; mountIcon: (el: HTMLDivElement) => void; @@ -26,7 +26,7 @@ type UserProfileCustomPage = { unmount: (el?: HTMLDivElement) => void; }; -type UserProfileCustomLink = { +type ProfileCustomLink = { label: string; url: string; mountIcon: (el: HTMLDivElement) => void; @@ -178,7 +178,7 @@ const checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[], vali reorderItems.reduce((acc, cp) => { if (acc.includes(cp.label)) { console.error( - `Clerk: The "${cp.label}" item is used more than once when reordering UserProfile pages. This may cause unexpected behavior.`, + `Clerk: The "${cp.label}" item is used more than once when reordering pages. This may cause unexpected behavior.`, ); } return [...acc, cp.label]; @@ -199,15 +199,15 @@ const isValidPageItem = (cp: CustomPage, validReorderItems: string[]): cp is Cus return isCustomPage(cp) || isCustomLink(cp) || isReorderItem(cp, validReorderItems); }; -const isCustomPage = (cp: CustomPage): cp is UserProfileCustomPage => { +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 UserProfileCustomLink => { +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 UserProfileReorderItem => { +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) ); diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index b5303a816dd..58650f3d595 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -25,7 +25,7 @@ import type { UserProfilePageProps, WithClerkProp, } from '../types'; -import { errorInDevMode, useOrganizationProfileCustomPages, useUserProfileCustomPages } from '../utils'; +import { logErrorInDevMode, useOrganizationProfileCustomPages, useUserProfileCustomPages } from '../utils'; import { withClerk } from './withClerk'; type UserProfileExportType = typeof _UserProfile & { @@ -141,13 +141,13 @@ export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp }, 'SignUp'); export function UserProfilePage({ children }: PropsWithChildren) { - errorInDevMode(userProfilePageRenderedError); - return
{children}
; + logErrorInDevMode(userProfilePageRenderedError); + return <>{children}; } export function UserProfileLink({ children }: PropsWithChildren) { - errorInDevMode(userProfileLinkRenderedError); - return
{children}
; + logErrorInDevMode(userProfileLinkRenderedError); + return <>{children}; } const _UserProfile = withClerk( @@ -189,18 +189,18 @@ const _UserButton = withClerk( ); export const UserButton: UserButtonExportType = Object.assign(_UserButton, { - UserProfilePage: UserProfilePage, - UserProfileLink: UserProfileLink, + UserProfilePage, + UserProfileLink, }); export function OrganizationProfilePage({ children }: PropsWithChildren) { - errorInDevMode(organizationProfilePageRenderedError); - return
{children}
; + logErrorInDevMode(organizationProfilePageRenderedError); + return <>{children}; } export function OrganizationProfileLink({ children }: PropsWithChildren) { - errorInDevMode(organizationProfileLinkRenderedError); - return
{children}
; + logErrorInDevMode(organizationProfileLinkRenderedError); + return <>{children}; } const _OrganizationProfile = withClerk( @@ -253,8 +253,8 @@ const _OrganizationSwitcher = withClerk( ); export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, { - OrganizationProfilePage: OrganizationProfilePage, - OrganizationProfileLink: OrganizationProfileLink, + OrganizationProfilePage, + OrganizationProfileLink, }); export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index 590e8b08017..816d275a5c1 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -5,4 +5,4 @@ export { loadClerkJsScript } from './loadClerkJsScript'; export * from './useMaxAllowedInstancesGuard'; export * from './useCustomElementPortal'; export * from './useCustomPages'; -export * from './errorInDevMode'; +export * from './logErrorInDevMode'; diff --git a/packages/react/src/utils/errorInDevMode.ts b/packages/react/src/utils/logErrorInDevMode.ts similarity index 69% rename from packages/react/src/utils/errorInDevMode.ts rename to packages/react/src/utils/logErrorInDevMode.ts index dfa5fd09ba0..8992e997d38 100644 --- a/packages/react/src/utils/errorInDevMode.ts +++ b/packages/react/src/utils/logErrorInDevMode.ts @@ -1,6 +1,6 @@ import { isDevelopmentEnvironment } from '@clerk/shared'; -export const errorInDevMode = (message: string) => { +export const logErrorInDevMode = (message: string) => { if (isDevelopmentEnvironment()) { console.error(message); } diff --git a/packages/react/src/utils/useCustomElementPortal.tsx b/packages/react/src/utils/useCustomElementPortal.tsx index 911977bdc25..72161b1035e 100644 --- a/packages/react/src/utils/useCustomElementPortal.tsx +++ b/packages/react/src/utils/useCustomElementPortal.tsx @@ -19,19 +19,10 @@ export const useCustomElementPortal = (elements: UseCustomElementPortalParams[]) const initialState = Array(elements.length).fill(null); const [nodes, setNodes] = useState<(Element | null)[]>(initialState); - const portals: UseCustomElementPortalReturn[] = []; - - elements.forEach((el, index) => { - const mount = (node: Element) => { - setNodes(prevState => prevState.map((n, i) => (i === index ? node : n))); - }; - const unmount = () => { - setNodes(prevState => prevState.map((n, i) => (i === index ? null : n))); - }; - - const portal = () => <>{nodes[index] ? createPortal(el.component, nodes[index] as Element) : null}; - portals.push({ portal, mount, unmount, id: el.id }); - }); - - return portals; + 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 index c92812ff269..97352e03ac6 100644 --- a/packages/react/src/utils/useCustomPages.tsx +++ b/packages/react/src/utils/useCustomPages.tsx @@ -10,7 +10,7 @@ import { } from '../components/uiComponents'; import { customLinkWrongProps, customPagesIgnoredComponent, customPageWrongProps } from '../errors'; import type { UserProfilePageProps } from '../types'; -import { errorInDevMode } from './errorInDevMode'; +import { logErrorInDevMode } from './logErrorInDevMode'; import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal'; import { useCustomElementPortal } from './useCustomElementPortal'; @@ -62,7 +62,7 @@ const useCustomPages = ({ React.Children.forEach(children, child => { if (!isThatComponent(child, PageComponent) && !isThatComponent(child, LinkComponent)) { if (child) { - errorInDevMode(customPagesIgnoredComponent(componentName)); + logErrorInDevMode(customPagesIgnoredComponent(componentName)); } return; } @@ -79,7 +79,7 @@ const useCustomPages = ({ // this is a custom page validChildren.push({ label, labelIcon, children, url }); } else { - errorInDevMode(customPageWrongProps(componentName)); + logErrorInDevMode(customPageWrongProps(componentName)); return; } } @@ -89,7 +89,7 @@ const useCustomPages = ({ // This is an external link validChildren.push({ label, labelIcon, url }); } else { - errorInDevMode(customLinkWrongProps(componentName)); + logErrorInDevMode(customLinkWrongProps(componentName)); return; } } diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 23c4c460a16..044d1b936a4 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -723,13 +723,7 @@ export type UserProfileProps = { */ additionalOAuthScopes?: Partial>; /* - * Provide addition custom route items and pages to be rendered inside the UserProfile. - * e.g. - * - * C}> - *
Hello from custom page!
- *
- *
+ * Provide custom pages and links to be rendered inside the UserProfile. */ customPages?: CustomPage[]; }; @@ -755,13 +749,7 @@ export type OrganizationProfileProps = { */ appearance?: OrganizationProfileTheme; /* - * Provide addition custom route items and pages to be rendered inside the OrganizationProfile. - * e.g. - * - * C}> - *
Hello from custom page!
- *
- *
+ * Provide custom pages and links to be rendered inside the OrganizationProfile. */ customPages?: CustomPage[]; }; From 43316883c575e89668464bc1129b26ad7dfa89ee Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Fri, 13 Oct 2023 14:59:49 +0300 Subject: [PATCH 13/16] refactor(clerk-react,types): Resolve PR comments --- .changeset/proud-ways-lie.md | 34 ++++++++++++++++- .../OrganizationProfileRoutes.tsx | 5 ++- .../UserProfile/UserProfileRoutes.tsx | 5 ++- packages/clerk-js/src/ui/constants.ts | 9 +++++ .../src/ui/utils/createCustomPages.tsx | 38 ++++++++++--------- .../react/src/components/uiComponents.tsx | 3 +- packages/react/src/utils/index.ts | 1 - packages/react/src/utils/useCustomPages.tsx | 2 +- packages/shared/src/utils/index.ts | 1 + .../src/utils/logErrorInDevMode.ts | 2 +- 10 files changed, 74 insertions(+), 26 deletions(-) create mode 100644 packages/clerk-js/src/ui/constants.ts rename packages/{react => shared}/src/utils/logErrorInDevMode.ts (66%) diff --git a/.changeset/proud-ways-lie.md b/.changeset/proud-ways-lie.md index a659759959b..d099191bd35 100644 --- a/.changeset/proud-ways-lie.md +++ b/.changeset/proud-ways-lie.md @@ -4,4 +4,36 @@ '@clerk/types': minor --- -Introduce Custom Pages in UserProfile and OrganizationProfile +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/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index 8a145de4d90..9c4461392c7 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -1,4 +1,5 @@ import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; +import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID } from '../../constants'; import { Gate } from '../../common/Gate'; import { useOrganizationProfileContext } from '../../contexts'; import { ProfileCardContent } from '../../elements'; @@ -16,8 +17,8 @@ import { VerifyDomainPage } from './VerifyDomainPage'; export const OrganizationProfileRoutes = (props: PropsOfComponent) => { const { pages } = useOrganizationProfileContext(); - const isMembersPageRoot = pages.routes[0].id === 'members'; - const isSettingsPageRoot = pages.routes[0].id === 'settings'; + 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; diff --git a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx index 3f3c41fef97..a9b293e3a30 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/UserProfileRoutes.tsx @@ -1,4 +1,5 @@ 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'; @@ -25,7 +26,9 @@ import { Web3Page } from './Web3Page'; export const UserProfileRoutes = (props: PropsOfComponent) => { const { pages } = useUserProfileContext(); - const isAccountPageRoot = pages.routes[0].id === 'account' || pages.routes[0].id === 'security'; + 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; diff --git a/packages/clerk-js/src/ui/constants.ts b/packages/clerk-js/src/ui/constants.ts new file mode 100644 index 00000000000..17d1dea5121 --- /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/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index e72a909846f..1f509177834 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -2,6 +2,7 @@ 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'; @@ -14,7 +15,7 @@ export type CustomPageContent = { }; type ProfileReorderItem = { - label: 'account' | 'security'; + label: 'account' | 'security' | 'members' | 'settings'; }; type ProfileCustomPage = { @@ -184,6 +185,7 @@ const checkForDuplicateUsageOfReorderingItems = (customPages: CustomPage[], vali return [...acc, cp.label]; }, [] as string[]); }; + //path !== '/' && path !== 'account' const warnForDuplicatePaths = (routes: NavbarRoute[], pathsToFilter: string[]) => { const paths = routes @@ -243,27 +245,27 @@ const getUserProfileDefaultRoutes = (): GetDefaultRoutesReturnType => { const INITIAL_ROUTES: NavbarRoute[] = [ { name: localizationKeys('userProfile.start.headerTitle__account'), - id: 'account', + id: USER_PROFILE_NAVBAR_ROUTE_ID.ACCOUNT, icon: User, path: 'account', }, { name: localizationKeys('userProfile.start.headerTitle__security'), - id: 'security', + id: USER_PROFILE_NAVBAR_ROUTE_ID.SECURITY, icon: TickShield, path: 'account', }, ]; const pageToRootNavbarRouteMap: Record = { - profile: INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'email-address': INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'phone-number': INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'connected-account': INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'web3-wallet': INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, - username: INITIAL_ROUTES.find(r => r.id === 'account') as NavbarRoute, - 'multi-factor': INITIAL_ROUTES.find(r => r.id === 'security') as NavbarRoute, - password: INITIAL_ROUTES.find(r => r.id === 'security') as NavbarRoute, + 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); @@ -275,24 +277,24 @@ const getOrganizationProfileDefaultRoutes = (): GetDefaultRoutesReturnType => { const INITIAL_ROUTES: NavbarRoute[] = [ { name: localizationKeys('organizationProfile.start.headerTitle__members'), - id: 'members', + id: ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS, icon: User, path: 'organization-members', }, { name: localizationKeys('organizationProfile.start.headerTitle__settings'), - id: 'settings', + id: ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS, icon: CogFilled, path: 'organization-settings', }, ]; const pageToRootNavbarRouteMap: Record = { - 'invite-members': INITIAL_ROUTES.find(r => r.id === 'members') as NavbarRoute, - domain: INITIAL_ROUTES.find(r => r.id === 'settings') as NavbarRoute, - profile: INITIAL_ROUTES.find(r => r.id === 'settings') as NavbarRoute, - leave: INITIAL_ROUTES.find(r => r.id === 'settings') as NavbarRoute, - delete: INITIAL_ROUTES.find(r => r.id === 'settings') as NavbarRoute, + '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); diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 58650f3d595..42edc8f1ff2 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, @@ -25,7 +26,7 @@ import type { UserProfilePageProps, WithClerkProp, } from '../types'; -import { logErrorInDevMode, useOrganizationProfileCustomPages, useUserProfileCustomPages } from '../utils'; +import { useOrganizationProfileCustomPages, useUserProfileCustomPages } from '../utils'; import { withClerk } from './withClerk'; type UserProfileExportType = typeof _UserProfile & { diff --git a/packages/react/src/utils/index.ts b/packages/react/src/utils/index.ts index 816d275a5c1..04a02b30856 100644 --- a/packages/react/src/utils/index.ts +++ b/packages/react/src/utils/index.ts @@ -5,4 +5,3 @@ export { loadClerkJsScript } from './loadClerkJsScript'; export * from './useMaxAllowedInstancesGuard'; export * from './useCustomElementPortal'; export * from './useCustomPages'; -export * from './logErrorInDevMode'; diff --git a/packages/react/src/utils/useCustomPages.tsx b/packages/react/src/utils/useCustomPages.tsx index 97352e03ac6..7037a4751c0 100644 --- a/packages/react/src/utils/useCustomPages.tsx +++ b/packages/react/src/utils/useCustomPages.tsx @@ -1,3 +1,4 @@ +import { logErrorInDevMode } from '@clerk/shared'; import type { CustomPage } from '@clerk/types'; import type { ReactElement } from 'react'; import React from 'react'; @@ -10,7 +11,6 @@ import { } from '../components/uiComponents'; import { customLinkWrongProps, customPagesIgnoredComponent, customPageWrongProps } from '../errors'; import type { UserProfilePageProps } from '../types'; -import { logErrorInDevMode } from './logErrorInDevMode'; import type { UseCustomElementPortalParams, UseCustomElementPortalReturn } from './useCustomElementPortal'; import { useCustomElementPortal } from './useCustomElementPortal'; diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index 7e6dcec81b1..8838c7ed4b4 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/react/src/utils/logErrorInDevMode.ts b/packages/shared/src/utils/logErrorInDevMode.ts similarity index 66% rename from packages/react/src/utils/logErrorInDevMode.ts rename to packages/shared/src/utils/logErrorInDevMode.ts index 8992e997d38..ea299ba832f 100644 --- a/packages/react/src/utils/logErrorInDevMode.ts +++ b/packages/shared/src/utils/logErrorInDevMode.ts @@ -1,4 +1,4 @@ -import { isDevelopmentEnvironment } from '@clerk/shared'; +import { isDevelopmentEnvironment } from './runtimeEnvironment'; export const logErrorInDevMode = (message: string) => { if (isDevelopmentEnvironment()) { From 82ed0bc11106d3cb4f673ce87463991271ebf5d3 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Fri, 13 Oct 2023 15:12:43 +0300 Subject: [PATCH 14/16] chore(clerk-js): Lint OrganizationProfileRoutes --- .../OrganizationProfileRoutes.tsx | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index 9c4461392c7..ed0c9c4cecf 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -1,6 +1,6 @@ +import { Gate } from '../../common'; import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID } from '../../constants'; -import { Gate } from '../../common/Gate'; import { useOrganizationProfileContext } from '../../contexts'; import { ProfileCardContent } from '../../elements'; import { Route, Switch } from '../../router'; @@ -48,11 +48,11 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent - - + permission={'org:sys_profile:manage'} + redirectTo='../' + > + +
- - - + permission={'org:sys_domains:manage'} + redirectTo='../../' + > + + + - - - + permission={'org:sys_domains:delete'} + redirectTo='../../' + > + + +
- - - + permission={'org:sys_domains:manage'} + redirectTo='../../' + > + + +
- - + permission={'org:sys_domains:manage'} + redirectTo='../' + > + +
@@ -104,12 +104,12 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent - - - + permission={'org:sys_profile:delete'} + redirectTo='../' + > + + + @@ -122,11 +122,11 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent - - + permission={'org:sys_memberships:manage'} + redirectTo='../' + > + + From 21fbedaddc015395ba3af20f94313d838684f8e3 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Mon, 16 Oct 2023 15:15:13 +0300 Subject: [PATCH 15/16] fix(clerk-js): Fix custom icons props --- .../clerk-js/src/ui/utils/ExternalElementMounter.tsx | 9 +++++++-- packages/clerk-js/src/ui/utils/createCustomPages.tsx | 6 ++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx index a6c63253755..db29be45905 100644 --- a/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx +++ b/packages/clerk-js/src/ui/utils/ExternalElementMounter.tsx @@ -5,7 +5,7 @@ type ExternalElementMounterProps = { unmount: (el?: HTMLDivElement) => void; }; -export const ExternalElementMounter = ({ mount, unmount }: ExternalElementMounterProps) => { +export const ExternalElementMounter = ({ mount, unmount, ...rest }: ExternalElementMounterProps) => { const nodeRef = useRef(null); useEffect(() => { @@ -19,5 +19,10 @@ export const ExternalElementMounter = ({ mount, unmount }: ExternalElementMounte }; }, [nodeRef.current]); - return
; + return ( +
+ ); }; diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index 1f509177834..cff378eaf3d 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -121,10 +121,11 @@ const getRoutesAndContents = ({ customPages, defaultRoutes }: GetRoutesAndConten return { name: cp.label, id: `custom-page-${index}`, - icon: () => ( + icon: props => ( ), path: sanitizeCustomLinkURL(cp.url), @@ -137,10 +138,11 @@ const getRoutesAndContents = ({ customPages, defaultRoutes }: GetRoutesAndConten return { name: cp.label, id: `custom-page-${index}`, - icon: () => ( + icon: props => ( ), path: pageURL, From 3d8b86aa147db6bab7d849c6c7d5b167845f08b1 Mon Sep 17 00:00:00 2001 From: Stefanos Anagnostou Date: Mon, 23 Oct 2023 18:24:36 -0700 Subject: [PATCH 16/16] fix(nextjs): Fix typings issue --- .../src/app-beta/client/ui-components.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/nextjs/src/app-beta/client/ui-components.tsx b/packages/nextjs/src/app-beta/client/ui-components.tsx index f7c4a0d3894..e0ef758fe5f 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;