Skip to content

Commit ed1fcf3

Browse files
fix(mosaic): reset the open profile page when it leaves the navigation (#10508)
Co-authored-by: Alex Carpenter <alex.carpenter@clerk.dev>
1 parent 6ab1907 commit ed1fcf3

12 files changed

Lines changed: 221 additions & 81 deletions

‎.changeset/shy-pages-share.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎.changeset/thin-trains-lose.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/features/organization-profile/__tests__/organization-profile.feature.test.tsx‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { screen, waitFor, within } from '@testing-library/react';
1+
import { act, screen, waitFor, within } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { beforeAll, describe, expect, it } from 'vitest';
44
import { page } from 'vitest/browser';
@@ -177,4 +177,26 @@ describe('OrganizationProfile', () => {
177177
await waitFor(() => expect(fapi.memberships).toHaveLength(0));
178178
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'));
179179
});
180+
181+
it('keeps the shown page after the open one leaves the navigation', async () => {
182+
const globex = fapiOrganization({ id: 'org_2', name: 'Globex', slug: 'globex' });
183+
const memberships = [
184+
fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }),
185+
fapiMembership(globex, { permissions: [] }),
186+
];
187+
const user = fapiUser({ id: 'user_1', organization_memberships: memberships });
188+
serveFapi({
189+
environment: fapiEnvironment(),
190+
client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]),
191+
memberships,
192+
});
193+
const { clerk } = await renderWithClerk(<OrganizationProfile defaultPage='members' />);
194+
195+
await act(() => clerk.setActive({ organization: globex.id }));
196+
await waitFor(() => expect(tabNames()).toEqual(['General']));
197+
await act(() => clerk.setActive({ organization: acme.id }));
198+
await waitFor(() => expect(tabNames()).toEqual(['General', 'Members']));
199+
200+
expect(profileNav().getByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
201+
});
180202
});

‎packages/mosaic/src/features/organization-profile/organization-profile.layout.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { withUniquePaths } from '../profile-pages/profile-pages.layout';
99
import type {
1010
CustomOrganizationProfilePage,
1111
OrganizationProfilePageId,
12+
OrganizationProfilePageListOptions,
1213
OrganizationProfilePages,
1314
} from './organization-profile.types';
1415

@@ -56,3 +57,15 @@ export function resolveOrganizationProfilePages(
5657
];
5758
return applyOrder(order, entries, entry => entry.id);
5859
}
60+
61+
export function organizationProfileNavEntries({
62+
pages,
63+
customPages,
64+
pageOrder,
65+
}: OrganizationProfilePageListOptions): OrganizationProfileNavEntry[] {
66+
return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder);
67+
}
68+
69+
export function organizationProfilePageIds(options: OrganizationProfilePageListOptions): string[] {
70+
return organizationProfileNavEntries(options).map(entry => entry.id);
71+
}

‎packages/mosaic/src/features/organization-profile/organization-profile.tsx‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client';
22

33
import type { ReactNode } from 'react';
4-
import { useState } from 'react';
54

