Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/shy-pages-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/thin-trains-lose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { screen, waitFor, within } from '@testing-library/react';
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeAll, describe, expect, it } from 'vitest';
import { page } from 'vitest/browser';
Expand Down Expand Up @@ -177,4 +177,26 @@ describe('OrganizationProfile', () => {
await waitFor(() => expect(fapi.memberships).toHaveLength(0));
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'));
});

it('keeps the shown page after the open one leaves the navigation', async () => {
const globex = fapiOrganization({ id: 'org_2', name: 'Globex', slug: 'globex' });
const memberships = [
fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }),
fapiMembership(globex, { permissions: [] }),
];
const user = fapiUser({ id: 'user_1', organization_memberships: memberships });
serveFapi({
environment: fapiEnvironment(),
client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]),
memberships,
});
const { clerk } = await renderWithClerk(<OrganizationProfile defaultPage='members' />);

await act(() => clerk.setActive({ organization: globex.id }));
await waitFor(() => expect(tabNames()).toEqual(['General']));
await act(() => clerk.setActive({ organization: acme.id }));
await waitFor(() => expect(tabNames()).toEqual(['General', 'Members']));

expect(profileNav().getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { withUniquePaths } from '../profile-pages/profile-pages.layout';
import type {
CustomOrganizationProfilePage,
OrganizationProfilePageId,
OrganizationProfilePageListOptions,
OrganizationProfilePages,
} from './organization-profile.types';

Expand Down Expand Up @@ -56,3 +57,15 @@ export function resolveOrganizationProfilePages(
];
return applyOrder(order, entries, entry => entry.id);
}

export function organizationProfileNavEntries({
pages,
customPages,
pageOrder,
}: OrganizationProfilePageListOptions): OrganizationProfileNavEntry[] {
return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder);
}

export function organizationProfilePageIds(options: OrganizationProfilePageListOptions): string[] {
return organizationProfileNavEntries(options).map(entry => entry.id);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';

import type { ReactNode } from 'react';
import { useState } from 'react';

import type { APIKeysPageProps } from '../api-keys/api-keys-table';
import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
import { organizationProfilePageIds } from './organization-profile.layout';
import { useOrganizationProfileModel } from './organization-profile.model';
import type { OrganizationProfileOptions } from './organization-profile.provider';
import { OrganizationProfileProvider } from './organization-profile.provider';
Expand Down Expand Up @@ -87,26 +88,27 @@ export function OrganizationProfile({
apiKeysProps,
}: OrganizationProfileProps) {
const model = useOrganizationProfileModel();
const [activePage, setActivePage] = useState<OrganizationProfileViewProps['activePage']>(
const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined;
const navigation = useProfilePagesController(
defaultPage ?? pageOrder?.[0] ?? 'general',
pages && organizationProfilePageIds({ pages, customPages, pageOrder }),
);

if (model.status === 'loading') {
return fallback ?? null;
}
if (model.status === 'hidden') {
if (!pages) {
return null;
}

return (
<OrganizationProfileProvider afterLeaveOrganizationUrl={afterLeaveOrganizationUrl}>
<OrganizationProfileView
elevation='flush'
activePage={activePage}
onPageChange={setActivePage}
{...navigation}
customPages={customPages}
pageOrder={pageOrder}
pages={organizationProfilePanels(model.pages, apiKeysProps)}
pages={pages}
/>
</OrganizationProfileProvider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,16 @@ export interface CustomOrganizationProfilePage {
icon?: ReactNode;
content: ReactNode;
}

/** What the organization's navigation is built from. */
export interface OrganizationProfilePageListOptions {
/** The built-in pages. One left undefined is left out of the navigation. */
pages: OrganizationProfilePages;
/** Pages of the consumer's own, added to the navigation after the built-ins. */
customPages?: readonly CustomOrganizationProfilePage[];
/**
* The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids
* left out keep their default place behind the ones named.
*/
pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,8 @@ import { Icon } from '../../components/icon';
import { useMessages } from '../../localization';
import type { ProfilePage, ProfilePagesViewProps } from '../profile-pages/profile-pages.view';
import { ProfilePagesView } from '../profile-pages/profile-pages.view';
import {
getAvailableOrganizationProfilePages,
ORGANIZATION_PROFILE_PAGE_ICONS,
resolveOrganizationProfilePages,
} from './organization-profile.layout';
import type {
CustomOrganizationProfilePage,
OrganizationProfilePageId,
OrganizationProfilePages,
} from './organization-profile.types';

/** What the organization's navigation is built from. */
export interface OrganizationProfilePageListOptions {
/** The built-in pages. One left undefined is left out of the navigation. */
pages: OrganizationProfilePages;
/** Pages of the consumer's own, added to the navigation after the built-ins. */
customPages?: readonly CustomOrganizationProfilePage[];
/**
* The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids
* left out keep their default place behind the ones named.
*/
pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[];
}
import { ORGANIZATION_PROFILE_PAGE_ICONS, organizationProfileNavEntries } from './organization-profile.layout';
import type { OrganizationProfilePageId, OrganizationProfilePageListOptions } from './organization-profile.types';

export interface OrganizationProfileViewProps
extends
Expand Down Expand Up @@ -59,23 +38,22 @@ export const OrganizationProfileView = React.forwardRef<HTMLDivElement, Organiza

/** The organization's navigation, in order: the built-ins it was given content for, then the consumer's pages. */
export function organizationProfilePageList(
{ pages, customPages, pageOrder }: OrganizationProfilePageListOptions,
options: OrganizationProfilePageListOptions,
labels: Record<OrganizationProfilePageId, string>,
): ProfilePage[] {
return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map(
entry =>
entry.custom
? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content }
: {
id: entry.id,
label: labels[entry.id],
icon: (
<Icon
glyph={ORGANIZATION_PROFILE_PAGE_ICONS[entry.id]}
size='sm'
/>
),
content: pages[entry.id],
},
return organizationProfileNavEntries(options).map(entry =>
entry.custom
? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content }
: {
id: entry.id,
label: labels[entry.id],
icon: (
<Icon
glyph={ORGANIZATION_PROFILE_PAGE_ICONS[entry.id]}
size='sm'
/>
),
content: options.pages[entry.id],
},
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useState } from 'react';

export function useProfilePagesController(initialPage: string, pageIds: readonly string[] | undefined) {
const [activePage, setActivePage] = useState(initialPage);

if (pageIds) {
const [firstPage] = pageIds;
if (firstPage !== undefined && !pageIds.includes(activePage)) {
setActivePage(firstPage);
}
}

return { activePage, onPageChange: setActivePage };
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { screen, waitFor, within } from '@testing-library/react';
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeAll, describe, expect, it } from 'vitest';
import { page } from 'vitest/browser';
Expand Down Expand Up @@ -281,4 +281,52 @@ describe('UserProfile with an active organization', () => {
);
expect(tabsDescribedBy('Organization')).toEqual(['Audit log', 'General', 'Members']);
});

it('keeps the shown page after the open organization page leaves the navigation', async () => {
const globex = fapiOrganization({ id: 'org_2', name: 'Globex' });
const memberships = [
fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }),
fapiMembership(globex, { permissions: [] }),
];
const user = fapiUser({ id: 'user_1', organization_memberships: memberships });
serveFapi({
environment: fapiEnvironment(),
client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]),
memberships,
});
const { clerk } = await renderWithClerk(<UserProfile defaultPage='organization/members' />);

await act(() => clerk.setActive({ organization: globex.id }));
await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General']));
await act(() => clerk.setActive({ organization: acme.id }));
await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General', 'Members']));

expect(screen.getByRole('tab', { name: 'Account', description: 'Account' })).toHaveAttribute(
'aria-selected',
'true',
);
});

it('stays on the organization page when the next organization lists it too', async () => {
const globex = fapiOrganization({ id: 'org_2', name: 'Globex' });
const memberships = [
fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }),
fapiMembership(globex, { permissions: ['org:sys_memberships:read'] }),
];
const user = fapiUser({ id: 'user_1', organization_memberships: memberships });
serveFapi({
environment: fapiEnvironment(),
client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]),
memberships,
});
const { clerk } = await renderWithClerk(<UserProfile defaultPage='organization/members' />);

