Skip to content

Commit ae5aa8e

Browse files
committed
feat(mosaic): Move profile URLs and leave options to MosaicProvider
1 parent 18e84ec commit ae5aa8e

13 files changed

Lines changed: 201 additions & 77 deletions

File tree

‎.changeset/mosaic-profile-provider.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Breaking changes:
1515

1616
- `UserButton`: `mode` is replaced by `combined`, which only decides what the menu lists. `mode='user'` becomes `combined={false}`, and to keep the account-only profile too, also set `profile={{ combined: false }}` on `MosaicProvider`.
1717
- `UserButton` and `OrganizationSwitcher`: `userProfileProps` and `organizationProfileProps` move to `MosaicProvider`'s `profile`.
18-
- `UserProfile`: `mode` is replaced by `combined`. When unset it follows `profile.combined`, and it also picks up the provider's profile props, with its own props winning.
18+
- `UserButton` and `OrganizationSwitcher`: `userProfileUrl`, `userProfileMode`, `organizationProfileUrl` and `organizationProfileMode` move to `MosaicProvider`'s `profile`.
19+
- `UserButton` and `OrganizationSwitcher`: `afterLeaveOrganizationUrl` moves to `profile.organizationProfileProps`, which also takes `apiKeysProps`.
20+
- `UserProfile`: `mode` is replaced by `combined`. When unset it follows `profile.combined`, and it also picks up the provider's profile props, with its own props winning. `OrganizationProfile` picks them up the same way.
1921
- With `experimental.newProfiles`, `OrganizationSwitcher` now opens the combined profile by default. Set `profile={{ combined: false }}` for the standalone organization profile.
2022
- The `UserProfileMode` type is removed.

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { ReactNode } from 'react';
44

