diff --git a/.changeset/user-profile-panel-shell.md b/.changeset/user-profile-panel-shell.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-panel-shell.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 0b812b5b6d7..eab32400402 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -12,6 +12,7 @@ import { ViewSource } from './ViewSource'; const docModules: Record> = { user: { 'user-button': dynamic(() => import('../stories/user-button.mdx')), + 'user-profile-profile-panel': dynamic(() => import('../stories/user-profile-profile-panel.mdx')), 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), 'user-profile-connected-accounts-section': dynamic( () => import('../stories/user-profile-connected-accounts-section.mdx'), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index d6c141deac7..31a7d4c8c03 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -145,6 +145,10 @@ import { Default as UserProfileDeleteSectionDefault, meta as userProfileDeleteSectionMeta, } from '../stories/user-profile-delete-section.stories'; +import { + Default as UserProfileProfilePanelDefault, + meta as userProfileProfilePanelMeta, +} from '../stories/user-profile-profile-panel.stories'; import { Default as UserProfileWeb3WalletsSectionDefault, meta as userProfileWeb3WalletsSectionMeta, @@ -297,6 +301,10 @@ const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, }; +const userProfileProfilePanelModule: StoryModule = { + meta: userProfileProfilePanelMeta, + Default: UserProfileProfilePanelDefault, +}; const userProfileConnectedAccountsSectionModule: StoryModule = { meta: userProfileConnectedAccountsSectionMeta, Default: UserProfileConnectedAccountsSectionDefault, @@ -313,6 +321,7 @@ const userProfileDeleteSectionModule: StoryModule = { export const registry: StoryModule[] = [ // User userButtonModule, + userProfileProfilePanelModule, userProfileAccountSectionModule, userProfileConnectedAccountsSectionModule, userProfileWeb3WalletsSectionModule, diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx new file mode 100644 index 00000000000..56a1b2d9b25 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -0,0 +1,65 @@ +import * as UserProfileProfilePanelStories from './user-profile-profile-panel.stories'; + +# UserProfileProfilePanel + +The Profile panel from UserProfile, composed without the surrounding navigation shell. The +presentational view uses `SettingsGroup` for its account details, connected accounts, Web3 wallets, and danger +zone while preserving the existing resource props and callback seams. + +## Example + + + +## Usage + +```tsx +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; + + ({ + id: email.id, + value: email.emailAddress, + isDefault: email.id === user.primaryEmailAddressId, + isVerified: email.verification.status === 'verified', + }))} + phones={user.phoneNumbers.map(phone => ({ + id: phone.id, + value: phone.phoneNumber, + isDefault: phone.id === user.primaryPhoneNumberId, + isVerified: phone.verification.status === 'verified', + }))} + connectedAccounts={user.externalAccounts.map(account => ({ + id: account.id, + provider: account.provider, + identifier: account.emailAddress, + connected: true, + }))} + web3Wallets={user.web3Wallets.map(wallet => ({ + id: wallet.id, + address: wallet.web3Wallet, + isPrimary: wallet.id === user.primaryWeb3WalletId, + isVerified: wallet.verification.status === 'verified', + }))} + onNameChange={setName} + onUsernameChange={setUsername} + onVerifyEmail={verifyEmail} + onSetPrimaryEmail={setPrimaryEmail} + onRemoveEmail={removeEmail} + onVerifyPhone={verifyPhone} + onSetPrimaryPhone={setPrimaryPhone} + onRemovePhone={removePhone} + onRemoveConnectedAccount={removeConnectedAccount} + onAddWeb3Wallet={addWeb3Wallet} + onSetPrimaryWeb3Wallet={setPrimaryWeb3Wallet} + onRemoveWeb3Wallet={removeWeb3Wallet} +/> +``` diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx new file mode 100644 index 00000000000..6565f4a1a83 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -0,0 +1,71 @@ +/** @jsxImportSource @emotion/react */ +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; +const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; + +export { default as __source } from './user-profile-profile-panel.stories?raw'; + +export const meta: StoryMeta = { + group: 'User', + title: 'UserProfileProfilePanel', + source: 'packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx', +}; + +export function Default(_args: Record) { + const [name, setName] = useState('Preston Booth'); + const [username, setUsername] = useState('prestonxyz'); + + return ( + undefined} + onAddPhone={() => undefined} + onConnectAccount={() => undefined} + onDeleteAccount={() => undefined} + onEditProfilePicture={() => undefined} + onRemoveConnectedAccount={() => undefined} + onRemoveEmail={() => undefined} + onRemovePhone={() => undefined} + onAddWeb3Wallet={() => undefined} + onRemoveWeb3Wallet={() => undefined} + onSetPrimaryWeb3Wallet={() => undefined} + onSetPrimaryEmail={() => undefined} + onSetPrimaryPhone={() => undefined} + onVerifyEmail={() => undefined} + onVerifyPhone={() => undefined} + onNameChange={setName} + onUsernameChange={setUsername} + /> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx new file mode 100644 index 00000000000..676b756c0fe --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -0,0 +1,285 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileProfilePanelViewProps } from '../user-profile-profile-panel.view'; +import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; + +const props: UserProfileProfilePanelViewProps = { + name: 'Preston Booth', + username: 'prestonxyz', + emails: [ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true }, + { id: 'email_2', value: 'item2@clerk.dev' }, + ], + phones: [{ id: 'phone_1', value: '+1 801-888-8181' }], +}; + +function renderView(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('UserProfileProfilePanelView', () => { + it('composes the profile content without profile navigation', () => { + renderView({ onEditProfilePicture: vi.fn() }); + + expect(screen.getByRole('heading', { name: 'Account' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3, name: 'Profile' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Account' }).closest('.cl-settings-group')).toContainElement( + document.querySelector('.cl-settings-group-list'), + ); + const nameInput = screen.getByRole('textbox', { name: 'Name' }); + const usernameInput = screen.getByRole('textbox', { name: 'Username' }); + expect(nameInput).toHaveValue('Preston Booth'); + expect(usernameInput).toHaveValue('prestonxyz'); + expect(nameInput).toHaveStyle({ width: '10.9375rem', maxWidth: '100%', flexShrink: 0 }); + expect(usernameInput).toHaveStyle({ width: '10.9375rem', maxWidth: '100%', flexShrink: 0 }); + expect(nameInput.closest('.cl-field-root')).not.toBeNull(); + expect(usernameInput.closest('.cl-field-root')).not.toBeNull(); + expect(screen.getByText('Name').closest('label')).toHaveAttribute('for', nameInput.id); + expect(screen.getByText('Username').closest('label')).toHaveAttribute('for', usernameInput.id); + expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); + expect(screen.getByText('Default')).toBeInTheDocument(); + expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); + expect(screen.getByText('Profile picture').closest('.cl-settings-group-label')).not.toBeNull(); + expect(screen.getByText('Email')).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('Phone')).toHaveAttribute('data-color', 'primary'); + expect(screen.getByText('item1@clerk.dev')).toHaveAttribute('data-color', 'primary'); + const editProfilePicture = screen.getByRole('button', { name: 'Edit profile picture' }); + expect(editProfilePicture).toHaveAttribute('data-size', 'lg'); + expect(editProfilePicture).toHaveClass('cl-avatar-button'); + expect(editProfilePicture.querySelector('.cl-avatar')).not.toBeNull(); + expect(screen.queryByRole('tab')).toBeNull(); + expect(screen.queryByRole('heading', { name: 'User Profile' })).toBeNull(); + }); + + it('edits the profile picture when the overhanging pen treatment is clicked', async () => { + const onEditProfilePicture = vi.fn(); + const user = userEvent.setup(); + renderView({ onEditProfilePicture }); + + const button = screen.getByRole('button', { name: 'Edit profile picture' }); + const penTreatment = button.querySelector('svg')?.parentElement; + expect(penTreatment).not.toBeNull(); + await user.click(penTreatment!); + + expect(onEditProfilePicture).toHaveBeenCalledOnce(); + }); + + it('renders connected accounts and the danger zone when provided', async () => { + const onConnectAccount = vi.fn(); + const onManageConnectedAccount = vi.fn(); + const onDeleteAccount = vi.fn(); + const user = userEvent.setup(); + renderView({ + connectedAccounts: [ + { id: 'google', provider: 'Google', identifier: 'test@google.com' }, + { id: 'apple', provider: 'Apple', connected: false }, + ], + onConnectAccount, + onManageConnectedAccount, + onDeleteAccount, + }); + + expect(screen.getByRole('heading', { level: 4, name: 'Connected accounts' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 4, name: 'Danger zone' })).toBeInTheDocument(); + expect(screen.getByText('Delete account', { selector: '.cl-settings-group-label-text' })).toBeInTheDocument(); + expect(screen.getByText('This action is permanent and irreversible.')).toHaveClass( + 'cl-settings-group-label-description', + ); + await user.click(screen.getByRole('button', { name: 'Manage Google' })); + expect(onManageConnectedAccount).not.toHaveBeenCalled(); + await user.click(screen.getByRole('menuitem', { name: 'Manage' })); + await user.click(screen.getByRole('button', { name: 'Connect' })); + await user.click(screen.getByRole('button', { name: 'Delete account' })); + + expect(onManageConnectedAccount).toHaveBeenCalledWith('google'); + expect(onConnectAccount).toHaveBeenCalledWith('apple'); + expect(onDeleteAccount).toHaveBeenCalledOnce(); + }); + + it('renders Web3 wallets and forwards wallet actions', async () => { + const onAddWeb3Wallet = vi.fn(); + const onSetPrimaryWeb3Wallet = vi.fn(); + const onRemoveWeb3Wallet = vi.fn(); + const user = userEvent.setup(); + renderView({ + web3Wallets: [ + { + id: 'primary', + address: '0x1234567890abcdef1234567890abcdef12345678', + provider: 'MetaMask', + isPrimary: true, + isVerified: true, + }, + { + id: 'secondary', + address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + provider: 'Coinbase Wallet', + isVerified: true, + }, + ], + onAddWeb3Wallet, + onSetPrimaryWeb3Wallet, + onRemoveWeb3Wallet, + }); + + expect(screen.getByRole('heading', { level: 4, name: 'Web3 wallets' })).toBeInTheDocument(); + expect(screen.getByText('MetaMask')).toBeInTheDocument(); + expect(screen.getByText('0x1234...5678')).toBeInTheDocument(); + expect(screen.getByText('Primary')).toBeInTheDocument(); + + await user.click(within(screen.getByRole('region', { name: 'Web3 wallets' })).getByRole('button', { name: 'Add' })); + await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await user.click(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })); + const removeWallet = screen.getByRole('menuitem', { name: 'Remove wallet' }); + expect(removeWallet).toHaveAttribute('data-color', 'negative'); + await user.click(removeWallet); + + expect(onAddWeb3Wallet).toHaveBeenCalledOnce(); + expect(onSetPrimaryWeb3Wallet).toHaveBeenCalledWith('secondary'); + expect(onRemoveWeb3Wallet).toHaveBeenCalledWith('secondary'); + }); + + it('shows unverified Web3 wallets without a set-primary action', async () => { + const user = userEvent.setup(); + renderView({ + web3Wallets: [{ id: 'unverified', address: 'short', isVerified: false }], + onSetPrimaryWeb3Wallet: vi.fn(), + onRemoveWeb3Wallet: vi.fn(), + }); + + expect(screen.getByText('short')).toBeInTheDocument(); + expect(screen.getByText('Unverified')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Manage short' })); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: 'Remove wallet' })).toBeInTheDocument(); + }); + + it('renders safely before profile data is available', () => { + render( + + + , + ); + + expect(screen.getByRole('heading', { name: 'Account' })).toBeInTheDocument(); + }); + + it('forwards field and contact actions', async () => { + const onNameChange = vi.fn(); + const onAddEmail = vi.fn(); + const onManageEmail = vi.fn(); + renderView({ onNameChange, onAddEmail, onManageEmail }); + const user = userEvent.setup(); + + await user.type(screen.getByRole('textbox', { name: 'Name' }), ' Jr.'); + await user.click(within(screen.getByRole('region', { name: 'Email' })).getByRole('button', { name: 'Add' })); + await user.click(screen.getByRole('button', { name: 'Manage item2@clerk.dev' })); + expect(onManageEmail).not.toHaveBeenCalled(); + await user.click(screen.getByRole('menuitem', { name: 'Manage' })); + + expect(onNameChange).toHaveBeenCalled(); + expect(onAddEmail).toHaveBeenCalledOnce(); + expect(onManageEmail).toHaveBeenCalledWith('email_2'); + }); + + it('matches the existing conditional contact and connected-account actions', async () => { + const onVerifyEmail = vi.fn(); + const onSetPrimaryEmail = vi.fn(); + const onRemoveEmail = vi.fn(); + const onVerifyPhone = vi.fn(); + const onSetPrimaryPhone = vi.fn(); + const onRemovePhone = vi.fn(); + const onRemoveConnectedAccount = vi.fn(); + const user = userEvent.setup(); + + renderView({ + emails: [ + { id: 'email_primary', value: 'primary@clerk.dev', isDefault: true, isVerified: false }, + { id: 'email_secondary', value: 'secondary@clerk.dev', isVerified: true }, + { id: 'email_unverified', value: 'unverified@clerk.dev', isVerified: false }, + ], + phones: [ + { id: 'phone_unverified', value: '+1 801-555-0100', isVerified: false }, + { id: 'phone_secondary', value: '+1 801-555-0101', isVerified: true }, + ], + connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz' }], + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, + onRemoveConnectedAccount, + }); + + await user.click(screen.getByRole('button', { name: 'Manage primary@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Complete verification' })); + expect(onVerifyEmail).toHaveBeenCalledWith('email_primary'); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryEmail).toHaveBeenCalledWith('email_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + const removeEmail = screen.getByRole('menuitem', { name: 'Remove email' }); + expect(removeEmail).toHaveAttribute('data-color', 'negative'); + await user.click(removeEmail); + expect(onRemoveEmail).toHaveBeenCalledWith('email_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage unverified@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify' })); + expect(onVerifyEmail).toHaveBeenCalledWith('email_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify phone number' })); + expect(onVerifyPhone).toHaveBeenCalledWith('phone_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(onRemovePhone).toHaveBeenCalledWith('phone_unverified'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0101' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryPhone).toHaveBeenCalledWith('phone_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage GitHub' })); + const removeConnectedAccount = screen.getByRole('menuitem', { name: 'Remove' }); + expect(removeConnectedAccount).toHaveAttribute('data-color', 'negative'); + await user.click(removeConnectedAccount); + expect(onRemoveConnectedAccount).toHaveBeenCalledWith('github'); + }); + + it('hides action triggers when immutable items have no available actions', () => { + renderView({ + emails: [ + { + id: 'email_immutable', + value: 'immutable@clerk.dev', + isDefault: true, + isVerified: true, + canRemove: false, + }, + ], + phones: [], + connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz', canRemove: false }], + onVerifyEmail: vi.fn(), + onSetPrimaryEmail: vi.fn(), + onRemoveEmail: vi.fn(), + onRemoveConnectedAccount: vi.fn(), + }); + + expect(screen.queryByRole('button', { name: 'Manage immutable@clerk.dev' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage GitHub' })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx new file mode 100644 index 00000000000..99a4edd26b0 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -0,0 +1,112 @@ +import * as stylex from '@stylexjs/stylex'; +import type { ReactElement } from 'react'; + +import { Heading } from '../components/heading'; +import { mergeStyleProps, themeProps } from '../props'; +import type { + UserProfileAccountSectionViewProps, + UserProfileEmail, + UserProfilePhone, +} from './user-profile-account-section.view'; +import { UserProfileAccountSectionView } from './user-profile-account-section.view'; +import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section.view'; +import { UserProfileConnectedAccountsSectionView } from './user-profile-connected-accounts-section.view'; +import { UserProfileDeleteSectionView } from './user-profile-delete-section.view'; +import { styles } from './user-profile-profile-panel.styles'; +import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section.view'; +import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; + +export type { UserProfileEmail, UserProfilePhone, UserProfileConnectedAccount, UserProfileWeb3Wallet }; + +export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { + connectedAccounts?: UserProfileConnectedAccount[]; + web3Wallets?: UserProfileWeb3Wallet[]; + onConnectAccount?: (id: string) => void; + onManageConnectedAccount?: (id: string) => void; + onRemoveConnectedAccount?: (id: string) => void; + onAddWeb3Wallet?: () => void; + onManageWeb3Wallet?: (id: string) => void; + onSetPrimaryWeb3Wallet?: (id: string) => void; + onRemoveWeb3Wallet?: (id: string) => void; + onDeleteAccount?: () => void; +} + +export function UserProfileProfilePanelView({ + imageUrl, + name = '', + username = '', + emails = [], + phones = [], + connectedAccounts = [], + web3Wallets = [], + onEditProfilePicture, + onNameChange, + onUsernameChange, + onAddEmail, + onManageEmail, + onVerifyEmail, + onSetPrimaryEmail, + onRemoveEmail, + onAddPhone, + onManagePhone, + onVerifyPhone, + onSetPrimaryPhone, + onRemovePhone, + onConnectAccount, + onManageConnectedAccount, + onRemoveConnectedAccount, + onAddWeb3Wallet, + onManageWeb3Wallet, + onSetPrimaryWeb3Wallet, + onRemoveWeb3Wallet, + onDeleteAccount, +}: UserProfileProfilePanelViewProps): ReactElement { + return ( +
+

} + size='base' + > + Profile + + + {connectedAccounts.length > 0 ? ( + + ) : null} + {web3Wallets.length > 0 || onAddWeb3Wallet ? ( + + ) : null} + {onDeleteAccount ? : null} +

+ ); +}