Skip to content

Commit 51861ad

Browse files
authored
Custom Pages for <UserProfile /> and <OrganizationProfile /> components (#1822)
* feat(clerk-js,clerk-react,types): Introduce Custom Pages in UserProfile * fix(clerk-js): Fix top-level `localizationKeys` evaluation * fix(clerk-react): Fix issue with useCustomPages when making changes on the custom pages in dev * chore(clerk-js): Update bundlewatch.config.json * fix(clerk-react): Fix issue when changing the custom pages length dynamically * feat(clerk-react,clerk-js): Add support for custom pages in OrganizationProfile * fix(clerk-react,clerk-js): Resolve comments for custom pages * test(clerk-js): Add tests for OrganizationProfile custom pages * fix(clerk-js): Add navbar menu for mobile on custom pages * fix(clerk-react): Omit `customPages` property from React package components * refactor(clerk-js): Refactor the UserProfileRoutes and OrganizationProfileRoutes to be more readable * refactor(clerk-react,types): Apply minor refactors suggested by PR comments * refactor(clerk-react,types): Resolve PR comments * chore(clerk-js): Lint OrganizationProfileRoutes * fix(clerk-js): Fix custom icons props * fix(nextjs): Fix typings issue
1 parent f7385e1 commit 51861ad

29 files changed

+1812
-284
lines changed

.changeset/proud-ways-lie.md

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/clerk-react': minor
4+
'@clerk/types': minor
5+
---
6+
7+
Introduce customization in `UserProfile` and `OrganizationProfile`
8+
9+
The `<UserProfile />` component now allows the addition of custom pages and external links to the navigation sidebar. Custom pages can be created using the `<UserProfile.Page>` component, and external links can be added using the `<UserProfile.Link>` component. The default routes, such as `Account` and `Security`, can be reordered.
10+
11+
Example React API usage:
12+
13+
```tsx
14+
<UserProfile>
15+
<UserProfile.Page label="Custom Page" url="custom" labelIcon={<CustomIcon />}>
16+
<MyCustomPageContent />
17+
</UserProfile.Page>
18+
<UserProfile.Link label="External" url="/home" labelIcon={<Icon />} />
19+
<UserProfile.Page label="account" />
20+
<UserProfile.Page label="security" />
21+
</UserProfile>
22+
```
23+
Custom pages and links should be provided as children using the `<UserButton.UserProfilePage>` and `<UserButton.UserProfileLink>` components when using the `UserButton` component.
24+
25+
The `<OrganizationProfile />` component now supports the addition of custom pages and external links to the navigation sidebar. Custom pages can be created using the `<OrganizationProfile.Page>` component, and external links can be added using the `<OrganizationProfile.Link>` component. The default routes, such as `Members` and `Settings`, can be reordered.
26+
27+
Example React API usage:
28+
29+
```tsx
30+
<OrganizationProfile>
31+
<OrganizationProfile.Page label="Custom Page" url="custom" labelIcon={<CustomIcon />}>
32+
<MyCustomPageContent />
33+
</OrganizationProfile.Page>
34+
<OrganizationProfile.Link label="External" url="/home" labelIcon={<Icon />} />
35+
<OrganizationProfile.Page label="members" />
36+
<OrganizationProfile.Page label="settings" />
37+
</OrganizationProfile>
38+
```
39+
Custom pages and links should be provided as children using the `<OrganizationSwitcher.OrganizationProfilePage>` and `<OrganizationSwitcher.OrganizationProfileLink>` components when using the `OrganizationSwitcher` component.

packages/clerk-js/bundlewatch.config.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"files": [
33
{ "path": "./dist/clerk.browser.js", "maxSize": "62kB" },
44
{ "path": "./dist/clerk.headless.js", "maxSize": "43kB" },
5-
{ "path": "./dist/ui-common*.js", "maxSize": "75KB" },
5+
{ "path": "./dist/ui-common*.js", "maxSize": "76KB" },
66
{ "path": "./dist/vendors*.js", "maxSize": "70KB" },
77
{ "path": "./dist/createorganization*.js", "maxSize": "5KB" },
88
{ "path": "./dist/impersonationfab*.js", "maxSize": "5KB" },
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Col, descriptors } from '../customizables';
2+
import { CardAlert, NavbarMenuButtonRow, useCardState, withCardStateProvider } from '../elements';
3+
import type { CustomPageContent } from '../utils';
4+
import { ExternalElementMounter } from '../utils';
5+
6+
export const CustomPageContentContainer = withCardStateProvider(
7+
({ mount, unmount }: Omit<CustomPageContent, 'url'>) => {
8+
const card = useCardState();
9+
return (
10+
<Col
11+
elementDescriptor={descriptors.page}
12+
gap={8}
13+
>
14+
<CardAlert>{card.error}</CardAlert>
15+
<NavbarMenuButtonRow />
16+
<Col
17+
elementDescriptor={descriptors.profilePage}
18+
gap={8}
19+
>
20+
<ExternalElementMounter
21+
mount={mount}
22+
unmount={unmount}
23+
/>
24+
</Col>
25+
</Col>
26+
);
27+
},
28+
);
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,14 @@
11
import React from 'react';
22

3-
import { useCoreOrganization } from '../../contexts';
4-
import type { NavbarRoute } from '../../elements';
3+
import { useCoreOrganization, useOrganizationProfileContext } from '../../contexts';
54
import { Breadcrumbs, NavBar, NavbarContextProvider, OrganizationPreview } from '../../elements';
6-
import { CogFilled, User } from '../../icons';
7-
import { localizationKeys } from '../../localization';
85
import type { PropsOfComponent } from '../../styledSystem';
96

10-
const organizationProfileRoutes: NavbarRoute[] = [
11-
{
12-
name: localizationKeys('organizationProfile.start.headerTitle__members'),
13-
id: 'members',
14-
icon: User,
15-
path: '/',
16-
},
17-
{
18-
name: localizationKeys('organizationProfile.start.headerTitle__settings'),
19-
id: 'settings',
20-
icon: CogFilled,
21-
path: 'organization-settings',
22-
},
23-
];
24-
257
export const OrganizationProfileNavbar = (
268
props: React.PropsWithChildren<Pick<PropsOfComponent<typeof NavBar>, 'contentRef'>>,
279
) => {
2810
const { organization } = useCoreOrganization();
11+
const { pages } = useOrganizationProfileContext();
2912

3013
if (!organization) {
3114
return null;
@@ -41,27 +24,20 @@ export const OrganizationProfileNavbar = (
4124
sx={t => ({ margin: `0 0 ${t.space.$4} ${t.space.$2}` })}
4225
/>
4326
}
44-
routes={organizationProfileRoutes}
27+
routes={pages.routes}
4528
contentRef={props.contentRef}
4629
/>
4730
{props.children}
4831
</NavbarContextProvider>
4932
);
5033
};
5134

52-
const pageToRootNavbarRouteMap = {
53-
'invite-members': organizationProfileRoutes.find(r => r.id === 'members'),
54-
domain: organizationProfileRoutes.find(r => r.id === 'settings'),
55-
profile: organizationProfileRoutes.find(r => r.id === 'settings'),
56-
leave: organizationProfileRoutes.find(r => r.id === 'settings'),
57-
delete: organizationProfileRoutes.find(r => r.id === 'settings'),
58-
};
59-
6035
export const OrganizationProfileBreadcrumbs = (props: Pick<PropsOfComponent<typeof Breadcrumbs>, 'title'>) => {
36+
const { pages } = useOrganizationProfileContext();
6137
return (
6238
<Breadcrumbs
6339
{...props}
64-
pageToRootNavbarRoute={pageToRootNavbarRouteMap}
40+
pageToRootNavbarRoute={pages.pageToRootNavbarRouteMap}
6541
/>
6642
);
6743
};
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { Gate } from '../../common/Gate';
1+
import { Gate } from '../../common';
2+
import { CustomPageContentContainer } from '../../common/CustomPageContentContainer';
3+
import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID } from '../../constants';
4+
import { useOrganizationProfileContext } from '../../contexts';
25
import { ProfileCardContent } from '../../elements';
36
import { Route, Switch } from '../../router';
47
import type { PropsOfComponent } from '../../styledSystem';
@@ -13,100 +16,125 @@ import { VerifiedDomainPage } from './VerifiedDomainPage';
1316
import { VerifyDomainPage } from './VerifyDomainPage';
1417

