Skip to content

Commit 960f930

Browse files
feat(mosaic): export UserProfile and OrganizationProfile (#10514)
1 parent 16f804d commit 960f930

26 files changed

Lines changed: 672 additions & 36 deletions

File tree

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

‎integration/templates/next-app-router-mosaic/src/app/custom/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic';
44

55
export default function Page() {
66
return (
7-
<MosaicProvider>
7+
<MosaicProvider experimental={{ newProfiles: true }}>
88
<UserButton
99
customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]}
1010
userProfileProps={{

‎integration/templates/next-app-router-mosaic/src/app/mosaic-user-button.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic';
44

55
export function MosaicUserButton() {
66
return (
7-
<MosaicProvider>
7+
<MosaicProvider experimental={{ newProfiles: true }}>
88
<UserButton />
99
</MosaicProvider>
1010
);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client';
2+
3+
import { MosaicProvider, OrganizationProfile } from '@clerk/mosaic';
4+
5+
export default function Page() {
6+
return (
7+
<MosaicProvider>
8+
<OrganizationProfile
9+
customPages={[{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }]}
10+
/>
11+
</MosaicProvider>
12+
);
13+
}

‎integration/templates/next-app-router-mosaic/src/app/organization-switcher/page.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { MosaicProvider, OrganizationSwitcher } from '@clerk/mosaic';
44

55
export default function Page() {
66
return (
7-
<MosaicProvider>
7+
<MosaicProvider experimental={{ newProfiles: true }}>
88
<OrganizationSwitcher
99
customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]}
1010
organizationProfileProps={{
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use client';
2+
3+
import { MosaicProvider, UserProfile } from '@clerk/mosaic';
4+
5+
export default function Page() {
6+
return (
7+
<MosaicProvider>
8+
<UserProfile customPages={[{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }]} />
9+
</MosaicProvider>
10+
);
11+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { Organization } from '@clerk/backend';
2+
import type { BrowserContext, Page } from '@playwright/test';
3+
import { expect, test } from '@playwright/test';
4+
5+
import type { FakeUser } from '../../testUtils';
6+
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
7+
8+
testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic OrganizationProfile @mosaic', ({ app }) => {
9+
test.describe.configure({ mode: 'serial' });
10+
11+
let fakeUser: FakeUser;
12+
let organization: Organization;
13+
const cleanups: Array<() => Promise<unknown>> = [];
14+
15+
test.beforeAll(async () => {
16+
const u = await createTestUtils({ app });
17+
fakeUser = u.services.users.createFakeUser(test);
18+
cleanups.push(() => fakeUser.deleteIfExists());
19+
const user = await u.services.users.createBapiUser(fakeUser);
20+
organization = await u.services.clerk.organizations.createOrganization({
21+
name: `Mosaic ${Date.now()}`,
22+
createdBy: user.id,
23+
});
24+
cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id));
25+
});
26+
27+
test.afterAll(async () => {
28+
const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()));
29+
await app.teardown();
30+
const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : []));
31+
if (failures.length > 0) {
32+
throw new AggregateError(failures, 'Mosaic OrganizationProfile cleanup failed');
33+
}
34+
});
35+
36+
const path = '/organization-profile';
37+
const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true });
38+
39+
async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
40+
const u = await createTestUtils({ app, page, context });
41+
await page.setViewportSize({ width: 1280, height: 800 });
42+
await u.po.signIn.goTo();
43+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
44+
await u.page.goToRelative(path);
45+
await u.po.expect.toBeSignedIn();
46+
await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id);
47+
await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id);
48+
await expect(tab(page, 'General')).toBeVisible();
49+
return u;
50+
}
51+
52+
test('renders nothing when signed out', async ({ page, context }) => {
53+
const u = await createTestUtils({ app, page, context });
54+
await u.page.goToRelative(path);
55+
await u.page.waitForClerkJsLoaded();
56+
57+
await expect(tab(page, 'General')).toHaveCount(0);
58+
});
59+
60+
test('opens on the general page with the active organization', async ({ page, context }) => {
61+
await signIn({ page, context });
62+
63+
await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true');
64+
await expect(page.getByText(organization.name).first()).toBeVisible();
65+
});
66+
67+
test('switches to the members page', async ({ page, context }) => {
68+
await signIn({ page, context });
69+
70+
await tab(page, 'Members').click();
71+
72+
await expect(tab(page, 'Members')).toHaveAttribute('aria-selected', 'true');
73+
await expect(page.getByText(fakeUser.email)).toBeVisible();
74+
});
75+
76+
test('renders a custom page', async ({ page, context }) => {
77+
await signIn({ page, context });
78+
79+
await tab(page, 'Custom page').click();
80+
await expect(page.getByText('custom-page-content')).toBeVisible();
81+
});
82+
});

‎integration/tests/mosaic/organization-switcher.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })(
8686
});
8787

