Skip to content

Commit 6542840

Browse files
feat(mosaic): export OrganizationProfile and open the Mosaic profiles from the switchers
1 parent 9443bfe commit 6542840

11 files changed

Lines changed: 115 additions & 462 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useOrganization } from '@clerk/shared/react';
2+
import React from 'react';
3+
4+
import { OrganizationProfile } from './organization-profile';
5+
import type { OrganizationProfileModalContentProps } from './organization-profile.modal.types';
6+
7+
export default function OrganizationProfileModalContent({
8+
options,
9+
close,
10+
}: OrganizationProfileModalContentProps): React.ReactElement {
11+
const { isLoaded, organization } = useOrganization();
12+
const inactive = isLoaded && !organization;
13+
14+
React.useEffect(() => {
15+
if (inactive) {
16+
close();
17+
}
18+
}, [inactive, close]);
19+
20+
const { page, ...props } = options ?? {};
21+
return (
22+
<OrganizationProfile
23+
{...props}
24+
defaultPage={page}
25+
/>
26+
);
27+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { defineModalSurface } from '../../modals/define-modal-surface';
2+
import type { OrganizationProfileModalOptions } from './organization-profile.modal.types';
3+
4+
export const organizationProfileModal = defineModalSurface<OrganizationProfileModalOptions>({
5+
id: 'organizationProfile',
6+
variant: 'profile',
7+
load: () => import('./organization-profile.modal-content'),
8+
});

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

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { act, screen, waitFor, within } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3-
import { describe, expect, it, vi } from 'vitest';
3+
import { describe, expect, it, onTestFinished, vi } from 'vitest';
4+
import { page } from 'vitest/browser';
45

6+
import { openDialog } from '../../../__tests__/feature/dialog';
57
import { type FakeFapiSeed, holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi';
68
import {
79
fapiClient,
@@ -45,6 +47,11 @@ const aliceSession = fapiSession({ id: 'sess_1', user: alice, last_active_organi
4547
const bobSession = fapiSession({ id: 'sess_2', user: bob });
4648
const personalSession = fapiSession({ id: 'sess_1', user: alice });
4749

50+
async function wideViewport() {
51+
await page.viewport(1280, 800);
52+
onTestFinished(() => page.viewport(414, 896));
53+
}
54+
4855
function signedIn(overrides: FakeFapiSeed = {}): FakeFapiSeed {
4956
return {
5057
client: fapiClient([aliceSession, bobSession]),
@@ -128,8 +135,8 @@ describe('OrganizationSwitcher', () => {
128135
});
129136

130137
it('heads the surface with the active organization, and manages it from the rows beneath', async () => {
131-
const { clerk } = await renderSwitcher({}, noOffers());
132-
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
138+
await wideViewport();
139+
await renderSwitcher({}, noOffers());
133140
const user = await openWithList();
134141

135142
const header = requiredPopup().querySelector('.cl-organization-switcher-header') ?? requiredPopup();
@@ -144,7 +151,8 @@ describe('OrganizationSwitcher', () => {
144151

145152
await user.click(screen.getByRole('button', { name: 'Settings' }));
146153
expect(screen.queryByRole('menu')).toBeNull();
147-
expect(openOrganizationProfile).toHaveBeenCalled();
154+
expect(await screen.findByRole('tab', { name: 'General' })).toHaveAttribute('aria-selected', 'true');
155+
expect(screen.queryByRole('tab', { name: 'Account' })).toBeNull();
148156
});
149157

150158
it('falls back to the account, with nothing to manage, where no organization is active', async () => {
@@ -215,16 +223,19 @@ describe('OrganizationSwitcher', () => {
215223
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/orgs/Other'));
216224
});
217225

218-
it('tells the organization profile where to land after leaving', async () => {
219-
const { clerk } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers());
220-
const openOrganizationProfile = vi.spyOn(clerk, 'openOrganizationProfile').mockImplementation(() => {});
226+
it('lands on afterLeaveOrganizationUrl after leaving from the opened profile', async () => {
227+
await wideViewport();
228+
const { navigate } = await renderSwitcher({ afterLeaveOrganizationUrl: '/left' }, noOffers());
221229
const user = await openWithList();
222230

223231
await user.click(screen.getByRole('button', { name: 'Settings' }));
232+
const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), {
233+
role: 'alertdialog',
234+
});
235+
await user.type(within(dialog).getByRole('textbox'), 'Acme');
236+
await user.click(within(dialog).getByRole('button', { name: 'Leave organization' }));
224237

225-
expect(openOrganizationProfile).toHaveBeenCalledWith(
226-
expect.objectContaining({ afterLeaveOrganizationUrl: '/left' }),
227-
);
238+
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/left'));
228239
});
229240

230241
it('tells create-organization where to land and whether to skip inviting members', async () => {

‎packages/mosaic/src/features/switcher/__tests__/switcher.pages.test.tsx‎

Lines changed: 0 additions & 190 deletions
This file was deleted.

‎packages/mosaic/src/features/switcher/switcher.model.tsx‎

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { buildTaskUrl } from '@clerk/shared/internal/clerk-js/sessionTasks';
22
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
33
import { useClerk, useOrganization, useSession, useUser } from '@clerk/shared/react';
44
import type {
5-
OrganizationProfileModalProps,
65
OrganizationResource,
76
OrganizationSwitcherProps,
87
UserButtonProps as ClerkUserButtonProps,
@@ -15,6 +14,8 @@ import { useMosaicRouter } from '../../hooks/use-mosaic-router';
1514
import { useModal } from '../../modals/use-modal';
1615
import { useGetPortalContainer } from '../../primitives/portal';
1716
import { replaceInPages, shrinkTotal } from '../../utils/infinite-pages';
17+
import { organizationProfileModal } from '../organization-profile/organization-profile.modal';
18+
import type { OrganizationProfileModalOptions } from '../organization-profile/organization-profile.modal.types';
1819
import { userProfileModal } from '../user-profile/user-profile.modal';
1920
import type { UserProfileModalOptions } from '../user-profile/user-profile.modal.types';
2021
import type {
@@ -115,7 +116,7 @@ export interface SwitcherModalProps {
115116
UserProfileModalOptions,
116117
'customPages' | 'pageOrder' | 'additionalOAuthScopes' | 'apiKeysProps' | 'mode'
117118
>;
118-
organizationProfile?: Pick<OrganizationProfileModalProps, 'customPages' | 'appearance'>;
119+
organizationProfile?: Pick<OrganizationProfileModalOptions, 'customPages' | 'pageOrder'>;
119120
}
120121

121122
function resolveAfterUrl<T extends object>(
@@ -178,8 +179,8 @@ function toSession(sessionId: string, user: UserResource): SwitcherSession {
178179
}
179180

180181
/**
181-
* @param modals - Props forwarded to the profile modals. The organization profile's custom pages arrive
182-
* already bridged, since only the wrapper can render the portals behind them.
182+
* @param modals - Props forwarded to the profile modals. A `combined` user profile also carries the
183+
* organization's pages, so managing the organization opens it on them.
183184
*/
184185
export function useSwitcherModel(options?: SwitcherModelOptions, modals?: SwitcherModalProps): SwitcherModel {
185186
const { isLoaded: isUserLoaded, user } = useUser();
@@ -191,6 +192,7 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
191192
const clerk = useClerk();
192193
const router = useMosaicRouter();
193194
const userProfile = useModal(userProfileModal);
195+
const organizationProfile = useModal(organizationProfileModal);
194196
// The modal must portal into the app's own dialog root, or it renders behind the surface that opened it.
195197
const getContainer = useGetPortalContainer();
196198
const environment = useMosaicEnvironment();
@@ -202,17 +204,15 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
202204
enabled: Boolean(environment?.organizationSettings.enabled),
203205
});
204206

207+
const organizationProfileProps = {
208+
...modals?.organizationProfile,
209+
afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl,
210+
};
211+
205212
const manageAccount = openOrNavigate({
206213
url: options?.userProfileUrl,
207214
mode: options?.userProfileMode,
208-
openModal: () =>
209-
userProfile.open(
210-
{
211-
...modals?.userProfile,
212-
organizationProfileProps: { afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl },
213-
},
214-
getContainer?.(),
215-
),
215+
openModal: () => userProfile.open({ ...modals?.userProfile, organizationProfileProps }, getContainer?.()),
216216
buildUrl: () => clerk.buildUserProfileUrl(),
217217
navigate: router.navigate,
218218
});
@@ -221,11 +221,12 @@ export function useSwitcherModel(options?: SwitcherModelOptions, modals?: Switch
221221
url: options?.organizationProfileUrl,
222222
mode: options?.organizationProfileMode,
223223
openModal: () =>
224-
clerk.openOrganizationProfile({
225-
getContainer,
226-
...modals?.organizationProfile,
227-
afterLeaveOrganizationUrl: options?.afterLeaveOrganizationUrl,
228-
}),
224+
modals?.userProfile?.mode === 'combined'
225+
? userProfile.open(
226+
{ ...modals.userProfile, organizationProfileProps, page: 'organization/general' },
227+
getContainer?.(),
228+
)
229+
: organizationProfile.open(organizationProfileProps, getContainer?.()),
229230
buildUrl: () => clerk.buildOrganizationProfileUrl(),
230231
navigate: router.navigate,
231232
});

0 commit comments

Comments
 (0)