Skip to content
Open
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-button-profile-props.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 @@ -87,8 +87,9 @@ vi.mock('@clerk/shared/react', async importOriginal => {
displayConfig: { afterSwitchSessionUrl: '/after-switch' },
authConfig: { singleSessionMode },
organizationSettings: { enabled: organizationsEnabled, forceOrganizationSelection },
commerceSettings: { billing: { user: { enabled: false } } },
apiKeysSettings: { user_api_keys_enabled: false },
commerceSettings: { billing: { user: { enabled: false }, organization: { enabled: false } } },
apiKeysSettings: { user_api_keys_enabled: false, orgs_api_keys_enabled: false },
userSettings: { enterpriseSSO: { self_serve_sso: false } },
Comment thread
alexcarpenter marked this conversation as resolved.
},
}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-li
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { useOrganizationListInView } from '../../../hooks/useOrganizationListInView';
import type { UserButtonModelOptions } from '../user-button.model';
import type { UserButtonModalProps, UserButtonModelOptions } from '../user-button.model';
import { useUserButtonModel } from '../user-button.model';

interface FakeUser {
Expand Down Expand Up @@ -188,8 +188,8 @@ afterEach(() => {
vi.clearAllMocks();
});

function Harness({ customPages, ...options }: UserButtonModelOptions & { customPages?: CustomPage[] } = {}) {
const c = useUserButtonModel(options, customPages);
function Harness({ modals, ...options }: UserButtonModelOptions & { modals?: UserButtonModalProps } = {}) {
const c = useUserButtonModel(options, modals);
if (c.status !== 'ready') {
return <output data-testid='status'>{c.status}</output>;
}
Expand Down Expand Up @@ -640,6 +640,25 @@ describe('useUserButtonModel', () => {
expect(decorateUrl).not.toHaveBeenCalled();
});

it('prefers the signInUrl prop over the instance sign-in URL for add-account and task routing', async () => {
render(<Harness signInUrl='/join' />);

fireEvent.click(screen.getByText('add-account'));
expect(navigate).toHaveBeenCalledWith('/join');

fireEvent.click(screen.getByText('switch'));
const navigateOnSetActive = setActive.mock.calls[0][0].navigate;
await act(async () => {
await navigateOnSetActive({
session: { currentTask: { key: 'choose-organization' } },
decorateUrl: (url: string) => url,
});
});
expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/join'));
expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/tasks/choose-organization'));
expect(navigate).not.toHaveBeenCalledWith(expect.stringContaining('/sign-in'));
});

it('prefers the afterSwitchSessionUrl prop over the instance URL', async () => {
render(<Harness afterSwitchSessionUrl='/app-switch' />);
fireEvent.click(screen.getByText('switch'));
Expand Down Expand Up @@ -723,13 +742,62 @@ describe('useUserButtonModel', () => {
unmountIcon: vi.fn(),
},
];
render(<Harness customPages={customPages} />);
render(<Harness modals={{ userProfile: { customPages } }} />);

fireEvent.click(screen.getByText('manage-account'));

expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages });
});

it('hands the user profile modal its OAuth scopes, API keys options, and appearance', () => {
const additionalOAuthScopes = { google: ['https://www.googleapis.com/auth/calendar'] };
const apiKeysProps = { showDescription: true, hide: false };
const appearance = { variables: { colorPrimary: 'red' } };
render(<Harness modals={{ userProfile: { additionalOAuthScopes, apiKeysProps, appearance } }} />);

fireEvent.click(screen.getByText('manage-account'));

expect(openUserProfile).toHaveBeenCalledWith({ getContainer, additionalOAuthScopes, apiKeysProps, appearance });
});

it('hands the organization profile modal its custom pages, appearance, and where leaving lands', () => {
const customPages: CustomPage[] = [{ label: 'members' }];
const appearance = { variables: { colorPrimary: 'red' } };
render(
<Harness
afterLeaveOrganizationUrl='/left'
modals={{ organizationProfile: { customPages, appearance } }}
/>,
);

fireEvent.click(screen.getByText('manage-org'));

expect(openOrganizationProfile).toHaveBeenCalledWith({
getContainer,
customPages,
appearance,
afterLeaveOrganizationUrl: '/left',
});
});

// clerk-js fills the `:param` template itself once the organization exists.
it('hands the create-organization modal where creating lands and whether to skip inviting', () => {
render(
<Harness
afterCreateOrganizationUrl='/orgs/:slug'
skipInvitationScreen
/>,
);

fireEvent.click(screen.getByText('create-org'));

expect(openCreateOrganization).toHaveBeenCalledWith({
getContainer,
afterCreateOrganizationUrl: '/orgs/:slug',
skipInvitationScreen: true,
});
});

// A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass
// alongside it. The two are resolved apart, so routing one profile leaves the other a modal.
it('navigates to a profile URL when one is given, and only for that profile', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
import type * as SharedReact from '@clerk/shared/react';
import type { CustomPage } from '@clerk/shared/types';
import { act, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { act, render, renderHook, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { CustomProfileItem } from '../../user-profile/user-profile.types';
import type { CustomPagesOptions } from '../user-button.pages';
import { useCustomPages } from '../user-button.pages';
import { useCustomPages, useOrganizationProfilePages, useUserProfilePages } from '../user-button.pages';

let selfServeSSOEnabled: boolean;
let environment: {
commerceSettings: { billing: { user: { enabled: boolean }; organization: { enabled: boolean } } };
apiKeysSettings: { user_api_keys_enabled: boolean; orgs_api_keys_enabled: boolean };
userSettings: { enterpriseSSO: { self_serve_sso: boolean } };
};

vi.mock('@clerk/shared/react', async importOriginal => {
const actual = await importOriginal<typeof SharedReact>();
return {
...actual,
useClerk: () => ({ organization: { selfServeSSOEnabled } }),
};
});

vi.mock('../../hooks/useMosaicEnvironment', () => ({
useMosaicEnvironment: () => environment,
}));

// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to
// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the
Expand Down Expand Up @@ -45,6 +65,54 @@ const docs: CustomProfileItem = {

beforeEach(() => {
emitted = undefined;
selfServeSSOEnabled = false;
environment = {
commerceSettings: { billing: { user: { enabled: false }, organization: { enabled: false } } },
apiKeysSettings: { user_api_keys_enabled: false, orgs_api_keys_enabled: false },
userSettings: { enterpriseSSO: { self_serve_sso: false } },
};
});

describe('useUserProfilePages', () => {
it('lists the pages every instance has', () => {
expect(renderHook(() => useUserProfilePages()).result.current).toEqual(['account', 'security']);
});

it('adds billing and API keys once the instance turns them on', () => {
environment.commerceSettings.billing.user.enabled = true;
environment.apiKeysSettings.user_api_keys_enabled = true;
expect(renderHook(() => useUserProfilePages()).result.current).toEqual([
'account',
'security',
'billing',
'apiKeys',
]);
});
});

describe('useOrganizationProfilePages', () => {
it('lists the pages every instance has', () => {
expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual(['general', 'members']);
});

it('adds billing and API keys once the instance turns them on', () => {
environment.commerceSettings.billing.organization.enabled = true;
environment.apiKeysSettings.orgs_api_keys_enabled = true;
expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual([
'general',
'members',
'billing',
'apiKeys',
]);
});

it('adds security only when the instance and the active organization both allow self-serve SSO', () => {
environment.userSettings.enterpriseSSO.self_serve_sso = true;
expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual(['general', 'members']);

selfServeSSOEnabled = true;
expect(renderHook(() => useOrganizationProfilePages()).result.current).toEqual(['general', 'members', 'security']);
});
});

describe('useCustomPages', () => {
Expand Down
86 changes: 68 additions & 18 deletions packages/ui/src/mosaic/user-button/__tests__/user-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,22 @@ import type { UserButtonController } from '../user-button.controller';

let controller: UserButtonController;

vi.mock('../user-button.model', () => ({
useUserButtonModel: () => ({ status: 'loading' }),
const { useUserButtonModel, useCustomPages } = vi.hoisted(() => ({
useUserButtonModel: vi.fn(() => ({ status: 'loading' })),
useCustomPages: vi.fn(),
}));

vi.mock('../user-button.model', () => ({ useUserButtonModel }));

vi.mock('../user-button.controller', () => ({
useUserButtonController: () => controller,
}));

// The custom pages outlive the popup, so the wrapper renders their portals in every state.
// The two bridges are told apart by the built-in page list each was given.
vi.mock('../user-button.pages', () => ({
useUserProfilePages: () => [],
useCustomPages: () => ({
customPages: undefined,
portals: [
<output
key='p'
data-testid='portal'
/>,
],
}),
useUserProfilePages: () => ['account'],
useOrganizationProfilePages: () => ['general'],
useCustomPages,
}));

// The wrapper's own job is which of the three controller states renders what, so the surface is
Expand Down Expand Up @@ -53,6 +49,16 @@ function ready(): UserButtonController {
describe('UserButton', () => {
beforeEach(() => {
controller = { status: 'loading' };
useUserButtonModel.mockClear();
useCustomPages.mockImplementation(({ builtInPages }: { builtInPages: readonly string[] }) => ({
customPages: [{ label: `${builtInPages[0]}-page` }],
portals: [
<output
key={builtInPages[0]}
data-testid={`${builtInPages[0]}-portal`}
/>,
],
}));
});

it('stands the fallback in while Clerk is still answering', () => {
Expand Down Expand Up @@ -83,17 +89,61 @@ describe('UserButton', () => {
expect(screen.queryByTestId('view')).not.toBeInTheDocument();
});

// The profile can be open in clerk-js's own root while the button itself has nothing to render.
it('keeps the custom page portals mounted in every state', () => {
it('keeps the custom page portals of both profiles mounted in every state', () => {
const { rerender } = render(<UserButton />);
expect(screen.getByTestId('portal')).toBeInTheDocument();
expect(screen.getByTestId('account-portal')).toBeInTheDocument();
expect(screen.getByTestId('general-portal')).toBeInTheDocument();

controller = { status: 'hidden' };
rerender(<UserButton />);
expect(screen.getByTestId('portal')).toBeInTheDocument();
expect(screen.getByTestId('account-portal')).toBeInTheDocument();
expect(screen.getByTestId('general-portal')).toBeInTheDocument();

controller = ready();
rerender(<UserButton />);
expect(screen.getByTestId('portal')).toBeInTheDocument();
expect(screen.getByTestId('account-portal')).toBeInTheDocument();
expect(screen.getByTestId('general-portal')).toBeInTheDocument();
});

it('hands the model each profile modal its props, and keeps the routing options apart', () => {
const appearance = { variables: { colorPrimary: 'red' } };
const additionalOAuthScopes = { google: ['https://www.googleapis.com/auth/calendar'] };
const apiKeysProps = { showDescription: true };
render(
<UserButton
afterLeaveOrganizationUrl='/left'
userProfileProps={{ additionalOAuthScopes, apiKeysProps, appearance }}
organizationProfileProps={{ appearance }}
/>,
);

expect(useUserButtonModel).toHaveBeenCalledWith(
{ afterLeaveOrganizationUrl: '/left' },
{
userProfile: { customPages: [{ label: 'account-page' }], additionalOAuthScopes, apiKeysProps, appearance },
organizationProfile: { customPages: [{ label: 'general-page' }], appearance },
},
);
});

it('bridges each profile its own custom pages, ordered against its own built-in pages', () => {
const page = { label: 'Usage', path: 'usage', content: <p>Usage</p> };
render(
<UserButton
userProfileProps={{ customPages: [page], pageOrder: ['usage', 'account'] }}
organizationProfileProps={{ customPages: [page], pageOrder: ['members', 'usage'] }}
/>,
);

expect(useCustomPages).toHaveBeenCalledWith({
items: [page],
order: ['usage', 'account'],
builtInPages: ['account'],
});
expect(useCustomPages).toHaveBeenCalledWith({
items: [page],
order: ['members', 'usage'],
builtInPages: ['general'],
});
});
});
Loading
Loading