Skip to content

Commit

Permalink
Custom Pages for <UserProfile /> and <OrganizationProfile /> comp…
Browse files Browse the repository at this point in the history
…onents (#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
  • Loading branch information
anagstef authored Oct 24, 2023
1 parent f7385e1 commit 51861ad
Show file tree
Hide file tree
Showing 29 changed files with 1,812 additions and 284 deletions.
39 changes: 39 additions & 0 deletions .changeset/proud-ways-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@clerk/clerk-js': minor
'@clerk/clerk-react': minor
'@clerk/types': minor
---

Introduce customization in `UserProfile` and `OrganizationProfile`

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.

Example React API usage:

```tsx
<UserProfile>
<UserProfile.Page label="Custom Page" url="custom" labelIcon={<CustomIcon />}>
<MyCustomPageContent />
</UserProfile.Page>
<UserProfile.Link label="External" url="/home" labelIcon={<Icon />} />
<UserProfile.Page label="account" />
<UserProfile.Page label="security" />
</UserProfile>
```
Custom pages and links should be provided as children using the `<UserButton.UserProfilePage>` and `<UserButton.UserProfileLink>` components when using the `UserButton` component.

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.

Example React API usage:

```tsx
<OrganizationProfile>
<OrganizationProfile.Page label="Custom Page" url="custom" labelIcon={<CustomIcon />}>
<MyCustomPageContent />
</OrganizationProfile.Page>
<OrganizationProfile.Link label="External" url="/home" labelIcon={<Icon />} />
<OrganizationProfile.Page label="members" />
<OrganizationProfile.Page label="settings" />
</OrganizationProfile>
```
Custom pages and links should be provided as children using the `<OrganizationSwitcher.OrganizationProfilePage>` and `<OrganizationSwitcher.OrganizationProfileLink>` components when using the `OrganizationSwitcher` component.
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
28 changes: 28 additions & 0 deletions packages/clerk-js/src/ui/common/CustomPageContentContainer.tsx
Original file line number Diff line number Diff line change
@@ -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<CustomPageContent, 'url'>) => {
const card = useCardState();
return (
<Col
elementDescriptor={descriptors.page}
gap={8}
>
<CardAlert>{card.error}</CardAlert>
<NavbarMenuButtonRow />
<Col
elementDescriptor={descriptors.profilePage}
gap={8}
>
<ExternalElementMounter
mount={mount}
unmount={unmount}
/>
</Col>
</Col>
);
},
);
Original file line number Diff line number Diff line change
@@ -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<Pick<PropsOfComponent<typeof NavBar>, 'contentRef'>>,
) => {
const { organization } = useCoreOrganization();
const { pages } = useOrganizationProfileContext();

if (!organization) {
return null;
Expand All @@ -41,27 +24,20 @@ export const OrganizationProfileNavbar = (
sx={t => ({ margin: `0 0 ${t.space.$4} ${t.space.$2}` })}
/>
}
routes={organizationProfileRoutes}
routes={pages.routes}
contentRef={props.contentRef}
/>
{props.children}
</NavbarContextProvider>
);
};

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<PropsOfComponent<typeof Breadcrumbs>, 'title'>) => {
const { pages } = useOrganizationProfileContext();
return (
<Breadcrumbs
{...props}
pageToRootNavbarRoute={pageToRootNavbarRouteMap}
pageToRootNavbarRoute={pages.pageToRootNavbarRouteMap}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Gate } from '../../common/Gate';
import { Gate } from '../../common';
import { CustomPageContentContainer } from '../../common/CustomPageContentContainer';
import { ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID } from '../../constants';
import { useOrganizationProfileContext } from '../../contexts';
import { ProfileCardContent } from '../../elements';
import { Route, Switch } from '../../router';
import type { PropsOfComponent } from '../../styledSystem';
Expand All @@ -13,100 +16,125 @@ import { VerifiedDomainPage } from './VerifiedDomainPage';
import { VerifyDomainPage } from './VerifyDomainPage';

export const OrganizationProfileRoutes = (props: PropsOfComponent<typeof ProfileCardContent>) => {
const { pages } = useOrganizationProfileContext();
const isMembersPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS;
const isSettingsPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.SETTINGS;

const customPageRoutesWithContents = pages.contents?.map((customPage, index) => {
const shouldFirstCustomItemBeOnRoot = !isSettingsPageRoot && !isMembersPageRoot && index === 0;
return (
<Route
index={shouldFirstCustomItemBeOnRoot}
path={shouldFirstCustomItemBeOnRoot ? undefined : customPage.url}
key={`custom-page-${customPage.url}`}
>
<CustomPageContentContainer
mount={customPage.mount}
unmount={customPage.unmount}
/>
</Route>
);
});

return (
<ProfileCardContent contentRef={props.contentRef}>
<Route path='organization-settings'>
<Switch>
<Route
path='profile'
flowStart
>
<Gate
permission={'org:sys_profile:manage'}
redirectTo='../'
>
<ProfileSettingsPage />
</Gate>
</Route>
<Route
path='domain'
flowStart
>
<Switch>
{customPageRoutesWithContents}
<Route>
<Route path={isSettingsPageRoot ? undefined : 'organization-settings'}>
<Switch>
<Route path=':id/verify'>
<Route
path='profile'
flowStart
>
<Gate
permission={'org:sys_domains:manage'}
redirectTo='../../'
permission={'org:sys_profile:manage'}
redirectTo='../'
>
<VerifyDomainPage />
<ProfileSettingsPage />
</Gate>
</Route>
<Route path=':id/remove'>
<Gate
permission={'org:sys_domains:delete'}
redirectTo='../../'
>
<RemoveDomainPage />
</Gate>
<Route
path='domain'
flowStart
>
<Switch>
<Route path=':id/verify'>
<Gate
permission={'org:sys_domains:manage'}
redirectTo='../../'
>
<VerifyDomainPage />
</Gate>
</Route>
<Route path=':id/remove'>
<Gate
permission={'org:sys_domains:delete'}
redirectTo='../../'
>
<RemoveDomainPage />
</Gate>
</Route>
<Route path=':id'>
<Gate
permission={'org:sys_domains:manage'}
redirectTo='../../'
>
<VerifiedDomainPage />
</Gate>
</Route>
<Route index>
<Gate
permission={'org:sys_domains:manage'}
redirectTo='../'
>
<AddDomainPage />
</Gate>
</Route>
</Switch>
</Route>
<Route
path='leave'
flowStart
>
<LeaveOrganizationPage />
</Route>
<Route path=':id'>
<Route
path='delete'
flowStart
>
<Gate
permission={'org:sys_domains:manage'}
redirectTo='../../'
permission={'org:sys_profile:delete'}
redirectTo='../'
>
<VerifiedDomainPage />
<DeleteOrganizationPage />
</Gate>
</Route>
<Route index>
<OrganizationSettings />
</Route>
</Switch>
</Route>
<Route path={isMembersPageRoot ? undefined : 'organization-members'}>
<Switch>
<Route
path='invite-members'
flowStart
>
<Gate
permission={'org:sys_domains:manage'}
permission={'org:sys_memberships:manage'}
redirectTo='../'
>
<AddDomainPage />
<InviteMembersPage />
</Gate>
</Route>
<Route index>
<OrganizationMembers />
</Route>
</Switch>
</Route>
<Route
path='leave'
flowStart
>
<LeaveOrganizationPage />
</Route>
<Route
path='delete'
flowStart
>
<Gate
permission={'org:sys_profile:delete'}
redirectTo='../'
>
<DeleteOrganizationPage />
</Gate>
</Route>
<Route index>
<OrganizationSettings />
</Route>
</Switch>
</Route>
<Route>
<Switch>
<Route
path='invite-members'
flowStart
>
<Gate
permission={'org:sys_memberships:manage'}
redirectTo='../'
>
<InviteMembersPage />
</Gate>
</Route>
<Route index>
<OrganizationMembers />
</Route>
</Switch>
</Route>
</Route>
</Switch>
</ProfileCardContent>
);
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { CustomPage } from '@clerk/types';
import { describe, it } from '@jest/globals';
import React from 'react';

Expand All @@ -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(<OrganizationProfile />, { wrapper });
expect(getByText('Org1')).toBeDefined();
expect(getByText('Members')).toBeDefined();
expect(getByText('Settings')).toBeDefined();
expect(getByText('Custom1')).toBeDefined();
expect(getByText('ExternalLink')).toBeDefined();
});
});
Loading

0 comments on commit 51861ad

Please sign in to comment.