diff --git a/.changeset/profile-composed-userprofile.md b/.changeset/profile-composed-userprofile.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/profile-composed-userprofile.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/composed/UserProfile/APIKeys.tsx b/packages/ui/src/composed/UserProfile/APIKeys.tsx new file mode 100644 index 00000000000..e627775110e --- /dev/null +++ b/packages/ui/src/composed/UserProfile/APIKeys.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { lazy, type ReactNode } from 'react'; + +import { APIKeysSection } from '../APIKeysSection'; + +const APIKeysPage = lazy(() => + import('../../components/UserProfile/APIKeysPage').then(m => ({ + default: m.APIKeysPage, + })), +); + +export const UserProfileAPIKeysPanel = (): ReactNode => ; diff --git a/packages/ui/src/composed/UserProfile/Account.tsx b/packages/ui/src/composed/UserProfile/Account.tsx new file mode 100644 index 00000000000..5672ae47c38 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Account.tsx @@ -0,0 +1,40 @@ +'use client'; + +import type { PropsWithChildren, ReactNode } from 'react'; + +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { AccountPage } from '../../components/UserProfile/AccountPage'; +import { localizationKeys } from '../../customizables'; +import { PageContext } from '../PageContext'; + +function AccountComposed({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + ({ gap: t.space.$8, color: t.colors.$colorForeground, isolation: 'isolate' })} + > + {children} + + + ); +} + +export function UserProfileAccountPanel({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx new file mode 100644 index 00000000000..280352a05ef --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountConnectedAccounts.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountConnectedAccounts as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileConnectedAccountsSection = createSection('UserProfileConnectedAccountsSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountEmails.tsx b/packages/ui/src/composed/UserProfile/AccountEmails.tsx new file mode 100644 index 00000000000..f4aab0d638b --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountEmails.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountEmails as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileEmailSection = createSection('UserProfileEmailSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx new file mode 100644 index 00000000000..e0f6e527b9b --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountEnterpriseAccounts.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountEnterpriseAccounts as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileEnterpriseAccountsSection = createSection('UserProfileEnterpriseAccountsSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountPhone.tsx b/packages/ui/src/composed/UserProfile/AccountPhone.tsx new file mode 100644 index 00000000000..b24c5eaa551 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountPhone.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountPhone as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfilePhoneSection = createSection('UserProfilePhoneSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountProfile.tsx b/packages/ui/src/composed/UserProfile/AccountProfile.tsx new file mode 100644 index 00000000000..0e5ee1d5499 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountProfile.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { UserProfileSection } from '../../components/UserProfile/UserProfileSection'; +import { createSection } from '../createSection'; + +export const UserProfileProfileSection = createSection('UserProfileProfileSection', UserProfileSection); diff --git a/packages/ui/src/composed/UserProfile/AccountUsername.tsx b/packages/ui/src/composed/UserProfile/AccountUsername.tsx new file mode 100644 index 00000000000..8976d221197 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountUsername.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountUsername as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileUsernameSection = createSection('UserProfileUsernameSection', Section); diff --git a/packages/ui/src/composed/UserProfile/AccountWeb3.tsx b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx new file mode 100644 index 00000000000..44b1df38425 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/AccountWeb3.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { AccountWeb3 as Section } from '../../components/UserProfile/AccountSections'; +import { createSection } from '../createSection'; + +export const UserProfileWeb3Section = createSection('UserProfileWeb3Section', Section); diff --git a/packages/ui/src/composed/UserProfile/Billing.tsx b/packages/ui/src/composed/UserProfile/Billing.tsx new file mode 100644 index 00000000000..a8ad004e6f2 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Billing.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { lazy, type ReactNode } from 'react'; + +import { BillingSection } from '../BillingSection'; + +const BillingPage = lazy(() => + import('../../components/UserProfile/BillingPage').then(m => ({ + default: m.BillingPage, + })), +); + +const PlansPage = lazy(() => + import('../../components/UserProfile/PlansPage').then(m => ({ + default: m.PlansPage, + })), +); + +const StatementPage = lazy(() => + import('../../components/Statements').then(m => ({ + default: m.StatementPage, + })), +); + +const PaymentAttemptPage = lazy(() => + import('../../components/PaymentAttempts').then(m => ({ + default: m.PaymentAttemptPage, + })), +); + +export const UserProfileBillingPanel = (): ReactNode => ( + +); diff --git a/packages/ui/src/composed/UserProfile/Security.tsx b/packages/ui/src/composed/UserProfile/Security.tsx new file mode 100644 index 00000000000..451e545467c --- /dev/null +++ b/packages/ui/src/composed/UserProfile/Security.tsx @@ -0,0 +1,39 @@ +'use client'; + +import type { PropsWithChildren, ReactNode } from 'react'; + +import { CardStateProvider, useCardState } from '@/ui/elements/contexts'; +import { ProfileCard } from '@/ui/elements/ProfileCard'; + +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; +import { localizationKeys } from '../../customizables'; +import { PageContext } from '../PageContext'; + +function SecurityComposed({ children }: PropsWithChildren): ReactNode { + const card = useCardState(); + return ( + + + {children} + + + ); +} + +export function UserProfileSecurityPanel({ children }: PropsWithChildren): ReactNode { + if (!children) { + return ; + } + + return ( + + + {children} + + + ); +} diff --git a/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx new file mode 100644 index 00000000000..0f25cfad6ac --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityActiveDevices.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { ActiveDevicesSection } from '../../components/UserProfile/ActiveDevicesSection'; +import { createSection } from '../createSection'; + +export const UserProfileActiveDevicesSection = createSection('UserProfileActiveDevicesSection', ActiveDevicesSection); diff --git a/packages/ui/src/composed/UserProfile/SecurityDelete.tsx b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx new file mode 100644 index 00000000000..9e116c958de --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityDelete.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { SecurityDelete as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const UserProfileDeleteSection = createSection('UserProfileDeleteSection', Section); diff --git a/packages/ui/src/composed/UserProfile/SecurityMfa.tsx b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx new file mode 100644 index 00000000000..ad5c61564d6 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityMfa.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { SecurityMfa as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const UserProfileMfaSection = createSection('UserProfileMfaSection', Section); diff --git a/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx new file mode 100644 index 00000000000..b0cf767a6d1 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityPasskeys.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { SecurityPasskeys as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const UserProfilePasskeysSection = createSection('UserProfilePasskeysSection', Section); diff --git a/packages/ui/src/composed/UserProfile/SecurityPassword.tsx b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx new file mode 100644 index 00000000000..3a4ad201999 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/SecurityPassword.tsx @@ -0,0 +1,6 @@ +'use client'; + +import { SecurityPassword as Section } from '../../components/UserProfile/SecuritySections'; +import { createSection } from '../createSection'; + +export const UserProfilePasswordSection = createSection('UserProfilePasswordSection', Section); diff --git a/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx new file mode 100644 index 00000000000..a7b1e6f9311 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/UserProfileProvider.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useClerk, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, OAuthProvider, OAuthScope, UserProfileProps } from '@clerk/shared/types'; +import type { PropsWithChildren, ReactNode } from 'react'; + +import type { Appearance } from '@/ui/internal/appearance'; + +import { UserProfileContext } from '../../contexts/components/UserProfile'; +import { fallbackModuleManager, ProfileProviderShell } from '../ProfileProviderShell'; + +type UserProfileProviderProps = PropsWithChildren<{ + appearance?: Appearance; + additionalOAuthScopes?: Partial>; + apiKeysProps?: UserProfileProps['apiKeysProps']; +}>; + +export const UserProfileProvider = (props: UserProfileProviderProps): ReactNode => { + const { children, appearance, additionalOAuthScopes, apiKeysProps } = props; + const clerk = useClerk(); + const { isLoaded, user } = useUser(); + + const environment = (clerk as any).__internal_environment as EnvironmentResource | null | undefined; + const moduleManager = clerk.__internal_moduleManager ?? fallbackModuleManager; + + if (!isLoaded || !user || !environment) { + return null; + } + + const userProfileCtxValue = { + componentName: 'UserProfile' as const, + mode: 'mounted' as const, + routing: 'hash' as const, + path: undefined, + additionalOAuthScopes, + apiKeysProps, + customPages: [], + }; + + return ( + + {children} + + ); +}; diff --git a/packages/ui/src/composed/UserProfile/index.tsx b/packages/ui/src/composed/UserProfile/index.tsx new file mode 100644 index 00000000000..7de69d4d962 --- /dev/null +++ b/packages/ui/src/composed/UserProfile/index.tsx @@ -0,0 +1,17 @@ +export { UserProfileProvider } from './UserProfileProvider'; +export { UserProfileAccountPanel } from './Account'; +export { UserProfileSecurityPanel } from './Security'; +export { UserProfileBillingPanel } from './Billing'; +export { UserProfileAPIKeysPanel } from './APIKeys'; +export { UserProfileProfileSection } from './AccountProfile'; +export { UserProfileUsernameSection } from './AccountUsername'; +export { UserProfileEmailSection } from './AccountEmails'; +export { UserProfilePhoneSection } from './AccountPhone'; +export { UserProfileConnectedAccountsSection } from './AccountConnectedAccounts'; +export { UserProfileEnterpriseAccountsSection } from './AccountEnterpriseAccounts'; +export { UserProfileWeb3Section } from './AccountWeb3'; +export { UserProfilePasswordSection } from './SecurityPassword'; +export { UserProfilePasskeysSection } from './SecurityPasskeys'; +export { UserProfileMfaSection } from './SecurityMfa'; +export { UserProfileActiveDevicesSection } from './SecurityActiveDevices'; +export { UserProfileDeleteSection } from './SecurityDelete'; diff --git a/packages/ui/src/composed/__tests__/UserProfile.test.tsx b/packages/ui/src/composed/__tests__/UserProfile.test.tsx new file mode 100644 index 00000000000..ba9d832903d --- /dev/null +++ b/packages/ui/src/composed/__tests__/UserProfile.test.tsx @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { AccountPage } from '../../components/UserProfile/AccountPage'; +import { SecurityPage } from '../../components/UserProfile/SecurityPage'; +import { clearFetchCache } from '../../hooks'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('Experimental UserProfile', () => { + beforeEach(() => { + clearFetchCache(); + }); + + // The page components (AccountPage/SecurityPage) are thin wrappers that render + // the composed panels. The section-visibility matrix is asserted once at the + // component level in AccountSections/SecuritySections; here we only cover what + // the page level adds — the enterprise-SSO additional-identification guard — + // plus a single mount smoke per page. + describe('Account page', () => { + it('hides add buttons when enterprise SSO disables additional identifications', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + email_addresses: ['test@clerk.com'], + enterprise_accounts: [ + { + active: true, + enterprise_connection: { + disable_additional_identifications: true, + }, + } as any, + ], + }); + f.withEnterpriseSso(); + }); + + const { queryByRole } = render(, { wrapper }); + expect(queryByRole('button', { name: /add email address/i })).not.toBeInTheDocument(); + }); + }); + + describe('Security page', () => { + it('renders active devices section', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render(, { wrapper }); + await waitFor(() => screen.getByText(/active devices/i)); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx new file mode 100644 index 00000000000..1fcb41b7502 --- /dev/null +++ b/packages/ui/src/composed/__tests__/UserProfileSections.test.tsx @@ -0,0 +1,320 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { UserProfileAccountPanel } from '../UserProfile/Account'; +import { UserProfileConnectedAccountsSection } from '../UserProfile/AccountConnectedAccounts'; +import { UserProfileEmailSection } from '../UserProfile/AccountEmails'; +import { UserProfileEnterpriseAccountsSection } from '../UserProfile/AccountEnterpriseAccounts'; +import { UserProfilePhoneSection } from '../UserProfile/AccountPhone'; +import { UserProfileProfileSection } from '../UserProfile/AccountProfile'; +import { UserProfileUsernameSection } from '../UserProfile/AccountUsername'; +import { UserProfileWeb3Section } from '../UserProfile/AccountWeb3'; +import { UserProfileSecurityPanel } from '../UserProfile/Security'; +import { UserProfileActiveDevicesSection } from '../UserProfile/SecurityActiveDevices'; +import { UserProfileDeleteSection } from '../UserProfile/SecurityDelete'; +import { UserProfilePasswordSection } from '../UserProfile/SecurityPassword'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('UserProfile composed sections', () => { + beforeEach(() => { + clearFetchCache(); + }); + + describe('Account — inline form flow', () => { + it('inline form flow: update profile opens form', async () => { + const { wrapper } = await createFixtures(f => { + f.withName(); + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + const { getByRole, getByLabelText, userEvent } = render(, { wrapper }); + await userEvent.click(getByRole('button', { name: /update profile/i })); + await waitFor(() => getByLabelText(/first name/i)); + expect(getByLabelText(/first name/i)).toBeInTheDocument(); + }); + }); + + describe('Account — section composition mode', () => { + it('renders only declared sections', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withPhoneNumber(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + phone_numbers: ['+11111111111'], + }); + }); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + screen.getByText('Test User'); + expect(screen.getAllByText(/email address/i).length).toBeGreaterThan(0); + expect(queryByText(/phone number/i)).not.toBeInTheDocument(); + }); + + it('renders header', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText('Profile details'); + }); + + it('renders custom content between sections', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + render( + + +
Custom content
+ +
, + { wrapper }, + ); + + expect(screen.getByTestId('custom-banner')).toBeInTheDocument(); + screen.getByText('Custom content'); + }); + + it('environment guard: disabled email renders null', async () => { + const { wrapper } = await createFixtures(f => { + // Email NOT enabled + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Test', last_name: 'User' }); + }); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + screen.getByText('Test User'); + expect(queryByText(/email address/i)).not.toBeInTheDocument(); + }); + }); + + describe('Account — individual sections', () => { + it('UserProfileProfileSection renders user name', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], first_name: 'Jane', last_name: 'Doe' }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText('Jane Doe'); + }); + + it('UserProfileUsernameSection renders when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUsername(); + f.withUser({ email_addresses: ['test@clerk.com'], username: 'jdoe' }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText('jdoe'); + }); + + it('UserProfileUsernameSection renders null when disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], username: 'jdoe' }); + }); + + const { container } = render( + + + , + { wrapper }, + ); + + expect(container.querySelector('[class*="profileSection"]')).not.toBeInTheDocument(); + }); + + it('UserProfileEmailSection renders email list', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ email_addresses: ['primary@clerk.com', 'secondary@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText('primary@clerk.com'); + screen.getByText('secondary@clerk.com'); + }); + + it('UserProfilePhoneSection renders phone list when enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withPhoneNumber(); + f.withUser({ email_addresses: ['test@clerk.com'], phone_numbers: ['+11111111111'] }); + }); + + render( + + + , + { wrapper }, + ); + + expect(screen.getAllByText(/phone number/i).length).toBeGreaterThan(0); + }); + + it('UserProfileConnectedAccountsSection renders when social providers enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withSocialProvider({ provider: 'google' }); + f.withUser({ + email_addresses: ['test@clerk.com'], + external_accounts: [{ provider: 'google', email_address: 'test@clerk.com' }], + }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText(/connected accounts/i); + }); + + it('UserProfileEnterpriseAccountsSection renders null when enterprise SSO disabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { queryByText } = render( + + + , + { wrapper }, + ); + + expect(queryByText(/enterprise accounts/i)).not.toBeInTheDocument(); + }); + + it('UserProfileWeb3Section renders when web3_wallet enabled', async () => { + const { wrapper } = await createFixtures(f => { + f.withWeb3Wallet(); + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + render( + + + , + { wrapper }, + ); + + screen.getByText(/web3 wallets/i); + }); + }); + + describe('Account — section outside page', () => { + it('useRequirePage throws when rendered outside a page component', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + expect(() => render(, { wrapper })).toThrow( + ' must be rendered inside a page component', + ); + }); + }); + + describe('Security — section composition mode', () => { + it('renders only declared sections', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withPassword(); + f.withPasskey(); + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: true }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + await waitFor(() => screen.getByText(/^password/i)); + screen.getByText(/active devices/i); + expect(queryByText(/^passkeys/i)).not.toBeInTheDocument(); + expect(queryByText(/delete account/i)).not.toBeInTheDocument(); + }); + + it('UserProfileActiveDevicesSection renders without guard', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + render( + + + , + { wrapper }, + ); + + await waitFor(() => screen.getByText(/active devices/i)); + }); + + it('UserProfileDeleteSection respects user flag', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'], delete_self_enabled: false }); + }); + fixtures.clerk.user?.getSessions.mockReturnValue(Promise.resolve([])); + + const { queryByText } = render( + + + + , + { wrapper }, + ); + + await waitFor(() => screen.getByText(/active devices/i)); + expect(queryByText(/delete account/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/ui/src/composed/__tests__/action-animation.test.tsx b/packages/ui/src/composed/__tests__/action-animation.test.tsx new file mode 100644 index 00000000000..6b46dfe868f --- /dev/null +++ b/packages/ui/src/composed/__tests__/action-animation.test.tsx @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('@formkit/auto-animate/react'); +vi.unmock('@formkit/auto-animate'); + +import { bindCreateFixtures } from '@/test/create-fixtures'; +import { render, screen, waitFor } from '@/test/utils'; + +import { clearFetchCache } from '../../hooks'; +import { UserProfileAccountPanel } from '../UserProfile/Account'; +import { UserProfileEmailSection } from '../UserProfile/AccountEmails'; +import { UserProfileProfileSection } from '../UserProfile/AccountProfile'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +function findAddAnimationCall(calls: any[]) { + return calls.find(call => { + const keyframes = call[0]; + if (!Array.isArray(keyframes)) { + return false; + } + return keyframes.some( + (kf: any) => kf.opacity === 0 && typeof kf.transform === 'string' && kf.transform.includes('scale'), + ); + }); +} + +describe('Action open animation', () => { + beforeEach(() => { + clearFetchCache(); + vi.mocked(Element.prototype.animate).mockClear(); + }); + + it('calls el.animate with add keyframes when "Add email" action opens', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + }); + }); + + const { userEvent } = render(, { wrapper }); + vi.mocked(Element.prototype.animate).mockClear(); + + await userEvent.click(screen.getByRole('button', { name: /add email address/i })); + await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); + + it('composed sections: "Add email" triggers add animation', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withUser({ + first_name: 'Test', + last_name: 'User', + email_addresses: ['test@clerk.com'], + }); + }); + + const { userEvent } = render( + + + + , + { wrapper }, + ); + + vi.mocked(Element.prototype.animate).mockClear(); + await userEvent.click(screen.getByRole('button', { name: /add email address/i })); + await waitFor(() => expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()); + + expect(findAddAnimationCall(vi.mocked(Element.prototype.animate).mock.calls)).toBeDefined(); + }); +});