Skip to content
Draft
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-profile-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/panel/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { Panel } from './panel';
export { Panel, usePanelTitle } from './panel';
export type { PanelRootProps, PanelSectionsProps, PanelTitleProps } from './panel';
23 changes: 19 additions & 4 deletions packages/mosaic/src/components/panel/panel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useMergeRefs } from '@floating-ui/react';
import * as stylex from '@stylexjs/stylex';
import React from 'react';

Expand All @@ -13,13 +14,22 @@ export type PanelRootProps = MosaicComponentProps<'div'>;
export type PanelTitleProps = MosaicComponentProps<'div'>;
export type PanelSectionsProps = MosaicComponentProps<'div'>;

const PanelTitleContext = React.createContext<React.RefObject<HTMLDivElement | null> | null>(null);

export function usePanelTitle(): () => HTMLDivElement | null {
const titleRef = React.useContext(PanelTitleContext);
return () => titleRef?.current ?? null;
}

const Root = React.forwardRef<HTMLDivElement, PanelRootProps>(function PanelRoot({ render, xstyle, ...rest }, ref) {
return useRender({
const titleRef = React.useRef<HTMLDivElement>(null);
const element = useRender({
defaultTagName: 'div',
render,
ref,
props: mergeStyleProps(themeProps('panel'), stylex.props(reset.base, styles.root, xstyle), rest),
});
return <PanelTitleContext.Provider value={titleRef}>{element}</PanelTitleContext.Provider>;
});

// Inside a profile page, the profile renders the page title, and the ref reaches that instead.
Expand All @@ -29,16 +39,21 @@ const Title = React.forwardRef<HTMLDivElement, PanelTitleProps>(function PanelTi
) {
const inProfilePage = React.useContext(ContentPanelContext);
const profile = React.useContext(ProfileContext);
const panelTitleRef = React.useContext(PanelTitleContext);
const titleRef = useMergeRefs([ref, panelTitleRef]);
const level = useHeadingLevel();
const pageTitleRef = inProfilePage ? profile?.pageTitleRef : undefined;
React.useImperativeHandle(pageTitleRef ? ref : null, () => pageTitleRef?.current as HTMLDivElement);
React.useImperativeHandle(pageTitleRef ? titleRef : null, () => pageTitleRef?.current as HTMLDivElement);
return useRender({
defaultTagName: 'div',
render,
ref: pageTitleRef ? null : ref,
ref: pageTitleRef ? null : titleRef,
enabled: !inProfilePage,
props: {
...mergeStyleProps(themeProps('panel-title'), stylex.props(reset.base, styles.title, xstyle), rest),
...mergeStyleProps(themeProps('panel-title'), stylex.props(reset.base, styles.title, xstyle), {
tabIndex: -1,
...rest,
}),
children: (
<Heading
level={level}
Expand Down
32 changes: 31 additions & 1 deletion packages/mosaic/src/components/profile/profile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Card } from '../card';
import { Dialog } from '../dialog';
import { HeadingLevelProvider } from '../heading';
import { Icon } from '../icon';
import { Panel } from '../panel';
import { Panel, usePanelTitle } from '../panel';
import { Section } from '../section';
import type { ProfileRootProps } from './profile';
import { Profile } from './profile';
Expand Down Expand Up @@ -529,6 +529,36 @@ describe('Profile', () => {
expect(titleRef.current).toBe(title());
});

it('makes the page title the panel title inside a profile page', () => {
let panelTitle: () => HTMLDivElement | null = () => null;
function ReadPanelTitle() {
panelTitle = usePanelTitle();
return null;
}
render(
<MosaicProvider>
<Profile.Root value='account'>
<Profile.Title>User profile</Profile.Title>
<Profile.Nav>
<Profile.NavItem value='account'>Account</Profile.NavItem>
</Profile.Nav>
<Profile.Content pageTitle='Account'>
<Profile.ContentPanel value='account'>
<Panel.Root>
<Panel.Title>Account</Panel.Title>
<Panel.Sections>
<ReadPanelTitle />
</Panel.Sections>
</Panel.Root>
</Profile.ContentPanel>
</Profile.Content>
</Profile.Root>
</MosaicProvider>,
);

expect(panelTitle()).toBe(screen.getByRole('heading', { level: 3, name: 'Account' }));
});

it('returns the tablist to the column when the width comes back', () => {
renderSurface();
act(() => observe?.(3));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@ import {
fapiUser,
} from '../../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../../__tests__/feature/render';
import {
OrganizationProfileDangerSection,
type OrganizationProfileDangerSectionProps,
} from '../organization-profile-danger-section';
import { OrganizationProfileProvider } from '../../organization-profile.provider';
import { OrganizationProfileDangerSection } from '../organization-profile-danger-section';

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

async function renderSection(seed: FakeFapiSeed = signedIn(), props: OrganizationProfileDangerSectionProps = {}) {
async function renderSection(seed: FakeFapiSeed = signedIn()) {
const fapi = serveFapi(seed);
const view = await renderWithClerk(<OrganizationProfileDangerSection {...props} />);
const view = await renderWithClerk(<OrganizationProfileDangerSection />);
return { ...view, fapi };
}

Expand Down Expand Up @@ -144,8 +142,13 @@ describe('OrganizationProfileDangerSection', () => {
expect(dialog).toHaveTextContent('This removes 1 member and permanently deletes all organization data.');
});

it('deletes the organization and sends the user to the url the host passed', async () => {
const { fapi, navigate } = await renderSection(signedIn(), { afterLeaveOrganizationUrl: '/organizations' });
it('deletes the organization and sends the user to the url from the provider', async () => {
const fapi = serveFapi(signedIn());
const { navigate } = await renderWithClerk(
<OrganizationProfileProvider afterLeaveOrganizationUrl='/organizations'>
<OrganizationProfileDangerSection />
</OrganizationProfileProvider>,
);
const user = userEvent.setup();
const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Delete organization' }));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useOrganization, useOrganizationList, useSession, useUser } from '@cler

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

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

export function useOrganizationProfileDangerSectionModel({
afterLeaveOrganizationUrl,
}: {
afterLeaveOrganizationUrl?: string;
}): OrganizationProfileDangerSectionModel {
export function useOrganizationProfileDangerSectionModel(): OrganizationProfileDangerSectionModel {
const { afterLeaveOrganizationUrl } = useOrganizationProfileOptions();
const { isLoaded, organization, membership } = useOrganization();
const { user } = useUser();
const { session } = useSession();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ import { useOrganizationProfileDangerSectionModel } from './organization-profile
import { OrganizationProfileDangerSectionView } from './organization-profile-danger-section.view';

export type OrganizationProfileDangerSectionProps = {
afterLeaveOrganizationUrl?: string;
fallback?: React.ReactNode;
};

export function OrganizationProfileDangerSection(props: OrganizationProfileDangerSectionProps) {
const model = useOrganizationProfileDangerSectionModel(props);
const model = useOrganizationProfileDangerSectionModel();

if (model.status === 'loading') {
return props.fallback ?? null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../../__tests__/feature/fapi';
import { renderWithClerk } from '../../__tests__/feature/render';
import { MosaicProvider } from '../../mosaic-provider';
import { OrganizationProfileProvider } from './organization-profile.provider';
import { OrganizationProfileDangerSection } from './organization-profile-danger-section/organization-profile-danger-section';
import { OrganizationProfileGeneralPanel } from './organization-profile-general-panel';
import { OrganizationProfileProfileSection } from './organization-profile-profile-section/organization-profile-profile-section';
Expand Down Expand Up @@ -112,6 +113,25 @@ describe('OrganizationProfileGeneralPanel', () => {
it.todo('shows verified domains when enabled and the member can read them');
});

describe('leave', () => {
it('sends the user to the provider url after leaving from the default sections', async () => {
const fapi = serveFapi(signedIn());
const { navigate } = await renderWithClerk(
<OrganizationProfileProvider afterLeaveOrganizationUrl='/organizations'>
<OrganizationProfileGeneralPanel />
</OrganizationProfileProvider>,
);
const user = userEvent.setup();
const dialog = await openDialog(user, await screen.findByRole('button', { name: 'Leave organization' }));

await user.type(within(dialog).getByRole('textbox'), 'Acme');
await user.click(within(dialog).getByRole('button', { name: 'Leave organization' }));

await waitFor(() => expect(fapi.memberships).toHaveLength(0));
await waitFor(() => expect(navigate).toHaveBeenCalledWith('/organizations'));
});
});

describe('edit name', () => {
it('updates only the changed name and shows the refreshed value', async () => {
const { fapi } = await renderPanel();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createContext, type ReactNode, useContext } from 'react';

export interface OrganizationProfileOptions {
afterLeaveOrganizationUrl?: string;
}

export interface OrganizationProfileProviderProps extends OrganizationProfileOptions {
children: ReactNode;
}

const OrganizationProfileContext = createContext<OrganizationProfileOptions>({});

export function OrganizationProfileProvider({ afterLeaveOrganizationUrl, children }: OrganizationProfileProviderProps) {
return (
<OrganizationProfileContext.Provider value={{ afterLeaveOrganizationUrl }}>
{children}
</OrganizationProfileContext.Provider>
);
}

export function useOrganizationProfileOptions(): OrganizationProfileOptions {
return useContext(OrganizationProfileContext);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createDeferredPromise } from '@clerk/shared/utils';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';

import { useDestructiveController } from '../../../blocks/destructive/destructive.controller';
Expand Down Expand Up @@ -32,19 +31,16 @@ describe('UserProfileProfilePanelView', () => {
expect(screen.queryByRole('tab')).toBeNull();
});

it('keeps the final wallet confirmation mounted until removal settles', async () => {
it('keeps the final wallet confirmation mounted until removal settles, then focuses the title', async () => {
const user = userEvent.setup();
const titleRef = createRef<HTMLDivElement>();
const removal = createDeferredPromise();
const onRemoveWeb3Wallet = vi.fn(async () => {
await removal.promise;
});
const { rerender } = renderView({
titleRef,
children: (
<UserProfileWeb3WalletsSectionView
wallets={[{ id: 'wallet_1', provider: 'MetaMask', address: '0x1234', isVerified: true }]}
fallbackFocus={() => titleRef.current}
onRemove={onRemoveWeb3Wallet}
/>
),
Expand All @@ -54,10 +50,9 @@ describe('UserProfileProfilePanelView', () => {
await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' }));
rerender(
<MosaicProvider>
<UserProfileProfilePanelView titleRef={titleRef}>
<UserProfileProfilePanelView>
<UserProfileWeb3WalletsSectionView
wallets={[]}
fallbackFocus={() => titleRef.current}
onRemove={onRemoveWeb3Wallet}
/>
</UserProfileProfilePanelView>
Expand All @@ -70,6 +65,9 @@ describe('UserProfileProfilePanelView', () => {
await removal.promise;
});
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
await waitFor(() =>
expect(screen.getByRole('heading', { name: 'Account', level: 2 }).closest('.cl-panel-title')).toHaveFocus(),
);
});

it('renders the danger zone when provided', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../../__tests__/feature/render';
import { MosaicProvider } from '../../../../mosaic-provider';
import { UserProfileProvider } from '../../user-profile.provider';
import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section';
import {
deferred,
Expand Down Expand Up @@ -100,7 +101,9 @@ describe('connected accounts', () => {
it('sends requested scopes and the current URL to connect', async () => {
serveFapi(signedIn([]));
const { clerk } = await renderWithClerk(
<UserProfileConnectedAccountsSection additionalOAuthScopes={{ github: ['repo'] }} />,
<UserProfileProvider additionalOAuthScopes={{ github: ['repo'] }}>
<UserProfileConnectedAccountsSection />
</UserProfileProvider>,
);
const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve());
const request = holdRequests('post', '/v1/me/external_accounts');
Expand Down Expand Up @@ -183,7 +186,11 @@ describe('connected accounts', () => {

it('preserves modal state when connecting', async () => {
serveFapi(signedIn([]));
await renderWithClerk(<UserProfileConnectedAccountsSection mode='modal' />);
await renderWithClerk(
<UserProfileProvider mode='modal'>
<UserProfileConnectedAccountsSection />
</UserProfileProvider>,
);
const request = holdRequests('post', '/v1/me/external_accounts');

await userEvent.setup().click(screen.getByRole('button', { name: 'Connect GitHub' }));
Expand Down Expand Up @@ -374,7 +381,9 @@ describe('connected accounts', () => {
const callback = deferred<{ callbackUrl: string }>();
const open = vi.fn(() => callback.promise);
const { clerk } = await renderWithClerk(
<UserProfileConnectedAccountsSection additionalOAuthScopes={{ github: ['repo'] }} />,
<UserProfileProvider additionalOAuthScopes={{ github: ['repo'] }}>
<UserProfileConnectedAccountsSection />
</UserProfileProvider>,
{
__internal_oauthTransport: { getRedirectUrl: () => 'https://app.example/callback', open },
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { screen } from '@testing-library/react';
import type userEvent from '@testing-library/user-event';
import { createRef } from 'react';

import { type FakeFapiSeed, serveFapi } from '../../../../__tests__/feature/fake-fapi';
import {
Expand All @@ -12,7 +11,7 @@ import {
fapiVerification,
} from '../../../../__tests__/feature/fapi';
import { renderWithClerk } from '../../../../__tests__/feature/render';
import { UserProfileProfilePanelView } from '../../user-profile-profile-panel.view';
import { UserProfileProfilePanel } from '../../user-profile-profile-panel';
import { UserProfileConnectedAccountsSection } from '../user-profile-connected-accounts-section';

export const google = fapiExternalAccount({ id: 'idn_google', provider: 'google', username: 'jdoe' });
Expand Down Expand Up @@ -65,11 +64,10 @@ export function deferred<T>() {

export async function renderSection(accounts = [google], overrides: FakeFapiSeed = {}) {
const fapi = serveFapi(signedIn(accounts, overrides));
const titleRef = createRef<HTMLDivElement>();
const view = await renderWithClerk(
<UserProfileProfilePanelView titleRef={titleRef}>
<UserProfileConnectedAccountsSection fallbackFocus={() => titleRef.current} />
</UserProfileProfilePanelView>,
<UserProfileProfilePanel>
<UserProfileConnectedAccountsSection />
</UserProfileProfilePanel>,
);
return { ...view, fapi };
}
Expand Down
Loading
Loading