await act(() => clerk.setActive({ organization: globex.id }));
await waitFor(() => expect(clerk.organization?.id).toBe(globex.id));

expect(screen.getByRole('tab', { name: 'Members', description: 'Organization' })).toHaveAttribute(
'aria-selected',
'true',
);
});
});
39 changes: 39 additions & 0 deletions packages/mosaic/src/features/user-profile/user-profile.layout.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { logger } from '@clerk/shared/logger';

import type { IconComponent } from '../../icons/glyph';
import { Code } from '../../icons/glyphs/code';
import { CreditCard } from '../../icons/glyphs/credit-card';
import { ShieldCheck } from '../../icons/glyphs/shield-check';
import { UserCircle } from '../../icons/glyphs/user-circle';
import { applyOrder } from '../../utils/apply-order';
import { organizationProfilePageIds } from '../organization-profile/organization-profile.layout';
import type { OrganizationProfilePageListOptions } from '../organization-profile/organization-profile.types';
import { withUniquePaths } from '../profile-pages/profile-pages.layout';
import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types';

Expand Down Expand Up @@ -55,3 +59,38 @@ export function resolveUserProfilePages(
];
return applyOrder(order, entries, entry => entry.id);
}

type UserProfilePageListOptions = {
pages: UserProfilePages;
customPages?: readonly CustomProfilePage[];
pageOrder?: readonly string[];
organization?: OrganizationProfilePageListOptions;
};

export function userProfileNavEntries({
pages,
customPages,
pageOrder,
}: UserProfilePageListOptions): UserProfileNavEntry[] {
return resolveUserProfilePages(getAvailableUserProfilePages(pages), withoutOrganizationPaths(customPages), pageOrder);
}

export function userProfilePageIds(options: UserProfilePageListOptions): string[] {
const accountPageIds = userProfileNavEntries(options).map(entry => entry.id);
const organizationPageIds = options.organization
? organizationProfilePageIds(options.organization).map(id => `${ORGANIZATION_PAGE_PREFIX}${id}`)
: [];
return [...accountPageIds, ...organizationPageIds];
}

function withoutOrganizationPaths(customPages: readonly CustomProfilePage[] = []): CustomProfilePage[] {
return customPages.filter(page => {
if (!page.path.startsWith(ORGANIZATION_PAGE_PREFIX)) {
return true;
}
logger.warnOnce(
`[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix and was skipped.`,
);
return false;
});
}
Loading
Loading