1518
export const OrganizationProfileRoutes = (props: PropsOfComponent<typeof ProfileCardContent>) => {
19+
const { pages } = useOrganizationProfileContext();
20+
const isMembersPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS;
21+
const isSettingsPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS;
22+
23+
const customPageRoutesWithContents = pages.contents?.map((customPage, index) => {
24+
const shouldFirstCustomItemBeOnRoot = !isSettingsPageRoot && !isMembersPageRoot && index === 0;
25+
return (
26+
<Route
27+
index={shouldFirstCustomItemBeOnRoot}
28+
path={shouldFirstCustomItemBeOnRoot ? undefined : customPage.url}
29+
key={`custom-page-${customPage.url}`}
30+
>
31+
<CustomPageContentContainer
32+
mount={customPage.mount}
33+
unmount={customPage.unmount}
34+
/>
35+
</Route>
36+
);
37+
});
38+
1639
return (
1740
<ProfileCardContent contentRef={props.contentRef}>
18-
<Route path='organization-settings'>
19-
<Switch>
20-
<Route
21-
path='profile'
22-
flowStart
23-
>
24-
<Gate
25-
permission={'org:sys_profile:manage'}
26-
redirectTo='../'
27-
>
28-
<ProfileSettingsPage />
29-
</Gate>
30-
</Route>
31-
<Route
32-
path='domain'
33-
flowStart
34-
>
41+
<Switch>
42+
{customPageRoutesWithContents}
43+
<Route>
44+
<Route path={isSettingsPageRoot ? undefined : 'organization-settings'}>
3545
<Switch>
36-
<Route path=':id/verify'>
46+
<Route
47+
path='profile'
48+
flowStart
49+
>
3750
<Gate
38-
permission={'org:sys_domains:manage'}
39-
redirectTo='../../'
51+
permission={'org:sys_profile:manage'}
52+
redirectTo='../'
4053
>
41-
<VerifyDomainPage />
54+
<ProfileSettingsPage />
4255
</Gate>
4356
</Route>
44-
<Route path=':id/remove'>
45-
<Gate
46-
permission={'org:sys_domains:delete'}
47-
redirectTo='../../'
48-
>
49-
<RemoveDomainPage />
50-
</Gate>
57+
<Route
58+
path='domain'
59+
flowStart
60+
>
61+
<Switch>
62+
<Route path=':id/verify'>
63+
<Gate
64+
permission={'org:sys_domains:manage'}
65+
redirectTo='../../'
66+
>
67+
<VerifyDomainPage />
68+
</Gate>
69+
</Route>
70+
<Route path=':id/remove'>
71+
<Gate
72+
permission={'org:sys_domains:delete'}
73+
redirectTo='../../'
74+
>
75+
<RemoveDomainPage />
76+
</Gate>
77+
</Route>
78+
<Route path=':id'>
79+
<Gate
80+
permission={'org:sys_domains:manage'}
81+
redirectTo='../../'
82+
>
83+
<VerifiedDomainPage />
84+
</Gate>
85+
</Route>
86+
<Route index>
87+
<Gate
88+
permission={'org:sys_domains:manage'}
89+
redirectTo='../'
90+
>
91+
<AddDomainPage />
92+
</Gate>
93+
</Route>
94+
</Switch>
95+
</Route>
96+
<Route
97+
path='leave'
98+
flowStart
99+
>
100+
<LeaveOrganizationPage />
51101
</Route>
52-
<Route path=':id'>
102+
<Route
103+
path='delete'
104+
flowStart
105+
>
53106
<Gate
54-
permission={'org:sys_domains:manage'}
55-
redirectTo='../../'
107+
permission={'org:sys_profile:delete'}
108+
redirectTo='../'
56109
>
57-
<VerifiedDomainPage />
110+
<DeleteOrganizationPage />
58111
</Gate>
59112
</Route>
60113
<Route index>
114+
<OrganizationSettings />
115+
</Route>
116+
</Switch>
117+
</Route>
118+
<Route path={isMembersPageRoot ? undefined : 'organization-members'}>
119+
<Switch>
120+
<Route
121+
path='invite-members'
122+
flowStart
123+
>
61124
<Gate
62-
permission={'org:sys_domains:manage'}
125+
permission={'org:sys_memberships:manage'}
63126
redirectTo='../'
64127
>
65-
<AddDomainPage />
128+
<InviteMembersPage />
66129
</Gate>
67130
</Route>
131+
<Route index>
132+
<OrganizationMembers />
133+
</Route>
68134
</Switch>
69135
</Route>
70-
<Route
71-
path='leave'
72-
flowStart
73-
>
74-
<LeaveOrganizationPage />
75-
</Route>
76-
<Route
77-
path='delete'
78-
flowStart
79-
>
80-
<Gate
81-
permission={'org:sys_profile:delete'}
82-
redirectTo='../'
83-
>
84-
<DeleteOrganizationPage />
85-
</Gate>
86-
</Route>
87-
<Route index>
88-
<OrganizationSettings />
89-
</Route>
90-
</Switch>
91-
</Route>
92-
<Route>
93-
<Switch>
94-
<Route
95-
path='invite-members'
96-
flowStart
97-
>
98-
<Gate
99-
permission={'org:sys_memberships:manage'}
100-
redirectTo='../'
101-
>
102-
<InviteMembersPage />
103-
</Gate>
104-
</Route>
105-
<Route index>
106-
<OrganizationMembers />
107-
</Route>
108-
</Switch>
109-
</Route>
136+
</Route>
137+
</Switch>
110138
</ProfileCardContent>
111139
);
112140
};

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