65
import type { APIKeysPageProps } from '../api-keys/api-keys-table';
6+
import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
7+
import { organizationProfilePageIds } from './organization-profile.layout';
78
import { useOrganizationProfileModel } from './organization-profile.model';
89
import type { OrganizationProfileOptions } from './organization-profile.provider';
910
import { OrganizationProfileProvider } from './organization-profile.provider';
@@ -87,26 +88,27 @@ export function OrganizationProfile({
8788
apiKeysProps,
8889
}: OrganizationProfileProps) {
8990
const model = useOrganizationProfileModel();
90-
const [activePage, setActivePage] = useState<OrganizationProfileViewProps['activePage']>(
91+
const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined;
92+
const navigation = useProfilePagesController(
9193
defaultPage ?? pageOrder?.[0] ?? 'general',
94+
pages && organizationProfilePageIds({ pages, customPages, pageOrder }),
9295
);
9396

9497
if (model.status === 'loading') {
9598
return fallback ?? null;
9699
}
97-
if (model.status === 'hidden') {
100+
if (!pages) {
98101
return null;
99102
}
100103

101104
return (
102105
<OrganizationProfileProvider afterLeaveOrganizationUrl={afterLeaveOrganizationUrl}>
103106
<OrganizationProfileView
104107
elevation='flush'
105-
activePage={activePage}
106-
onPageChange={setActivePage}
108+
{...navigation}
107109
customPages={customPages}
108110
pageOrder={pageOrder}
109-
pages={organizationProfilePanels(model.pages, apiKeysProps)}
111+
pages={pages}
110112
/>
111113
</OrganizationProfileProvider>
112114
);

‎packages/mosaic/src/features/organization-profile/organization-profile.types.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,16 @@ export interface CustomOrganizationProfilePage {
1717
icon?: ReactNode;
1818
content: ReactNode;
1919
}
20+
21+
/** What the organization's navigation is built from. */
22+
export interface OrganizationProfilePageListOptions {
23+
/** The built-in pages. One left undefined is left out of the navigation. */
24+
pages: OrganizationProfilePages;
25+
/** Pages of the consumer's own, added to the navigation after the built-ins. */
26+
customPages?: readonly CustomOrganizationProfilePage[];
27+
/**
28+
* The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids
29+
* left out keep their default place behind the ones named.
30+
*/
31+
pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[];
32+
}

‎packages/mosaic/src/features/organization-profile/organization-profile.view.tsx‎

Lines changed: 17 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,8 @@ import { Icon } from '../../components/icon';
55
import { useMessages } from '../../localization';
66
import type { ProfilePage, ProfilePagesViewProps } from '../profile-pages/profile-pages.view';
77
import { ProfilePagesView } from '../profile-pages/profile-pages.view';
8-
import {
9-
getAvailableOrganizationProfilePages,
10-
ORGANIZATION_PROFILE_PAGE_ICONS,
11-
resolveOrganizationProfilePages,
12-
} from './organization-profile.layout';
13-
import type {
14-
CustomOrganizationProfilePage,
15-
OrganizationProfilePageId,
16-
OrganizationProfilePages,
17-
} from './organization-profile.types';
18-
19-
/** What the organization's navigation is built from. */
20-
export interface OrganizationProfilePageListOptions {
21-
/** The built-in pages. One left undefined is left out of the navigation. */
22-
pages: OrganizationProfilePages;
23-
/** Pages of the consumer's own, added to the navigation after the built-ins. */
24-
customPages?: readonly CustomOrganizationProfilePage[];
25-
/**
26-
* The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids
27-
* left out keep their default place behind the ones named.
28-
*/
29-
pageOrder?: readonly (OrganizationProfilePageId | (string & {}))[];
30-
}
8+
import { ORGANIZATION_PROFILE_PAGE_ICONS, organizationProfileNavEntries } from './organization-profile.layout';
9+
import type { OrganizationProfilePageId, OrganizationProfilePageListOptions } from './organization-profile.types';
3110

3211
export interface OrganizationProfileViewProps
3312
extends
@@ -59,23 +38,22 @@ export const OrganizationProfileView = React.forwardRef<HTMLDivElement, Organiza
5938

6039
/** The organization's navigation, in order: the built-ins it was given content for, then the consumer's pages. */
6140
export function organizationProfilePageList(
62-
{ pages, customPages, pageOrder }: OrganizationProfilePageListOptions,
41+
options: OrganizationProfilePageListOptions,
6342
labels: Record<OrganizationProfilePageId, string>,
6443
): ProfilePage[] {
65-
return resolveOrganizationProfilePages(getAvailableOrganizationProfilePages(pages), customPages, pageOrder).map(
66-
entry =>
67-
entry.custom
68-
? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content }
69-
: {
70-
id: entry.id,
71-
label: labels[entry.id],
72-
icon: (
73-
<Icon
74-
glyph={ORGANIZATION_PROFILE_PAGE_ICONS[entry.id]}
75-
size='sm'
76-
/>
77-
),
78-
content: pages[entry.id],
79-
},
44+
return organizationProfileNavEntries(options).map(entry =>
45+
entry.custom
46+
? { id: entry.id, label: entry.custom.label, icon: entry.custom.icon, content: entry.custom.content }
47+
: {
48+
id: entry.id,
49+
label: labels[entry.id],
50+
icon: (
51+
<Icon
52+
glyph={ORGANIZATION_PROFILE_PAGE_ICONS[entry.id]}
53+
size='sm'
54+
/>
55+
),
56+
content: options.pages[entry.id],
57+
},
8058
);
8159
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useState } from 'react';
2+
3+
export function useProfilePagesController(initialPage: string, pageIds: readonly string[] | undefined) {
4+
const [activePage, setActivePage] = useState(initialPage);
5+
6+
if (pageIds) {
7+
const [firstPage] = pageIds;
8+
if (firstPage !== undefined && !pageIds.includes(activePage)) {
9+
setActivePage(firstPage);
10+
}
11+
}
12+
13+
return { activePage, onPageChange: setActivePage };
14+
}

‎packages/mosaic/src/features/user-profile/__tests__/user-profile.feature.test.tsx‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { screen, waitFor, within } from '@testing-library/react';
1+
import { act, screen, waitFor, within } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
33
import { beforeAll, describe, expect, it } from 'vitest';
44
import { page } from 'vitest/browser';
@@ -281,4 +281,52 @@ describe('UserProfile with an active organization', () => {
281281
);
282282
expect(tabsDescribedBy('Organization')).toEqual(['Audit log', 'General', 'Members']);
283283
});
284+
285+
it('keeps the shown page after the open organization page leaves the navigation', async () => {
286+
const globex = fapiOrganization({ id: 'org_2', name: 'Globex' });
287+
const memberships = [
288+
fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }),
289+
fapiMembership(globex, { permissions: [] }),
290+
];
291+
const user = fapiUser({ id: 'user_1', organization_memberships: memberships });
292+
serveFapi({
293+
environment: fapiEnvironment(),
294+
client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]),
295+
memberships,
296+
});
297+
const { clerk } = await renderWithClerk(<UserProfile defaultPage='organization/members' />);
298+
299+
await act(() => clerk.setActive({ organization: globex.id }));
300+
await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General']));
301+
await act(() => clerk.setActive({ organization: acme.id }));
302+
await waitFor(() => expect(tabsDescribedBy('Organization')).toEqual(['General', 'Members']));
303+
304+
expect(screen.getByRole('tab', { name: 'Account', description: 'Account' })).toHaveAttribute(
305+
'aria-selected',
306+
'true',
307+
);
308+
});
309+
310+
it('stays on the organization page when the next organization lists it too', async () => {
311+
const globex = fapiOrganization({ id: 'org_2', name: 'Globex' });
312+
const memberships = [
313+
fapiMembership(acme, { permissions: ['org:sys_memberships:read'] }),
314+
fapiMembership(globex, { permissions: ['org:sys_memberships:read'] }),
315+
];
316+
const user = fapiUser({ id: 'user_1', organization_memberships: memberships });
317+
serveFapi({
318+
environment: fapiEnvironment(),
319+
client: fapiClient([fapiSession({ id: 'sess_1', user, last_active_organization_id: acme.id })]),
320+
memberships,
321+
});
322+
const { clerk } = await renderWithClerk(<UserProfile defaultPage='organization/members' />);
323+
324+
await act(() => clerk.setActive({ organization: globex.id }));
325+
await waitFor(() => expect(clerk.organization?.id).toBe(globex.id));
326+
327+
expect(screen.getByRole('tab', { name: 'Members', description: 'Organization' })).toHaveAttribute(
328+
'aria-selected',
329+
'true',
330+
);
331+
});
284332
});

