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/mosaic-user-profile-export.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
Expand Up @@ -4,7 +4,7 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic';

export default function Page() {
return (
<MosaicProvider>
<MosaicProvider experimental={{ newProfiles: true }}>
<UserButton
customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]}
userProfileProps={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MosaicProvider, UserButton } from '@clerk/mosaic';

export function MosaicUserButton() {
return (
<MosaicProvider>
<MosaicProvider experimental={{ newProfiles: true }}>
<UserButton />
</MosaicProvider>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client';

import { MosaicProvider, OrganizationProfile } from '@clerk/mosaic';

export default function Page() {
return (
<MosaicProvider>
<OrganizationProfile
customPages={[{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }]}
/>
</MosaicProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { MosaicProvider, OrganizationSwitcher } from '@clerk/mosaic';

export default function Page() {
return (
<MosaicProvider>
<MosaicProvider experimental={{ newProfiles: true }}>
<OrganizationSwitcher
customMenuItems={[{ id: 'custom-link', label: 'Custom link', href: '/custom/link-target' }]}
organizationProfileProps={{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client';

import { MosaicProvider, UserProfile } from '@clerk/mosaic';

export default function Page() {
return (
<MosaicProvider>
<UserProfile customPages={[{ label: 'Custom page', path: 'custom-page', content: <p>custom-page-content</p> }]} />
</MosaicProvider>
);
}
82 changes: 82 additions & 0 deletions integration/tests/mosaic/organization-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Organization } from '@clerk/backend';
import type { BrowserContext, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

import type { FakeUser } from '../../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';

testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic OrganizationProfile @mosaic', ({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;
let organization: Organization;
const cleanups: Array<() => Promise<unknown>> = [];

test.beforeAll(async () => {
const u = await createTestUtils({ app });
fakeUser = u.services.users.createFakeUser(test);
cleanups.push(() => fakeUser.deleteIfExists());
const user = await u.services.users.createBapiUser(fakeUser);
organization = await u.services.clerk.organizations.createOrganization({
name: `Mosaic ${Date.now()}`,
createdBy: user.id,
});
cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id));
});

test.afterAll(async () => {
const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()));
await app.teardown();
const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : []));
if (failures.length > 0) {
throw new AggregateError(failures, 'Mosaic OrganizationProfile cleanup failed');
}
});

const path = '/organization-profile';
const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true });

async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
const u = await createTestUtils({ app, page, context });
await page.setViewportSize({ width: 1280, height: 800 });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.page.goToRelative(path);
await u.po.expect.toBeSignedIn();
await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id);
await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id);
await expect(tab(page, 'General')).toBeVisible();
return u;
}

test('renders nothing when signed out', async ({ page, context }) => {
const u = await createTestUtils({ app, page, context });
await u.page.goToRelative(path);
await u.page.waitForClerkJsLoaded();

await expect(tab(page, 'General')).toHaveCount(0);
});

test('opens on the general page with the active organization', async ({ page, context }) => {
await signIn({ page, context });

await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(organization.name).first()).toBeVisible();
});

test('switches to the members page', async ({ page, context }) => {
await signIn({ page, context });

await tab(page, 'Members').click();

await expect(tab(page, 'Members')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(fakeUser.email)).toBeVisible();
});

test('renders a custom page', async ({ page, context }) => {
await signIn({ page, context });

await tab(page, 'Custom page').click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
});
6 changes: 4 additions & 2 deletions integration/tests/mosaic/organization-switcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,13 +86,15 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })(
});

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

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