+34
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { CustomPage } from '@clerk/types';
12
import { describe, it } from '@jest/globals';
23
import React from 'react';
34

@@ -19,4 +20,37 @@ describe('OrganizationProfile', () => {
1920
expect(getByText('Members')).toBeDefined();
2021
expect(getByText('Settings')).toBeDefined();
2122
});
23+
24+
it('includes custom nav items', async () => {
25+
const { wrapper, props } = await createFixtures(f => {
26+
f.withOrganizations();
27+
f.withUser({ email_addresses: ['[email protected]'], organization_memberships: ['Org1'] });
28+
});
29+
30+
const customPages: CustomPage[] = [
31+
{
32+
label: 'Custom1',
33+
url: 'custom1',
34+
mount: () => undefined,
35+
unmount: () => undefined,
36+
mountIcon: () => undefined,
37+
unmountIcon: () => undefined,
38+
},
39+
{
40+
label: 'ExternalLink',
41+
url: '/link',
42+
mountIcon: () => undefined,
43+
unmountIcon: () => undefined,
44+
},
45+
];
46+
47+
props.setProps({ customPages });
48+
49+
const { getByText } = render(<OrganizationProfile />, { wrapper });
50+
expect(getByText('Org1')).toBeDefined();
51+
expect(getByText('Members')).toBeDefined();
52+
expect(getByText('Settings')).toBeDefined();
53+
expect(getByText('Custom1')).toBeDefined();
54+
expect(getByText('ExternalLink')).toBeDefined();
55+
});
2256
});

0 commit comments

Comments
 (0)