‎packages/mosaic/src/features/user-profile/user-profile.layout.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import { logger } from '@clerk/shared/logger';
2+
13
import type { IconComponent } from '../../icons/glyph';
24
import { Code } from '../../icons/glyphs/code';
35
import { CreditCard } from '../../icons/glyphs/credit-card';
46
import { ShieldCheck } from '../../icons/glyphs/shield-check';
57
import { UserCircle } from '../../icons/glyphs/user-circle';
68
import { applyOrder } from '../../utils/apply-order';
9+
import { organizationProfilePageIds } from '../organization-profile/organization-profile.layout';
10+
import type { OrganizationProfilePageListOptions } from '../organization-profile/organization-profile.types';
711
import { withUniquePaths } from '../profile-pages/profile-pages.layout';
812
import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types';
913

@@ -55,3 +59,38 @@ export function resolveUserProfilePages(
5559
];
5660
return applyOrder(order, entries, entry => entry.id);
5761
}
62+
63+
type UserProfilePageListOptions = {
64+
pages: UserProfilePages;
65+
customPages?: readonly CustomProfilePage[];
66+
pageOrder?: readonly string[];
67+
organization?: OrganizationProfilePageListOptions;
68+
};
69+
70+
export function userProfileNavEntries({
71+
pages,
72+
customPages,
73+
pageOrder,
74+
}: UserProfilePageListOptions): UserProfileNavEntry[] {
75+
return resolveUserProfilePages(getAvailableUserProfilePages(pages), withoutOrganizationPaths(customPages), pageOrder);
76+
}
77+
78+
export function userProfilePageIds(options: UserProfilePageListOptions): string[] {
79+
const accountPageIds = userProfileNavEntries(options).map(entry => entry.id);
80+
const organizationPageIds = options.organization
81+
? organizationProfilePageIds(options.organization).map(id => `${ORGANIZATION_PAGE_PREFIX}${id}`)
82+
: [];
83+
return [...accountPageIds, ...organizationPageIds];
84+
}
85+
86+
function withoutOrganizationPaths(customPages: readonly CustomProfilePage[] = []): CustomProfilePage[] {
87+
return customPages.filter(page => {
88+
if (!page.path.startsWith(ORGANIZATION_PAGE_PREFIX)) {
89+
return true;
90+
}
91+
logger.warnOnce(
92+
`[Clerk] UserProfile custom page "${page.path}" uses the reserved "${ORGANIZATION_PAGE_PREFIX}" prefix and was skipped.`,
93+
);
94+
return false;
95+
});
96+
}

0 commit comments

Comments
 (0)