55
import type { APIKeysPageProps } from '../api-keys/api-keys-table';
66
import { useProfilePagesController } from '../profile-pages/profile-pages.controller';
7+
import { useMosaicProfile } from '../profile-pages/use-mosaic-profile';
8+
import { pagesOnly } from '../switcher/switcher.pages';
79
import { organizationProfilePageIds } from './organization-profile.layout';
810
import { useOrganizationProfileModel } from './organization-profile.model';
911
import type { OrganizationProfileOptions } from './organization-profile.provider';
@@ -79,14 +81,16 @@ export interface OrganizationProfileProps
7981
* <OrganizationProfile afterLeaveOrganizationUrl='/organizations' />
8082
* ```
8183
*/
82-
export function OrganizationProfile({
83-
customPages,
84-
pageOrder,
85-
defaultPage,
86-
afterLeaveOrganizationUrl,
87-
fallback,
88-
apiKeysProps,
89-
}: OrganizationProfileProps) {
84+
export function OrganizationProfile(props: OrganizationProfileProps) {
85+
const { organizationProfileProps } = useMosaicProfile();
86+
const {
87+
customPages = pagesOnly(organizationProfileProps?.customPages),
88+
pageOrder = organizationProfileProps?.pageOrder,
89+
defaultPage,
90+
afterLeaveOrganizationUrl = organizationProfileProps?.afterLeaveOrganizationUrl,
91+
fallback,
92+
apiKeysProps = organizationProfileProps?.apiKeysProps,
93+
} = props;
9094
const model = useOrganizationProfileModel();
9195
const pages = model.status === 'ready' ? organizationProfilePanels(model.pages, apiKeysProps) : undefined;
9296
const navigation = useProfilePagesController(

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

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
fapiUser,
1818
} from '../../../__tests__/feature/fapi';
1919
import { renderWithClerk } from '../../../__tests__/feature/render';
20+
import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
2021
import type { OrganizationSwitcherProps } from '../organization-switcher';
2122
import { OrganizationSwitcher } from '../organization-switcher';
2223

@@ -67,9 +68,13 @@ function tree(props: OrganizationSwitcherProps = {}) {
6768
);
6869
}
6970

70-
function renderSwitcher(props: OrganizationSwitcherProps = {}, seed: FakeFapiSeed = signedIn()) {
71+
function renderSwitcher(
72+
props: OrganizationSwitcherProps = {},
73+
seed: FakeFapiSeed = signedIn(),
74+
profile?: MosaicProfile,
75+
) {
7176
const fapi = serveFapi(seed);
72-
return renderWithClerk(tree(props)).then(view => ({ ...view, fapi }));
77+
return renderWithClerk(tree(props), undefined, undefined, undefined, profile).then(view => ({ ...view, fapi }));
7378
}
7479

7580
const host = () => screen.getByTestId('host');
@@ -218,7 +223,9 @@ describe('OrganizationSwitcher', () => {
218223
});
219224

220225
it('tells the organization profile where to land after leaving', async () => {
221-
const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers());
226+
const { clerk } = await renderSwitcher({}, noOffers(), {
227+
organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
228+
});
222229
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
223230
const user = await openWithList();
224231

@@ -377,11 +384,11 @@ describe('OrganizationSwitcher custom trigger badges', () => {
377384
});
378385

379386
describe('OrganizationSwitcher with experimental.newProfiles', () => {
380-
async function renderWithNewProfiles(props: OrganizationSwitcherProps = {}) {
387+
async function renderWithNewProfiles(profile: MosaicProfile = { combined: false }) {
381388
await page.viewport(1280, 800);
382389
onTestFinished(() => page.viewport(414, 896));
383390
serveFapi(noOffers());
384-
return renderWithClerk(tree(props), undefined, undefined, { newProfiles: true }, { combined: false });
391+
return renderWithClerk(tree(), undefined, undefined, { newProfiles: true }, profile);
385392
}
386393

387394
it('opens the OrganizationProfile, without the account pages, from Settings', async () => {
@@ -396,8 +403,14 @@ describe('OrganizationSwitcher with experimental.newProfiles', () => {
396403
expect(openOrganizationProfile).not.toHaveBeenCalled();
397404
});
398405

399-
it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => {
400-
const { navigate } = await renderWithNewProfiles({ afterLeaveOrganizationUrl: '/left' });
406+
it.each([
407+
{ name: 'separate', combined: false },
408+
{ name: 'combined', combined: true },
409+
])('lands on afterLeaveOrganizationUrl after leaving from the opened profile: $name', async ({ combined }) => {
410+
const { navigate } = await renderWithNewProfiles({
411+
combined,
412+
organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
413+
});
401414
const user = await openWithList();
402415

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

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@ export type OrganizationSwitcherProps = OrganizationSwitcherModelOptions &
3535
* ```
3636
*
3737
* @example
38-
* Passing a URL routes to a page of your own instead of opening Clerk's modal. `afterSelectOrganizationUrl`
39-
* is where switching organization lands, and takes a `:param` template, a plain path, or a function.
38+
* `createOrganizationUrl` routes to a page of your own instead of opening Clerk's modal. `afterSelectOrganizationUrl`
39+
* is where switching organization lands, and takes a `:param` template, a plain path, or a function. Set
40+
* `organizationProfileUrl` on `MosaicProvider`'s `profile` to route Settings to a page of your own.
4041
* ```tsx
4142
* <OrganizationSwitcher
42-
* organizationProfileUrl='/settings/organization'
4343
* createOrganizationUrl='/orgs/new'
4444
* afterSelectOrganizationUrl='/orgs/:slug'
4545
* afterSelectPersonalUrl='/dashboard'

‎packages/mosaic/src/features/profile-pages/use-mosaic-profile.ts‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { createContext, useContext } from 'react';
44
import type { OrganizationProfilePageId } from '../switcher/switcher.pages';
55
import type { CustomProfileItem, UserProfilePageId } from '../user-profile/user-profile.types';
66

7-
export interface MosaicProfile {
7+
export type MosaicProfile = MosaicProfileOptions & UserProfileRouting & OrganizationProfileRouting;
8+
9+
interface MosaicProfileOptions {
810
/**
911
* Whether one profile carries the account's pages and the active organization's. `false` keeps them
1012
* in separate profiles. It decides the profile's shape, not what `UserButton`'s menu lists.
@@ -23,6 +25,35 @@ export interface MosaicProfile {
2325
organizationProfileProps?: ProfileOrganizationProps;
2426
}
2527

28+
/** A URL is the whole opt-in to navigation, and `modal` forbids one, so the pair cannot contradict itself. */
29+
type UserProfileRouting =
30+
| {
31+
/** Full URL or path of your own user profile page. Setting it routes there instead of opening the modal. */
32+
userProfileUrl: string;
33+
/** How the user profile opens: `modal` opens it in place, `navigation` goes to `userProfileUrl`. Follows from whether `userProfileUrl` is set. */
34+
userProfileMode?: 'navigation';
35+
}
36+
| {
37+
/** Full URL or path of your own user profile page. Setting it routes there instead of opening the modal. */
38+
userProfileUrl?: never;
39+
/** How the user profile opens: `modal` opens it in place, `navigation` goes to `userProfileUrl`. Follows from whether `userProfileUrl` is set. */
40+
userProfileMode?: 'modal';
41+
};
42+
43+
type OrganizationProfileRouting =
44+
| {
45+
/** Full URL or path of your own organization profile page. Setting it routes there instead of opening the modal. */
46+
organizationProfileUrl: string;
47+
/** How the organization profile opens: `modal` opens it in place, `navigation` goes to `organizationProfileUrl`. Follows from whether `organizationProfileUrl` is set. */
48+
organizationProfileMode?: 'navigation';
49+
}
50+
| {
51+
/** Full URL or path of your own organization profile page. Setting it routes there instead of opening the modal. */
52+
organizationProfileUrl?: never;
53+
/** How the organization profile opens: `modal` opens it in place, `navigation` goes to `organizationProfileUrl`. Follows from whether `organizationProfileUrl` is set. */
54+
organizationProfileMode?: 'modal';
55+
};
56+
2657
/** What a profile takes beyond the profile component's own props. */
2758
export interface ProfilePages<PageId extends string> {
2859
/**
@@ -47,7 +78,9 @@ export interface ProfileUserProps
4778

4879
/** Options for the underlying `<OrganizationProfile />` component. */
4980
export interface ProfileOrganizationProps
50-
extends ProfilePages<OrganizationProfilePageId>, Pick<OrganizationProfileProps, 'appearance'> {}
81+
extends
82+
ProfilePages<OrganizationProfilePageId>,
83+
Pick<OrganizationProfileProps, 'afterLeaveOrganizationUrl' | 'apiKeysProps' | 'appearance'> {}
5184

5285
const MosaicProfileContext = createContext<MosaicProfile>({});
5386

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

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@ import type { ReactElement } from 'react';
44
import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
55
import { page } from 'vitest/browser';
66

7+
import { openDialog } from '../../../__tests__/feature/dialog';
78
import { type FakeFapiSeed, serveFapi } from '../../../__tests__/feature/fake-fapi';
89
import {
910
fapiClient,
1011
fapiEmailAddress,
12+
fapiEnvironment,
1113
fapiMembership,
1214
fapiOrganization,
1315
fapiSession,
1416
fapiUser,
1517
} from '../../../__tests__/feature/fapi';
1618
import { openMenu } from '../../../__tests__/feature/menu';
1719
import { renderWithClerk } from '../../../__tests__/feature/render';
20+
import { OrganizationProfile } from '../../organization-profile/organization-profile';
1821
import { OrganizationSwitcher } from '../../organization-switcher/organization-switcher';
1922
import type { MosaicProfile } from '../../profile-pages/use-mosaic-profile';
2023
import { UserButton } from '../../user-button/user-button';
@@ -75,6 +78,15 @@ async function chooseSetting(trigger: () => HTMLElement, label: string) {
7578
await user.click(screen.getByRole('menuitem', { name: label }));
7679
}
7780

81+
async function leaveOrganization() {
82+
const user = userEvent.setup();
83+
const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), {
84+
role: 'alertdialog',
85+
});
86+
await user.type(within(dialog).getByRole('textbox'), 'Acme');
87+
await user.click(within(dialog).getByRole('button', { name: 'Leave organization' }));
88+
}
89+
7890
beforeEach(async () => {
7991
await page.viewport(1280, 800);
8092
onTestFinished(() => page.viewport(414, 896));
@@ -156,8 +168,45 @@ describe('the profile the switchers open', () => {
156168
expect(tabs()).toEqual(accountTabs);
157169
});
158170

171+
it('routes both profiles with URLs from the provider', async () => {
172+
const { navigate } = await renderWithProfile(<OrganizationSwitcher />, {
173+
userProfileUrl: '/account',
174+
organizationProfileUrl: '/org',
175+
});
176+
177+
await clickSettings(switcherTrigger);
178+
179+
expect(navigate).toHaveBeenCalledWith('/org');
180+
});
181+
182+
it("hides the organization's API keys from the profile a switcher opens", async () => {
183+
const keysMemberships = [fapiMembership(acme, { permissions: ['org:sys_api_keys:read'] })];
184+
await renderWithProfile(
185+
<OrganizationSwitcher />,
186+
{ combined: false, organizationProfileProps: { apiKeysProps: { hide: true } } },
187+
{
188+
environment: fapiEnvironment({
189+
api_keys_settings: { user_api_keys_enabled: false, orgs_api_keys_enabled: true },
190+
}),
191+
client: fapiClient([
192+
fapiSession({
193+
id: 'sess_1',
194+
user: { ...alice, organization_memberships: keysMemberships },
195+
last_active_organization_id: 'org_1',
196+
}),
197+
]),
198+
memberships: keysMemberships,
199+
},
200+
);
201+
202+
await clickSettings(switcherTrigger);
203+
204+
expect(await selectedTab('General')).toBeInTheDocument();
205+
expect(tabs()).toEqual(['General']);
206+
});
207+
159208
it('routes the one profile with a URL, and opens the other as a modal', async () => {
160-
const { navigate } = await renderWithProfile(<UserButton userProfileUrl='/account' />);
209+
const { navigate } = await renderWithProfile(<UserButton />, { userProfileUrl: '/account' });
161210

162211
await chooseSetting(buttonTrigger, 'Manage profile');
163212
expect(navigate).toHaveBeenCalledWith('/account');
@@ -203,7 +252,48 @@ describe('the profile the switchers open', () => {
203252
});
204253
});
205254

255+
describe('a mounted OrganizationProfile under a provider profile', () => {
256+
const audit = { ...terms, label: 'Audit', path: 'audit' };
257+
258+
it("uses the provider's profile props, leaving links out", async () => {
259+
await renderWithProfile(<OrganizationProfile />, {
260+
organizationProfileProps: {
261+
customPages: [audit, { label: 'Docs', path: 'docs', href: 'https://example.com/docs' }],
262+
pageOrder: ['audit'],
263+
},
264+
});
265+
266+
await waitFor(() => expect(tabs()).toEqual(['Audit', 'General', 'Members']));
267+
});
268+
269+
it('lets its own props win over the provider', async () => {
270+
await renderWithProfile(<OrganizationProfile customPages={[{ ...terms, label: 'Own', path: 'own' }]} />, {
271+
organizationProfileProps: { customPages: [audit] },
272+
});
273+
274+
await waitFor(() => expect(tabs()).toEqual(['General', 'Members', 'Own']));
275+
});
276+
277+
it('lands where the provider says after leaving the organization', async () => {
278+
const { navigate } = await renderWithProfile(<OrganizationProfile />, {
279+
organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
280+
});
281+
await leaveOrganization();
282+
283+
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left'));
284+
});
285+
});
286+
206287
describe('a mounted UserProfile under a provider profile', () => {
288+
it('lands where the provider says after leaving the organization', async () => {
289+
const { navigate } = await renderWithProfile(<UserProfile defaultPage='organization/general' />, {
290+
organizationProfileProps: { afterLeaveOrganizationUrl: '/left' },
291+
});
292+
await leaveOrganization();
293+
294+
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left'));
295+
});
296+
207297
it.each([
208298
{ name: 'follows the provider', profile: separate, combined: undefined, expected: accountTabs },
209299
{ name: 'defaults to combined', profile: undefined, combined: undefined, expected: allTabs },

0 commit comments

Comments
 (0)