8888
test('renders a custom page inside the OrganizationProfile modal', async ({ page, context }) => {
89+
await page.setViewportSize({ width: 1280, height: 800 });
8990
await signIn({ page, context });
9091

9192
await trigger(page).click();
9293
await popup(page).getByRole('button', { name: 'Settings' }).click();
9394

94-
const profile = page.locator('.cl-organizationProfile-root');
95-
await profile.getByText('Custom page').click();
95+
await expect(page.getByRole('tab', { name: 'General', exact: true })).toHaveAttribute('aria-selected', 'true');
96+
await expect(page.locator('.cl-organizationProfile-root')).toHaveCount(0);
97+
await page.getByRole('tab', { name: 'Custom page', exact: true }).click();
9698
await expect(page.getByText('custom-page-content')).toBeVisible();
9799
});
98100
},

‎integration/tests/mosaic/user-button.test.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
4949
async function openProfileSettings(page: Page) {
5050
await popup(page).getByRole('button', { name: 'Settings' }).click();
5151
const profileSettings = page.getByRole('menuitem', { name: 'Manage profile' });
52-
await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor();
52+
const profile = page.getByRole('tab', { name: 'Account', exact: true });
53+
await profileSettings.or(profile).first().waitFor();
5354
if (await profileSettings.isVisible()) {
5455
await profileSettings.click();
5556
}
@@ -101,14 +102,26 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
101102
await expect(page.getByText('custom-link-target')).toBeVisible();
102103
});
103104

105+
test('opens the Mosaic UserProfile in a modal', async ({ page, context }) => {
106+
await page.setViewportSize({ width: 1280, height: 800 });
107+
await signIn({ page, context });
108+
109+
await trigger(page).click();
110+
await openProfileSettings(page);
111+
112+
await expect(page.getByRole('tab', { name: 'Account', exact: true })).toHaveAttribute('aria-selected', 'true');
113+
await expect(page.locator('.cl-userProfile-root')).toHaveCount(0);
114+
await expect(popup(page)).toBeHidden();
115+
});
116+
104117
test('renders a custom page inside the UserProfile modal', async ({ page, context }) => {
105-
const u = await signIn({ page, context }, '/custom');
118+
await page.setViewportSize({ width: 1280, height: 800 });
119+
await signIn({ page, context }, '/custom');
106120

107121
await trigger(page).click();
108122
await openProfileSettings(page);
109-
await u.po.userProfile.waitForUserProfileModal();
110123

111-
await page.locator('.cl-userProfile-root').getByText('Custom page').click();
124+
await page.getByRole('tab', { name: 'Custom page', exact: true }).click();
112125
await expect(page.getByText('custom-page-content')).toBeVisible();
113126
});
114127
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { Organization } from '@clerk/backend';
2+
import type { BrowserContext, Page } from '@playwright/test';
3+
import { expect, test } from '@playwright/test';
4+
5+
import type { FakeUser } from '../../testUtils';
6+
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
7+
8+
testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic UserProfile @mosaic', ({ app }) => {
9+
test.describe.configure({ mode: 'serial' });
10+
11+
let fakeUser: FakeUser;
12+
let organization: Organization;
13+
const cleanups: Array<() => Promise<unknown>> = [];
14+
15+
test.beforeAll(async () => {
16+
const u = await createTestUtils({ app });
17+
fakeUser = u.services.users.createFakeUser(test);
18+
cleanups.push(() => fakeUser.deleteIfExists());
19+
const user = await u.services.users.createBapiUser(fakeUser);
20+
organization = await u.services.clerk.organizations.createOrganization({
21+
name: `Mosaic ${Date.now()}`,
22+
createdBy: user.id,
23+
});
24+
cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id));
25+
});
26+
27+
test.afterAll(async () => {
28+
const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()));
29+
await app.teardown();
30+
const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : []));
31+
if (failures.length > 0) {
32+
throw new AggregateError(failures, 'Mosaic UserProfile cleanup failed');
33+
}
34+
});
35+
36+
const path = '/user-profile';
37+
const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true });
38+
39+
async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
40+
const u = await createTestUtils({ app, page, context });
41+
await page.setViewportSize({ width: 1280, height: 800 });
42+
await u.po.signIn.goTo();
43+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
44+
await u.page.goToRelative(path);
45+
await u.po.expect.toBeSignedIn();
46+
await expect(tab(page, 'Account')).toBeVisible();
47+
return u;
48+
}
49+
50+
test('renders nothing when signed out', async ({ page, context }) => {
51+
const u = await createTestUtils({ app, page, context });
52+
await u.page.goToRelative(path);
53+
await u.page.waitForClerkJsLoaded();
54+
55+
await expect(tab(page, 'Account')).toHaveCount(0);
56+
});
57+
58+
test('opens on the account page with the signed-in user', async ({ page, context }) => {
59+
await signIn({ page, context });
60+
61+
await expect(tab(page, 'Account')).toHaveAttribute('aria-selected', 'true');
62+
await expect(page.getByText(fakeUser.email)).toBeVisible();
63+
});
64+
65+
test("lists the active organization's pages after the account's", async ({ page, context }) => {
66+
await signIn({ page, context });
67+
68+
await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id);
69+
await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id);
70+
71+
await tab(page, 'General').click();
72+
await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true');
73+
await expect(page.getByText(organization.name).first()).toBeVisible();
74+
});
75+
76+
test('renders a custom page', async ({ page, context }) => {
77+
await signIn({ page, context });
78+
79+
await tab(page, 'Custom page').click();
80+
await expect(page.getByText('custom-page-content')).toBeVisible();
81+
});
82+
});

0 commit comments

Comments
 (0)