const profile = page.locator('.cl-organizationProfile-root');
await profile.getByText('Custom page').click();
await expect(page.getByRole('tab', { name: 'General', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.cl-organizationProfile-root')).toHaveCount(0);
await page.getByRole('tab', { name: 'Custom page', exact: true }).click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
},
Expand Down
21 changes: 17 additions & 4 deletions integration/tests/mosaic/user-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
async function openProfileSettings(page: Page) {
await popup(page).getByRole('button', { name: 'Settings' }).click();
const profileSettings = page.getByRole('menuitem', { name: 'Manage profile' });
await profileSettings.or(page.locator('.cl-userProfile-root')).first().waitFor();
const profile = page.getByRole('tab', { name: 'Account', exact: true });
await profileSettings.or(profile).first().waitFor();
if (await profileSettings.isVisible()) {
await profileSettings.click();
}
Expand Down Expand Up @@ -101,14 +102,26 @@ testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic User
await expect(page.getByText('custom-link-target')).toBeVisible();
});

test('opens the Mosaic UserProfile in a modal', async ({ page, context }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await signIn({ page, context });

await trigger(page).click();
await openProfileSettings(page);

await expect(page.getByRole('tab', { name: 'Account', exact: true })).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('.cl-userProfile-root')).toHaveCount(0);
await expect(popup(page)).toBeHidden();
});

test('renders a custom page inside the UserProfile modal', async ({ page, context }) => {
const u = await signIn({ page, context }, '/custom');
await page.setViewportSize({ width: 1280, height: 800 });
await signIn({ page, context }, '/custom');

await trigger(page).click();
await openProfileSettings(page);
await u.po.userProfile.waitForUserProfileModal();

await page.locator('.cl-userProfile-root').getByText('Custom page').click();
await page.getByRole('tab', { name: 'Custom page', exact: true }).click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
});
82 changes: 82 additions & 0 deletions integration/tests/mosaic/user-profile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Organization } from '@clerk/backend';
import type { BrowserContext, Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

import type { FakeUser } from '../../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';

testAgainstRunningApps({ withPattern: ['next.appRouterMosaic.*'] })('Mosaic UserProfile @mosaic', ({ app }) => {
test.describe.configure({ mode: 'serial' });

let fakeUser: FakeUser;
let organization: Organization;
const cleanups: Array<() => Promise<unknown>> = [];

test.beforeAll(async () => {
const u = await createTestUtils({ app });
fakeUser = u.services.users.createFakeUser(test);
cleanups.push(() => fakeUser.deleteIfExists());
const user = await u.services.users.createBapiUser(fakeUser);
organization = await u.services.clerk.organizations.createOrganization({
name: `Mosaic ${Date.now()}`,
createdBy: user.id,
});
cleanups.push(() => u.services.clerk.organizations.deleteOrganization(organization.id));
});

test.afterAll(async () => {
const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()));
await app.teardown();
const failures = results.flatMap(result => (result.status === 'rejected' ? [result.reason] : []));
if (failures.length > 0) {
throw new AggregateError(failures, 'Mosaic UserProfile cleanup failed');
}
});

const path = '/user-profile';
const tab = (page: Page, name: string) => page.getByRole('navigation').getByRole('tab', { name, exact: true });

async function signIn({ page, context }: { page: Page; context: BrowserContext }) {
const u = await createTestUtils({ app, page, context });
await page.setViewportSize({ width: 1280, height: 800 });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.page.goToRelative(path);
await u.po.expect.toBeSignedIn();
await expect(tab(page, 'Account')).toBeVisible();
return u;
}

test('renders nothing when signed out', async ({ page, context }) => {
const u = await createTestUtils({ app, page, context });
await u.page.goToRelative(path);
await u.page.waitForClerkJsLoaded();

await expect(tab(page, 'Account')).toHaveCount(0);
});

test('opens on the account page with the signed-in user', async ({ page, context }) => {
await signIn({ page, context });

await expect(tab(page, 'Account')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(fakeUser.email)).toBeVisible();
});

test("lists the active organization's pages after the account's", async ({ page, context }) => {
await signIn({ page, context });

await page.evaluate(orgId => window.Clerk?.setActive({ organization: orgId }), organization.id);
await page.waitForFunction(orgId => window.Clerk?.organization?.id === orgId, organization.id);

await tab(page, 'General').click();
await expect(tab(page, 'General')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByText(organization.name).first()).toBeVisible();
});

test('renders a custom page', async ({ page, context }) => {
await signIn({ page, context });

await tab(page, 'Custom page').click();
await expect(page.getByText('custom-page-content')).toBeVisible();
});
});
9 changes: 8 additions & 1 deletion packages/mosaic/src/__tests__/feature/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { act, render } from '@testing-library/react';
import type { ReactElement } from 'react';
import { vi } from 'vitest';

