Skip to content

Commit 4f0db44

Browse files
feat(mosaic): add user and organization profile providers (#10192)
1 parent b5d1827 commit 4f0db44

35 files changed

Lines changed: 298 additions & 155 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { Panel } from './panel';
1+
export { Panel, usePanelTitle } from './panel';
22
export type { PanelRootProps, PanelSectionsProps, PanelTitleProps } from './panel';

‎packages/mosaic/src/components/panel/panel.tsx‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useMergeRefs } from '@floating-ui/react';
12
import * as stylex from '@stylexjs/stylex';
23
import React from 'react';
34

@@ -13,13 +14,22 @@ export type PanelRootProps = MosaicComponentProps<'div'>;
1314
export type PanelTitleProps = MosaicComponentProps<'div'>;
1415
export type PanelSectionsProps = MosaicComponentProps<'div'>;
1516

17+
const PanelTitleContext = React.createContext<React.RefObject<HTMLDivElement | null> | null>(null);
18+
19+
export function usePanelTitle(): () => HTMLDivElement | null {
20+
const titleRef = React.useContext(PanelTitleContext);
21+
return () => titleRef?.current ?? null;
22+
}
23+
1624
const Root = React.forwardRef<HTMLDivElement, PanelRootProps>(function PanelRoot({ render, xstyle, ...rest }, ref) {
17-
return useRender({
25+
const titleRef = React.useRef<HTMLDivElement>(null);
26+
const element = useRender({
1827
defaultTagName: 'div',
1928
render,
2029
ref,
2130
props: mergeStyleProps(themeProps('panel'), stylex.props(reset.base, styles.root, xstyle), rest),
2231
});
32+
return <PanelTitleContext.Provider value={titleRef}>{element}</PanelTitleContext.Provider>;
2333
});
2434

2535
// Inside a profile page, the profile renders the page title, and the ref reaches that instead.
@@ -29,16 +39,21 @@ const Title = React.forwardRef<HTMLDivElement, PanelTitleProps>(function PanelTi
2939
) {
3040
const inProfilePage = React.useContext(ContentPanelContext);
3141
const profile = React.useContext(ProfileContext);
42+
const panelTitleRef = React.useContext(PanelTitleContext);
43+
const titleRef = useMergeRefs([ref, panelTitleRef]);
3244
const level = useHeadingLevel();
3345
const pageTitleRef = inProfilePage ? profile?.pageTitleRef : undefined;
34-
React.useImperativeHandle(pageTitleRef ? ref : null, () => pageTitleRef?.current as HTMLDivElement);
46+
React.useImperativeHandle(pageTitleRef ? titleRef : null, () => pageTitleRef?.current as HTMLDivElement);
3547
return useRender({
3648
defaultTagName: 'div',
3749
render,
38-
ref: pageTitleRef ? null : ref,
50+
ref: pageTitleRef ? null : titleRef,
3951
enabled: !inProfilePage,
4052
props: {
41-
...mergeStyleProps(themeProps('panel-title'), stylex.props(reset.base, styles.title, xstyle), rest),
53+
...mergeStyleProps(themeProps('panel-title'), stylex.props(reset.base, styles.title, xstyle), {
54+
tabIndex: -1,
55+
...rest,
56+
}),
4257
children: (
4358
<Heading
4459
level={level}

‎packages/mosaic/src/components/profile/profile.test.tsx‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { Card } from '../card';
1010
import { Dialog } from '../dialog';
1111
import { HeadingLevelProvider } from '../heading';
1212
import { Icon } from '../icon';
13-
import { Panel } from '../panel';
13+
import { Panel, usePanelTitle } from '../panel';
1414
import { Section } from '../section';
1515
import type { ProfileRootProps } from './profile';
1616
import { Profile } from './profile';
@@ -551,6 +551,36 @@ describe('Profile', () => {
551551
expect(titleRef.current).toBe(title());
552552
});
553553

554+
it('makes the page title the panel title inside a profile page', () => {
555+
let panelTitle: () => HTMLDivElement | null = () => null;
556+
function ReadPanelTitle() {
557+
panelTitle = usePanelTitle();
558+
return null;
559+
}
560+
render(
561+
<MosaicProvider>
562+
<Profile.Root value='account'>
563+
<Profile.Title>User profile</Profile.Title>
564+
<Profile.Nav>
565+
<Profile.NavItem value='account'>Account</Profile.NavItem>
566+
</Profile.Nav>
567+
<Profile.Content pageTitle='Account'>
568+
<Profile.ContentPanel value='account'>
569+
<Panel.Root>
570+
<Panel.Title>Account</Panel.Title>
571+
<Panel.Sections>
572+
<ReadPanelTitle />
573+
</Panel.Sections>
574+
</Panel.Root>
575+
</Profile.ContentPanel>
576+
</Profile.Content>
577+
</Profile.Root>
578+
</MosaicProvider>,
579+
);
580+
581+
expect(panelTitle()).toBe(screen.getByRole('heading', { level: 3, name: 'Account' }));
582+
});
583+
554584
it('returns the tablist to the column when the width comes back', () => {
555585
renderSurface();
556586
act(() => observe?.(3));

‎packages/mosaic/src/features/organization-profile/organization-profile-danger-section/__tests__/organization-profile-danger-section.feature.test.tsx‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@ import {
1414
fapiUser,
1515
} from '../../../../__tests__/feature/fapi';
1616
import { renderWithClerk } from '../../../../__tests__/feature/render';
17-
import {
18-
OrganizationProfileDangerSection,
19-
type OrganizationProfileDangerSectionProps,
20-
} from '../organization-profile-danger-section';
17+
import { OrganizationProfileProvider } from '../../organization-profile.provider';
18+
import { OrganizationProfileDangerSection } from '../organization-profile-danger-section';
2119

2220
const acme = fapiOrganization({ id: 'org_1', name: 'Acme', members_count: 20 });
2321
const deletePermission = ['org:sys_profile:delete'];
@@ -38,9 +36,9 @@ function signedIn({
3836
} satisfies FakeFapiSeed;
3937
}
4038

41-
async function renderSection(seed: FakeFapiSeed = signedIn(), props: OrganizationProfileDangerSectionProps = {}) {
39+
async function renderSection(seed: FakeFapiSeed = signedIn()) {
4240
const fapi = serveFapi(seed);
43-
const view = await renderWithClerk(<OrganizationProfileDangerSection {...props} />);
41+
const view = await renderWithClerk(<OrganizationProfileDangerSection />);
4442
return { ...view, fapi };
4543
}
4644

@@ -152,8 +150,13 @@ describe('OrganizationProfileDangerSection', () => {
152150
expect(dialog).toHaveTextContent('This removes 1 member and permanently deletes all organization data.');
153151
});
154152

155-
it('deletes the organization and sends the user to the url the host passed', async () => {
156-
const { fapi, navigate } = await renderSection(signedIn(), { afterLeaveOrganizationUrl: '/organizations' });
153+
it('deletes the organization and sends the user to the url from the provider', async () => {
154+
const fapi = serveFapi(signedIn());
155+
const { navigate } = await renderWithClerk(
156+
<OrganizationProfileProvider afterLeaveOrganizationUrl='/organizations'>
157+
<OrganizationProfileDangerSection />
158+
</OrganizationProfileProvider>,
159+
);
157160
const user = userEvent.setup();
158161
const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Delete organization' }), {
159162
role: 'alertdialog',

‎packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.model.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useOrganization, useOrganizationList, useSession, useUser } from '@cler
22

33
import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
44
import { useMosaicRouter } from '../../../hooks/use-mosaic-router';
5+
import { useOrganizationProfileOptions } from '../organization-profile.provider';
56

67
const organizationListParams = {
78
userMemberships: { infinite: true },
@@ -20,11 +21,8 @@ export type OrganizationProfileDangerSectionModel =
2021
deleteOrganization?: () => Promise<void>;
2122
};
2223

23-
export function useOrganizationProfileDangerSectionModel({
24-
afterLeaveOrganizationUrl,
25-
}: {
26-
afterLeaveOrganizationUrl?: string;
27-
}): OrganizationProfileDangerSectionModel {
24+
export function useOrganizationProfileDangerSectionModel(): OrganizationProfileDangerSectionModel {
25+
const { afterLeaveOrganizationUrl } = useOrganizationProfileOptions();
2826
const { isLoaded, organization, membership } = useOrganization();
2927
const { user } = useUser();
3028
const { session } = useSession();

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@ import { useOrganizationProfileDangerSectionModel } from './organization-profile
44
import { OrganizationProfileDangerSectionView } from './organization-profile-danger-section.view';
55

66
export type OrganizationProfileDangerSectionProps = {
7-
afterLeaveOrganizationUrl?: string;
87
fallback?: React.ReactNode;
98
};
109

1110
export function OrganizationProfileDangerSection(props: OrganizationProfileDangerSectionProps) {
12-
const model = useOrganizationProfileDangerSectionModel(props);
11+
const model = useOrganizationProfileDangerSectionModel();
1312

1413
if (model.status === 'loading') {
1514
return props.fallback ?? null;

‎packages/mosaic/src/features/organization-profile/organization-profile-general-panel.feature.test.tsx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '../../__tests__/feature/fapi';
1616
import { renderWithClerk } from '../../__tests__/feature/render';
1717
import { MosaicProvider } from '../../mosaic-provider';
18+
import { OrganizationProfileProvider } from './organization-profile.provider';
1819
import { OrganizationProfileDangerSection } from './organization-profile-danger-section/organization-profile-danger-section';
1920
import { OrganizationProfileGeneralPanel } from './organization-profile-general-panel';
2021
import { OrganizationProfileProfileSection } from './organization-profile-profile-section/organization-profile-profile-section';
@@ -112,6 +113,27 @@ describe('OrganizationProfileGeneralPanel', () => {
112113
it.todo('shows verified domains when enabled and the member can read them');
113114
});
114115

116+
describe('leave', () => {
117+
it('sends the user to the provider url after leaving from the default sections', async () => {
118+
const fapi = serveFapi(signedIn());
119+
const { navigate } = await renderWithClerk(
120+
<OrganizationProfileProvider afterLeaveOrganizationUrl='/organizations'>
121+
<OrganizationProfileGeneralPanel />
122+
</OrganizationProfileProvider>,
123+
);
124+
const user = userEvent.setup();
125+
const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }), {
126+
role: 'alertdialog',
127+
});
128+
129+
await user.type(within(dialog).getByRole('textbox'), 'Acme');
130+
await user.click(within(dialog).getByRole('button', { name: 'Leave organization' }));
131+
132+
await waitFor(() => expect(fapi.memberships).toHaveLength(0));
133+
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'));
134+
});
135+
});
136+
115137
describe('edit name', () => {
116138
it('updates only the changed name and shows the refreshed value', async () => {
117139
const { fapi } = await renderPanel();
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { createContext, type ReactNode, useContext } from 'react';
2+
3+
export interface OrganizationProfileOptions {
4+
afterLeaveOrganizationUrl?: string;
5+
}
6+
7+
export interface OrganizationProfileProviderProps extends OrganizationProfileOptions {
8+
children: ReactNode;
9+
}
10+
11+
const OrganizationProfileContext = createContext<OrganizationProfileOptions>({});
12+
13+
export function OrganizationProfileProvider({ afterLeaveOrganizationUrl, children }: OrganizationProfileProviderProps) {
14+
return (
15+
<OrganizationProfileContext.Provider value={{ afterLeaveOrganizationUrl }}>
16+
{children}
17+
</OrganizationProfileContext.Provider>
18+
);
19+
}
20+
21+
export function useOrganizationProfileOptions(): OrganizationProfileOptions {
22+
return useContext(OrganizationProfileContext);
23+
}

‎packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createDeferredPromise } from '@clerk/shared/utils';
22
import { act, render, screen, waitFor, within } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
4-
import { createRef } from 'react';
54
import { describe, expect, it, vi } from 'vitest';
65

76
import { useConfirmController } from '../../../blocks/confirm';
@@ -15,11 +14,9 @@ import { UserProfileWeb3WalletsSectionView } from '../user-profile-web3-wallets-
1514

1615
function Web3Wallets({
1716
wallets,
18-
fallbackFocus,
1917
onRemove,
2018
}: {
2119
wallets: UserProfileWeb3Wallet[];
22-
fallbackFocus: () => HTMLElement | null;
2320
onRemove: (id: string) => Promise<void>;
2421
}) {
2522
const controller = useUserProfileWeb3WalletsController({
@@ -28,7 +25,6 @@ function Web3Wallets({
2825
connect: () => Promise.resolve(),
2926
setPrimary: () => Promise.resolve(),
3027
remove: onRemove,
31-
fallbackFocus,
3228
});
3329
return <UserProfileWeb3WalletsSectionView {...controller} />;
3430
}
@@ -54,19 +50,16 @@ describe('UserProfileProfilePanelView', () => {
5450
expect(screen.queryByRole('tab')).toBeNull();
5551
});
5652

57-
it('keeps the final wallet confirmation mounted until removal settles', async () => {
53+
it('keeps the final wallet confirmation mounted until removal settles, then focuses the title', async () => {
5854
const user = userEvent.setup();
59-
const titleRef = createRef<HTMLDivElement>();
6055
const removal = createDeferredPromise();
6156
const onRemoveWeb3Wallet = vi.fn(async () => {
6257
await removal.promise;
6358
});
6459
const { rerender } = renderView({
65-
titleRef,
6660
children: (
6761
<Web3Wallets
6862
wallets={[{ id: 'wallet_1', provider: 'MetaMask', address: '0x1234', isVerified: true }]}
69-
fallbackFocus={() => titleRef.current}
7063
onRemove={onRemoveWeb3Wallet}
7164
/>
7265
),
@@ -76,10 +69,9 @@ describe('UserProfileProfilePanelView', () => {
7669
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
7770
rerender(
7871
<MosaicProvider>
79-
<UserProfileProfilePanelView titleRef={titleRef}>
72+
<UserProfileProfilePanelView>
8073
<Web3Wallets
8174
wallets={[]}
82-
fallbackFocus={() => titleRef.current}
8375
onRemove={onRemoveWeb3Wallet}
8476
/>
8577
</UserProfileProfilePanelView>
@@ -92,6 +84,9 @@ describe('UserProfileProfilePanelView', () => {
9284
await removal.promise;
9385
});
9486
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
87+
await waitFor(() =>
88+
expect(screen.getByRole('heading', { name: 'Account', level: 2 }).closest('.cl-panel-title')).toHaveFocus(),
89+
);
9590
});
9691

9792
it('renders the danger zone when provided', () => {

0 commit comments

Comments
 (0)