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/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/__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.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 d389730b187..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 @@ -1,27 +1,20 @@ -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'; 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 { 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'; -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'; - -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 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'; export interface UserProfileEmail { id: string; @@ -102,92 +95,31 @@ 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); - 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} - - - - - {m.name.label} - {name} - - {onSubmitName ? ( - - - - ) : null} - - - - - - {m.username.label} - {username} - - {onSubmitUsername ? ( - - - - ) : null} - - + + + {!allowMultipleAccounts ? ( ) : 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, - 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} - - } - /> - ); -} - -function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { - const controller = useUserProfileEditUsernameController({ username, onSubmit }); - - return ( - - {m.username.edit} - - } - /> +
); } @@ -375,7 +201,7 @@ function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectio {label} {item ? ( - + {item.value} {item.isDefault ? {m.primary} : null} @@ -464,7 +290,7 @@ function ContactRow({ kind, label, items, onAdd, onManage, onVerify, onSetPrimar return ( - + {item.value} {item.isDefault ? {m.primary} : null} 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 new file mode 100644 index 00000000000..bb94d3b00f2 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -0,0 +1,81 @@ +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.dialog'; +import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; + +export interface UserProfileNameRowViewProps { + name: string; + firstName?: string; + lastName?: string; + firstNameAttribute?: UserProfileNameAttribute; + lastNameAttribute?: UserProfileNameAttribute; + onSubmit?: (value: UserProfileEditNameValue) => Promise; +} + +export function UserProfileNameRowView({ + name, + firstName, + lastName, + firstNameAttribute, + lastNameAttribute, + onSubmit, +}: UserProfileNameRowViewProps) { + return ( + + + + {m.name.label} + {name} + + {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 new file mode 100644 index 00000000000..87dad81fe40 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -0,0 +1,137 @@ +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'; +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 [rejectionError, setRejectionError] = useState(); + const displayedError = errorMessage ?? rejectionError; + const initials = name + .split(/\s+/) + .map(part => part[0]) + .join('') + .slice(0, 2) + .toUpperCase(); + + return ( + } + 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); + } + }} + > + + + + + {initials} + + + + {m.picture.label} + {m.picture.description} + + + + {displayedError ? {displayedError} : 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; +} 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 new file mode 100644 index 00000000000..b5adeb23984 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -0,0 +1,51 @@ +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 { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; + +export interface UserProfileUsernameRowViewProps { + username: string; + onSubmit?: (username: string) => Promise; +} + +export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { + return ( + + + + {m.username.label} + {username} + + {onSubmit ? ( + + + + ) : null} + + + ); +} + +function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { + const controller = useUserProfileEditUsernameController({ username, onSubmit }); + + return ( + + {m.username.edit} + + } + /> + ); +} 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 index e77f736336e..fe998896899 100644 --- 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 @@ -21,7 +21,7 @@ export type { UserProfileFormError, UserProfileNameAttribute, } from './user-profile-account-section/user-profile-account-section.types'; -export type { UserProfileEditNameValue } from './user-profile-account-section/user-profile-edit-name.view'; +export type { UserProfileEditNameValue } from './user-profile-account-section/user-profile-edit-name.dialog'; export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { connectedAccounts?: UserProfileConnectedAccount[];