import type { MosaicExperimental } from '../../hooks/use-mosaic-experimental';
import type { MosaicLocalization } from '../../localization';
import { MosaicProvider } from '../../mosaic-provider';
import { publishableKey } from './fake-fapi';
Expand All @@ -12,6 +13,7 @@ export async function renderWithClerk(
ui: ReactElement,
options?: Parameters<Clerk['load']>[0],
localization?: MosaicLocalization,
experimental?: MosaicExperimental,
) {
const clerk = new Clerk(publishableKey());
const navigate = vi.fn((_to: string) => Promise.resolve());
Expand All @@ -21,7 +23,12 @@ export async function renderWithClerk(
clerk={clerk}
clerkStatus={clerk.status}
>
<MosaicProvider localization={localization}>{element}</MosaicProvider>
<MosaicProvider
localization={localization}
experimental={experimental}
>
{element}
</MosaicProvider>
</ClerkContextProvider>
);
let current = ui;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useOrganization } from '@clerk/shared/react';
import React from 'react';

import { OrganizationProfile } from './organization-profile';
import type { OrganizationProfileModalContentProps } from './organization-profile.modal.types';

export default function OrganizationProfileModalContent({
options,
close,
}: OrganizationProfileModalContentProps): React.ReactElement {
const { isLoaded, organization } = useOrganization();
const inactive = isLoaded && !organization;

React.useEffect(() => {
if (inactive) {
close();
}
}, [inactive, close]);

const { page, ...props } = options ?? {};
return (
<OrganizationProfile
{...props}
defaultPage={page}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineModalSurface } from '../../modals/define-modal-surface';
import type { OrganizationProfileModalOptions } from './organization-profile.modal.types';

export const organizationProfileModal = defineModalSurface<OrganizationProfileModalOptions>({
id: 'organizationProfile',
variant: 'profile',
load: () => import('./organization-profile.modal-content'),
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { page } from 'vitest/browser';

import { openDialog } from '../../../__tests__/feature/dialog';
import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
import {
fapiClient,
Expand Down Expand Up @@ -373,3 +375,38 @@ describe('OrganizationSwitcher custom trigger badges', () => {
expect(trigger()).toHaveAccessibleName('Open organization menu for Other');
});
});

describe('OrganizationSwitcher with experimental.newProfiles', () => {
async function renderWithNewProfiles(props: OrganizationSwitcherProps = {}) {
await page.viewport(1280, 800);
onTestFinished(() => page.viewport(414, 896));
serveFapi(noOffers());
return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true });
}

it('opens the OrganizationProfile, without the account pages, from Settings', async () => {
const { clerk } = await renderWithNewProfiles();
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile');
const user = await openWithList();

await user.click(screen.getByRole('button', { name: 'Settings' }));

expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull();
expect(openOrganizationProfile).not.toHaveBeenCalled();
});

it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => {
const { navigate } = await renderWithNewProfiles({ afterLeaveOrganizationUrl: '/left' });
const user = await openWithList();

await user.click(screen.getByRole('button', { name: 'Settings' }));
const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), {
role: 'alertdialog',
});
await user.type(within(dialog).getByRole('textbox'), 'Acme');
await user.click(within(dialog).getByRole('button', { name: 'Leave organization' }));

await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left'));
});
});
Loading
Loading