From 9a5a35dcb16afa44eb969983fc8ab8bc165a3cbb Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sun, 13 Sep 2026 23:04:14 -0600 Subject: [PATCH 1/7] refactor(ui): extract the profile picture row view --- .changeset/wild-carpets-make.md | 2 + .../user-profile-picture-row.view.test.tsx | 66 +++++++++ .../user-profile-profile-panel.view.test.tsx | 49 ++----- .../user-profile-account-section.view.tsx | 131 ++++-------------- .../user-profile-picture-row.view.tsx | 129 +++++++++++++++++ 5 files changed, 235 insertions(+), 142 deletions(-) create mode 100644 .changeset/wild-carpets-make.md create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-picture-row.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx diff --git a/.changeset/wild-carpets-make.md b/.changeset/wild-carpets-make.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/wild-carpets-make.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-picture-row.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-picture-row.view.test.tsx new file mode 100644 index 00000000000..6fac24fcc7b --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-picture-row.view.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfilePictureRowViewProps } from '../user-profile-account-section/user-profile-picture-row.view'; +import { UserProfilePictureRowView } from '../user-profile-account-section/user-profile-picture-row.view'; + +function renderView(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('UserProfilePictureRowView', () => { + it('renders and clears the error supplied by the section', () => { + const { rerender } = renderView({ errorMessage: 'File size exceeds the maximum limit of 10MB.' }); + + expect(screen.getByRole('alert')).toHaveTextContent('File size exceeds the maximum limit of 10MB.'); + + rerender( + + + , + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('offers Upload while the avatar is only a generated default', () => { + renderView({ + hasImage: false, + imageUrl: 'https://img.clerk.com/generated-default.png', + onChange: vi.fn(), + onRemove: vi.fn(), + }); + + expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); + }); + + it('offers change and remove in a menu once a profile picture is set', async () => { + const onChange = vi.fn(); + const onRemove = vi.fn(); + const user = userEvent.setup(); + renderView({ + hasImage: true, + imageUrl: 'https://example.com/avatar.png', + onChange, + onRemove, + }); + + expect(screen.queryByRole('button', { name: 'Upload' })).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); + + expect(screen.getByRole('menuitem', { name: 'Change avatar' })).toBeInTheDocument(); + await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); + + expect(onRemove).toHaveBeenCalledOnce(); + }); +}); 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 index 391198f30e5..55235bb68be 100644 --- 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 @@ -71,9 +71,11 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); const input = container.querySelector('input[type="file"]'); - expect(input).not.toBeNull(); + if (!input) { + throw new Error('File picker not found'); + } const file = new File(['avatar'], 'avatar.png', { type: 'image/png' }); - await user.upload(input as HTMLInputElement, file); + await user.upload(input, file); expect(onProfilePictureChange).toHaveBeenCalledWith(file); }); @@ -85,7 +87,11 @@ describe('UserProfileProfilePanelView', () => { const { container } = renderView({ onProfilePictureChange, onProfilePictureReject }); const oversized = new File([new Uint8Array(10 * 1000 * 1000 + 1)], 'big.png', { type: 'image/png' }); - await user.upload(container.querySelector('input[type="file"]') as HTMLInputElement, oversized); + const input = container.querySelector('input[type="file"]'); + if (!input) { + throw new Error('File picker not found'); + } + await user.upload(input, oversized); expect(onProfilePictureChange).not.toHaveBeenCalled(); expect(onProfilePictureReject).toHaveBeenCalledWith([{ file: oversized, reason: 'size' }]); @@ -96,7 +102,10 @@ describe('UserProfileProfilePanelView', () => { it('clears the rejection once an acceptable file is picked', async () => { const user = userEvent.setup(); const { container } = renderView({ onProfilePictureChange: vi.fn() }); - const input = container.querySelector('input[type="file"]') as HTMLInputElement; + const input = container.querySelector('input[type="file"]'); + if (!input) { + throw new Error('File picker not found'); + } await user.upload(input, new File([new Uint8Array(10 * 1000 * 1000 + 1)], 'big.png', { type: 'image/png' })); expect(screen.getByRole('alert')).toBeInTheDocument(); @@ -105,38 +114,6 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('alert')).toBeNull(); }); - it('offers Upload while the avatar is only a generated default', () => { - renderView({ - hasImage: false, - imageUrl: 'https://img.clerk.com/generated-default.png', - onProfilePictureChange: vi.fn(), - onRemoveProfilePicture: vi.fn(), - }); - - expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); - }); - - it('offers change and remove in a menu once a profile picture is set', async () => { - const onProfilePictureChange = vi.fn(); - const onRemoveProfilePicture = vi.fn(); - const user = userEvent.setup(); - renderView({ - hasImage: true, - imageUrl: 'https://example.com/avatar.png', - onProfilePictureChange, - onRemoveProfilePicture, - }); - - expect(screen.queryByRole('button', { name: 'Upload' })).toBeNull(); - await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); - - expect(screen.getByRole('menuitem', { name: 'Change avatar' })).toBeInTheDocument(); - await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); - - expect(onRemoveProfilePicture).toHaveBeenCalledOnce(); - }); - it('breaks out both contact types when multiple accounts are allowed', () => { renderView({ emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 2a239bb7e55..a43c5e5ade9 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,9 +1,7 @@ -import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; -import { FileUpload } from '@clerk/headless/file-upload'; +import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; import { useState } from 'react'; -import { Avatar } from '../../components/avatar'; import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; import { Icon } from '../../components/icon'; @@ -18,10 +16,7 @@ import type { UserProfileEditNameValue } from './user-profile-edit-name.view'; import { UserProfileEditNameView } from './user-profile-edit-name.view'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; import { UserProfileEditUsernameView } from './user-profile-edit-username.view'; - -const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; -/** Matches the limit the row's own description advertises. */ -const PROFILE_PICTURE_MAX_BYTES = 10 * 1000 * 1000; +import { UserProfilePictureRowView } from './user-profile-picture-row.view'; export interface UserProfileEmail { id: string; @@ -102,57 +97,33 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { - const initials = name - .split(/\s+/) - .map(part => part[0]) - .join('') - .slice(0, 2) - .toUpperCase(); - const [rejection, setRejection] = useState(null); + const [pictureError, setPictureError] = useState(); return ( - } - onReject={rejections => { - setRejection(rejections[0]?.reason ?? null); - onProfilePictureReject?.(rejections); - }} - onValueChange={files => { - const file = files[0]; - if (file) { - setRejection(null); - onProfilePictureChange?.(file); - } - }} - > +
{m.sectionTitle} - - - - - - {initials} - - - - {m.picture.label} - {m.picture.description} - - - - {rejection ? {m.picture.errors[rejection]} : null} - + { + setPictureError(undefined); + onProfilePictureChange(file); + } + : undefined + } + onReject={rejections => { + const rejection = rejections[0]; + setPictureError(rejection ? m.picture.errors[rejection.reason] : undefined); + onProfilePictureReject?.(rejections); + }} + onRemove={onRemoveProfilePicture} + /> @@ -232,62 +203,10 @@ export function UserProfileAccountSectionView({ onVerify={onVerifyPhone} /> ) : null} - +
); } -function ProfilePictureActions({ - hasImage, - canChange, - onRemove, -}: { - hasImage: boolean; - canChange: boolean; - onRemove?: () => void; -}) { - const { openFilePicker } = FileUpload.useFileUpload(); - const actions: UserProfileMenuAction[] = []; - - if (hasImage && canChange) { - actions.push({ label: m.picture.change, icon: 'pen', onClick: openFilePicker }); - } - - if (hasImage && onRemove) { - actions.push({ label: m.picture.remove, icon: 'close', onClick: onRemove }); - } - - if (actions.length > 0) { - return ( - - - - ); - } - - if (!hasImage && canChange) { - return ( - - - } - > - {m.picture.upload} - - - ); - } - - return null; -} - function EditName({ firstName, lastName, diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx new file mode 100644 index 00000000000..07d56dd3a39 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -0,0 +1,129 @@ +import type { FileRejection } from '@clerk/headless/file-upload'; +import { FileUpload } from '@clerk/headless/file-upload'; + +import { Avatar } from '../../components/avatar'; +import { Button } from '../../components/button'; +import { Section } from '../../components/section'; +import type { UserProfileMenuAction } from '../user-profile-action-menu'; +import { UserProfileActionMenu } from '../user-profile-action-menu'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; +/** Matches the limit the row's own description advertises. */ +const PROFILE_PICTURE_MAX_BYTES = 10 * 1000 * 1000; + +export interface UserProfilePictureRowViewProps { + name: string; + imageUrl?: string; + hasImage?: boolean; + errorMessage?: string; + onChange?: (file: File) => void; + onReject?: (rejections: FileRejection[]) => void; + onRemove?: () => void; +} + +export function UserProfilePictureRowView({ + name, + imageUrl, + hasImage = false, + errorMessage, + onChange, + onReject, + onRemove, +}: UserProfilePictureRowViewProps) { + const initials = name + .split(/\s+/) + .map(part => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + return ( + } + onReject={onReject} + onValueChange={files => { + const file = files[0]; + if (file) { + onChange?.(file); + } + }} + > + + + + + {initials} + + + + {m.picture.label} + {m.picture.description} + + + + {errorMessage ? {errorMessage} : null} + + ); +} + +function ProfilePictureActions({ + hasImage, + canChange, + onRemove, +}: { + hasImage: boolean; + canChange: boolean; + onRemove?: () => void; +}) { + const { openFilePicker } = FileUpload.useFileUpload(); + const actions: UserProfileMenuAction[] = []; + + if (hasImage && canChange) { + actions.push({ label: m.picture.change, icon: 'pen', onClick: openFilePicker }); + } + + if (hasImage && onRemove) { + actions.push({ label: m.picture.remove, icon: 'close', onClick: onRemove }); + } + + if (actions.length > 0) { + return ( + + + + ); + } + + if (!hasImage && canChange) { + return ( + + + } + > + {m.picture.upload} + + + ); + } + + return null; +} From 19cc1ecb1ed07dd0397acea854bf57fd1a3eac98 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sun, 13 Sep 2026 23:04:47 -0600 Subject: [PATCH 2/7] refactor(ui): extract the profile name row view --- .../user-profile-account-section.view.tsx | 34 ++++++++----------- .../user-profile-name-row.view.tsx | 23 +++++++++++++ 2 files changed, 38 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index a43c5e5ade9..23d0c3b807b 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -16,6 +16,7 @@ import type { UserProfileEditNameValue } from './user-profile-edit-name.view'; import { UserProfileEditNameView } from './user-profile-edit-name.view'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; import { UserProfileEditUsernameView } from './user-profile-edit-username.view'; +import { UserProfileNameRowView } from './user-profile-name-row.view'; import { UserProfilePictureRowView } from './user-profile-picture-row.view'; export interface UserProfileEmail { @@ -124,25 +125,20 @@ export function UserProfileAccountSectionView({ }} onRemove={onRemoveProfilePicture} /> - - - - {m.name.label} - {name} - - {onSubmitName ? ( - - - - ) : null} - - + + ) : undefined + } + /> diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx new file mode 100644 index 00000000000..0e5ecef4511 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; + +import { Section } from '../../components/section'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; + +export interface UserProfileNameRowViewProps { + name: string; + action?: ReactNode; +} + +export function UserProfileNameRowView({ name, action }: UserProfileNameRowViewProps) { + return ( + + + + {m.name.label} + {name} + + {action ? {action} : null} + + + ); +} From 62e6fa23830c2b32a7b8139d99f2cac1ab261e73 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Sun, 13 Sep 2026 23:05:14 -0600 Subject: [PATCH 3/7] refactor(ui): extract the profile username row view --- .../user-profile-account-section.view.tsx | 28 ++++++++----------- .../user-profile-username-row.view.tsx | 23 +++++++++++++++ 2 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-username-row.view.tsx diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 23d0c3b807b..6ba7d0bd0e7 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -18,6 +18,7 @@ import { useUserProfileEditUsernameController } from './user-profile-edit-userna import { UserProfileEditUsernameView } from './user-profile-edit-username.view'; import { UserProfileNameRowView } from './user-profile-name-row.view'; import { UserProfilePictureRowView } from './user-profile-picture-row.view'; +import { UserProfileUsernameRowView } from './user-profile-username-row.view'; export interface UserProfileEmail { id: string; @@ -139,22 +140,17 @@ export function UserProfileAccountSectionView({ ) : undefined } /> - - - - {m.username.label} - {username} - - {onSubmitUsername ? ( - - - - ) : null} - - + + ) : undefined + } + /> {!allowMultipleAccounts ? ( + + + {m.username.label} + {username} + + {action ? {action} : null} + + + ); +} From 152485d980855427c3ee7d5b3735aca57663e895 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 00:48:32 -0600 Subject: [PATCH 4/7] refactor(ui): move profile behavior into account rows --- .../user-profile-account-section.view.tsx | 102 ++---------------- .../user-profile-name-row.view.tsx | 68 +++++++++++- .../user-profile-picture-row.view.tsx | 12 ++- .../user-profile-username-row.view.tsx | 38 ++++++- 4 files changed, 114 insertions(+), 106 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 6ba7d0bd0e7..c4304faf3cc 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,6 +1,5 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import { useState } from 'react'; import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; @@ -11,11 +10,7 @@ import { UserProfileActionMenu } from '../user-profile-action-menu'; import { styles } from '../user-profile-profile-panel.styles'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; -import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; import type { UserProfileEditNameValue } from './user-profile-edit-name.view'; -import { UserProfileEditNameView } from './user-profile-edit-name.view'; -import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; -import { UserProfileEditUsernameView } from './user-profile-edit-username.view'; import { UserProfileNameRowView } from './user-profile-name-row.view'; import { UserProfilePictureRowView } from './user-profile-picture-row.view'; import { UserProfileUsernameRowView } from './user-profile-username-row.view'; @@ -99,8 +94,6 @@ export function UserProfileAccountSectionView({ onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { - const [pictureError, setPictureError] = useState(); - return (
@@ -110,46 +103,21 @@ export function UserProfileAccountSectionView({ name={name} imageUrl={imageUrl} hasImage={hasImage} - errorMessage={pictureError} - onChange={ - onProfilePictureChange - ? file => { - setPictureError(undefined); - onProfilePictureChange(file); - } - : undefined - } - onReject={rejections => { - const rejection = rejections[0]; - setPictureError(rejection ? m.picture.errors[rejection.reason] : undefined); - onProfilePictureReject?.(rejections); - }} + onChange={onProfilePictureChange} + onReject={onProfilePictureReject} onRemove={onRemoveProfilePicture} /> - ) : undefined - } + firstName={firstName} + lastName={lastName} + firstNameAttribute={firstNameAttribute} + lastNameAttribute={lastNameAttribute} + onSubmit={onSubmitName} /> - ) : undefined - } + onSubmit={onSubmitUsername} /> {!allowMultipleAccounts ? ( Promise; -}) { - const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); - - return ( - - {m.name.edit} - - } - /> - ); -} - -function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { - const controller = useUserProfileEditUsernameController({ username, onSubmit }); - - return ( - - {m.username.edit} - - } - /> - ); -} - interface ContactSectionProps { kind: 'email' | 'phone'; label: string; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx index 0e5ecef4511..cfe29028b39 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -1,14 +1,28 @@ -import type { ReactNode } from 'react'; - +import { Button } from '../../components/button'; import { Section } from '../../components/section'; import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import type { UserProfileNameAttribute } from './user-profile-account-section.types'; +import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.view'; +import { UserProfileEditNameView } from './user-profile-edit-name.view'; export interface UserProfileNameRowViewProps { name: string; - action?: ReactNode; + firstName?: string; + lastName?: string; + firstNameAttribute?: UserProfileNameAttribute; + lastNameAttribute?: UserProfileNameAttribute; + onSubmit?: (value: UserProfileEditNameValue) => Promise; } -export function UserProfileNameRowView({ name, action }: UserProfileNameRowViewProps) { +export function UserProfileNameRowView({ + name, + firstName, + lastName, + firstNameAttribute, + lastNameAttribute, + onSubmit, +}: UserProfileNameRowViewProps) { return ( @@ -16,8 +30,52 @@ export function UserProfileNameRowView({ name, action }: UserProfileNameRowViewP {m.name.label} {name} - {action ? {action} : null} + {onSubmit ? ( + + + + ) : null} ); } + +function EditName({ + firstName, + lastName, + firstNameAttribute, + lastNameAttribute, + onSubmit, +}: { + firstName?: string; + lastName?: string; + firstNameAttribute?: UserProfileNameAttribute; + lastNameAttribute?: UserProfileNameAttribute; + onSubmit: (value: UserProfileEditNameValue) => Promise; +}) { + const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); + + return ( + + {m.name.edit} + + } + /> + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx index 07d56dd3a39..87dad81fe40 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -1,5 +1,6 @@ import type { FileRejection } from '@clerk/headless/file-upload'; import { FileUpload } from '@clerk/headless/file-upload'; +import { useState } from 'react'; import { Avatar } from '../../components/avatar'; import { Button } from '../../components/button'; @@ -31,6 +32,8 @@ export function UserProfilePictureRowView({ onReject, onRemove, }: UserProfilePictureRowViewProps) { + const [rejectionError, setRejectionError] = useState(); + const displayedError = errorMessage ?? rejectionError; const initials = name .split(/\s+/) .map(part => part[0]) @@ -43,10 +46,15 @@ export function UserProfilePictureRowView({ accept={PROFILE_PICTURE_MIME_TYPES} maxSize={PROFILE_PICTURE_MAX_BYTES} render={} - onReject={onReject} + onReject={rejections => { + const rejection = rejections[0]; + setRejectionError(rejection ? m.picture.errors[rejection.reason] : undefined); + onReject?.(rejections); + }} onValueChange={files => { const file = files[0]; if (file) { + setRejectionError(undefined); onChange?.(file); } }} @@ -71,7 +79,7 @@ export function UserProfilePictureRowView({ onRemove={onRemove} /> - {errorMessage ? {errorMessage} : null} + {displayedError ? {displayedError} : null} ); } diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-username-row.view.tsx index d15c585e1e1..54f68daf507 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -1,14 +1,15 @@ -import type { ReactNode } from 'react'; - +import { Button } from '../../components/button'; import { Section } from '../../components/section'; import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; +import { UserProfileEditUsernameView } from './user-profile-edit-username.view'; export interface UserProfileUsernameRowViewProps { username: string; - action?: ReactNode; + onSubmit?: (username: string) => Promise; } -export function UserProfileUsernameRowView({ username, action }: UserProfileUsernameRowViewProps) { +export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { return ( @@ -16,8 +17,35 @@ export function UserProfileUsernameRowView({ username, action }: UserProfileUser {m.username.label} {username} - {action ? {action} : null} + {onSubmit ? ( + + + + ) : null} ); } + +function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { + const controller = useUserProfileEditUsernameController({ username, onSubmit }); + + return ( + + {m.username.edit} + + } + /> + ); +} From 2e877df2dd9edc617a11c7a8084f0287a21f36bd Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 01:13:41 -0600 Subject: [PATCH 5/7] refactor(ui): name profile edit components as dialogs --- .../src/stories/fixtures/user-profile-edit-name.ts | 2 +- ...st.tsx => user-profile-edit-name.dialog.test.tsx} | 12 ++++++------ ...sx => user-profile-edit-username.dialog.test.tsx} | 12 ++++++------ .../user-profile-account-section.view.tsx | 2 +- .../user-profile-edit-name.controller.ts | 2 +- ...me.view.tsx => user-profile-edit-name.dialog.tsx} | 6 +++--- .../user-profile-edit-username.controller.ts | 2 +- ...iew.tsx => user-profile-edit-username.dialog.tsx} | 6 +++--- .../user-profile-name-row.view.tsx | 6 +++--- .../user-profile-username-row.view.tsx | 4 ++-- .../user-profile/user-profile-profile-panel.view.tsx | 2 +- 11 files changed, 28 insertions(+), 28 deletions(-) rename packages/ui/src/mosaic/user-profile/__tests__/{user-profile-edit-name.view.test.tsx => user-profile-edit-name.dialog.test.tsx} (92%) rename packages/ui/src/mosaic/user-profile/__tests__/{user-profile-edit-username.view.test.tsx => user-profile-edit-username.dialog.test.tsx} (90%) rename packages/ui/src/mosaic/user-profile/user-profile-account-section/{user-profile-edit-name.view.tsx => user-profile-edit-name.dialog.tsx} (97%) rename packages/ui/src/mosaic/user-profile/user-profile-account-section/{user-profile-edit-username.view.tsx => user-profile-edit-username.dialog.tsx} (95%) diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts index c6e729cfecd..7604e9f09b1 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -1,6 +1,6 @@ import type { UserProfileFormError } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types'; import { UserProfileSaveError } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileEditNameValue } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.view'; +import type { UserProfileEditNameValue } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.dialog'; import { useState } from 'react'; export interface UserProfileEditNameFixtureOptions { diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx similarity index 92% rename from packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.view.test.tsx rename to packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx index 3676ee01d29..7578a86d811 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx @@ -4,11 +4,11 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../components/button'; import { MosaicProvider } from '../../MosaicProvider'; -import type { UserProfileEditNameViewProps } from '../user-profile-account-section/user-profile-edit-name.view'; -import { UserProfileEditNameView } from '../user-profile-account-section/user-profile-edit-name.view'; +import type { UserProfileEditNameDialogProps } from '../user-profile-account-section/user-profile-edit-name.dialog'; +import { UserProfileEditNameDialog } from '../user-profile-account-section/user-profile-edit-name.dialog'; -function renderView(overrides: Partial = {}) { - const props: UserProfileEditNameViewProps = { +function renderView(overrides: Partial = {}) { + const props: UserProfileEditNameDialogProps = { open: true, onOpenChange: vi.fn(), firstName: 'Preston', @@ -22,7 +22,7 @@ function renderView(overrides: Partial = {}) { props, ...render( - + , ), }; @@ -32,7 +32,7 @@ const firstNameField = () => screen.getByLabelText('First name'); const lastNameField = () => screen.getByLabelText('Last name'); const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); -describe('UserProfileEditNameView', () => { +describe('UserProfileEditNameDialog', () => { it('renders nothing until the caller opens it', () => { renderView({ open: false }); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx similarity index 90% rename from packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.view.test.tsx rename to packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx index 1ea123854d3..19d94dc953a 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx @@ -4,11 +4,11 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../components/button'; import { MosaicProvider } from '../../MosaicProvider'; -import type { UserProfileEditUsernameViewProps } from '../user-profile-account-section/user-profile-edit-username.view'; -import { UserProfileEditUsernameView } from '../user-profile-account-section/user-profile-edit-username.view'; +import type { UserProfileEditUsernameDialogProps } from '../user-profile-account-section/user-profile-edit-username.dialog'; +import { UserProfileEditUsernameDialog } from '../user-profile-account-section/user-profile-edit-username.dialog'; -function renderView(overrides: Partial = {}) { - const props: UserProfileEditUsernameViewProps = { +function renderView(overrides: Partial = {}) { + const props: UserProfileEditUsernameDialogProps = { open: true, onOpenChange: vi.fn(), username: 'prestonxyz', @@ -20,7 +20,7 @@ function renderView(overrides: Partial = {}) { props, ...render( - + , ), }; @@ -29,7 +29,7 @@ function renderView(overrides: Partial = {}) { const usernameField = () => screen.getByLabelText('Username'); const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); -describe('UserProfileEditUsernameView', () => { +describe('UserProfileEditUsernameDialog', () => { it('renders nothing until the caller opens it', () => { renderView({ open: false }); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index c4304faf3cc..b09658e065d 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -10,7 +10,7 @@ import { UserProfileActionMenu } from '../user-profile-action-menu'; import { styles } from '../user-profile-profile-panel.styles'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; -import type { UserProfileEditNameValue } from './user-profile-edit-name.view'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileNameRowView } from './user-profile-name-row.view'; import { UserProfilePictureRowView } from './user-profile-picture-row.view'; import { UserProfileUsernameRowView } from './user-profile-username-row.view'; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index 7b521029cf6..3a2af00f2e6 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -2,7 +2,7 @@ import { setup } from '../../machine/setup'; import { useMachine } from '../../machine/useMachine'; import type { UserProfileFormError } from './user-profile-account-section.types'; import { UserProfileSaveError } from './user-profile-account-section.types'; -import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.view'; +import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { saveName: (value: UserProfileEditNameValue) => Promise; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx similarity index 97% rename from packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.view.tsx rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index 3c917184f57..46f17bfd989 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -18,7 +18,7 @@ export interface UserProfileEditNameValue { lastName: string; } -export interface UserProfileEditNameViewProps { +export interface UserProfileEditNameDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** Rendering the opener here is what returns focus to it on close. */ @@ -40,7 +40,7 @@ export interface UserProfileEditNameViewProps { * `required` the instance asks for: the name the API will take is the API's to decide, so the action * stays live and a rejection comes back as `error`. */ -export function UserProfileEditNameView({ +export function UserProfileEditNameDialog({ open, onOpenChange, trigger, @@ -53,7 +53,7 @@ export function UserProfileEditNameView({ isSaving = false, error, onSubmit, -}: UserProfileEditNameViewProps) { +}: UserProfileEditNameDialogProps) { const formId = useId(); const initialFocusRef = useRef(null); const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index a9cd97a4797..e17d1b74a9b 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -2,7 +2,7 @@ import { setup } from '../../machine/setup'; import { useMachine } from '../../machine/useMachine'; import type { UserProfileFormError } from './user-profile-account-section.types'; import { UserProfileSaveError } from './user-profile-account-section.types'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.view'; +import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { saveUsername: (username: string) => Promise; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx similarity index 95% rename from packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.view.tsx rename to packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx index 28382a0dc9c..ea3372e4082 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx @@ -13,7 +13,7 @@ import type { UserProfileFormError } from './user-profile-account-section.types' export type UserProfileEditUsernameField = 'username'; -export interface UserProfileEditUsernameViewProps { +export interface UserProfileEditUsernameDialogProps { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; @@ -25,7 +25,7 @@ export interface UserProfileEditUsernameViewProps { onSubmit: () => void; } -export function UserProfileEditUsernameView({ +export function UserProfileEditUsernameDialog({ open, onOpenChange, trigger, @@ -35,7 +35,7 @@ export function UserProfileEditUsernameView({ isSaving = false, error, onSubmit, -}: UserProfileEditUsernameViewProps) { +}: UserProfileEditUsernameDialogProps) { const formId = useId(); const usernameRef = useRef(null); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx index cfe29028b39..bb94d3b00f2 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -3,8 +3,8 @@ import { Section } from '../../components/section'; import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; -import type { UserProfileEditNameValue } from './user-profile-edit-name.view'; -import { UserProfileEditNameView } from './user-profile-edit-name.view'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; export interface UserProfileNameRowViewProps { name: string; @@ -62,7 +62,7 @@ function EditName({ const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); return ( - Date: Mon, 14 Sep 2026 09:14:14 -0600 Subject: [PATCH 6/7] refactor(ui): colocate account section layout styles --- .../user-profile-account-section.styles.ts | 12 ++++++++++++ .../user-profile-account-section.view.tsx | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.styles.ts diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.styles.ts new file mode 100644 index 00000000000..e171b342c3a --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.styles.ts @@ -0,0 +1,12 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + sections: { + gap: space['8'], + display: 'flex', + flexDirection: 'column', + width: '100%', + }, +}); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index b09658e065d..e91604dfcf8 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -7,8 +7,8 @@ import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; import type { UserProfileMenuAction } from '../user-profile-action-menu'; import { UserProfileActionMenu } from '../user-profile-action-menu'; -import { styles } from '../user-profile-profile-panel.styles'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import { styles } from './user-profile-account-section.styles'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileNameRowView } from './user-profile-name-row.view'; From 41dceebf797e6a99c920871122445f3a535427b4 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Mon, 14 Sep 2026 11:04:29 -0600 Subject: [PATCH 7/7] fix(ui): preserve account contact styles on the base branch --- .../user-profile-account-section.view.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 9036cb5c877..0f293495329 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -7,6 +7,7 @@ import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; import type { UserProfileMenuAction } from '../user-profile-action-menu'; import { UserProfileActionMenu } from '../user-profile-action-menu'; +import { styles as panelStyles } from '../user-profile-profile-panel.styles'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; @@ -200,7 +201,7 @@ function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectio {label} {item ? ( - + {item.value} {item.isDefault ? {m.primary} : null} @@ -289,7 +290,7 @@ function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimar return ( - + {item.value} {item.isDefault ? {m.primary} : null}