diff --git a/.changeset/user-profile-edit-username.md b/.changeset/user-profile-edit-username.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/user-profile-edit-username.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts new file mode 100644 index 00000000000..83fb0a83da9 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -0,0 +1,28 @@ +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 { useState } from 'react'; + +export interface UserProfileEditUsernameFixtureOptions { + username?: string; + latency?: number; + failWith?: UserProfileFormError; +} + +export function useUserProfileEditUsernameFixture({ + username: initialUsername = 'prestonxyz', + latency = 800, + failWith, +}: UserProfileEditUsernameFixtureOptions = {}) { + const [username, setUsername] = useState(initialUsername); + + return { + username, + onSubmitUsername: async (value: string) => { + await new Promise(resolve => setTimeout(resolve, latency)); + if (failWith) { + throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); + } + setUsername(value); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index 9b8a9ce3eb8..a11315b5831 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -14,6 +14,7 @@ import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; +import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; export interface UserProfileFixtureOptions { /** Replaces the default "append an address" behaviour, e.g. to open a real prompt. */ @@ -44,6 +45,7 @@ const initialAPIKeys: UserProfileAPIKey[] = [ */ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions = {}) { const editName = useUserProfileEditNameFixture(); + const editUsername = useUserProfileEditUsernameFixture(); const [activePage, setActivePage] = useState('account'); const [emails, setEmails] = useState([ { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, @@ -112,10 +114,10 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const pages: UserProfileViewProps['pages'] = { account: { ...editName, + ...editUsername, allowMultipleAccounts: true, hasImage: Boolean(imageUrl), imageUrl, - username: 'prestonxyz', emails, phones, onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), @@ -137,7 +139,6 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions onRemovePhone: id => setPhones(current => current.filter(phone => phone.id !== id)), onSetPrimaryEmail: id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id }))), onSetPrimaryPhone: id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))), - onUsernameChange: () => undefined, onVerifyEmail: id => setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), onVerifyPhone: id => diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 47d47cc67cb..200d1667d72 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -10,6 +10,7 @@ import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; +import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; export { default as __source } from './user-profile-account-section.stories?raw'; @@ -25,11 +26,14 @@ export const meta: StoryMeta = { function AccountSection({ allowMultipleAccounts, failWith, + usernameFailWith, }: { allowMultipleAccounts: boolean; failWith?: UserProfileFormError; + usernameFailWith?: UserProfileFormError; }) { const editName = useUserProfileEditNameFixture({ failWith }); + const editUsername = useUserProfileEditUsernameFixture({ failWith: usernameFailWith }); const [emails, setEmails] = useState( allowMultipleAccounts ? [ @@ -46,12 +50,12 @@ function AccountSection({ return ( setEmails(current => [ ...current, @@ -74,7 +78,6 @@ function AccountSection({ onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} onRemoveProfilePicture={clearImage} - onUsernameChange={() => undefined} /> ); } @@ -99,3 +102,15 @@ export function EditNameFails() { /> ); } + +export function EditUsernameFails() { + return ( + + ); +} diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index ec593488fac..15f36e0ed77 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -6,6 +6,7 @@ import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; +import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; @@ -31,10 +32,12 @@ export function Default(_args: Record) { ]); const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl); const editName = useUserProfileEditNameFixture(); + const editUsername = useUserProfileEditUsernameFixture(); return ( ) { hasImage={Boolean(imageUrl)} imageUrl={imageUrl} phones={phones} - username='prestonxyz' onAddEmail={() => setEmails(current => [ ...current, @@ -99,7 +101,6 @@ export function Default(_args: Record) { onSetPrimaryPhone={() => undefined} onVerifyEmail={() => undefined} onVerifyPhone={() => undefined} - onUsernameChange={() => undefined} /> ); } 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.view.test.tsx new file mode 100644 index 00000000000..1ea123854d3 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.view.test.tsx @@ -0,0 +1,142 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileEditUsernameViewProps = { + open: true, + onOpenChange: vi.fn(), + username: 'prestonxyz', + onUsernameChange: vi.fn(), + onSubmit: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +const usernameField = () => screen.getByLabelText('Username'); +const saveButton = () => screen.getByRole('button', { name: 'Save changes' }); + +describe('UserProfileEditUsernameView', () => { + it('renders nothing until the caller opens it', () => { + renderView({ open: false }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('names the dialog and shows the value it was given', () => { + renderView(); + + expect(screen.getByRole('dialog', { name: 'Edit username' })).toBeInTheDocument(); + expect(usernameField()).toHaveValue('prestonxyz'); + }); + + it('opens on the field rather than the corner dismiss', async () => { + renderView(); + + // `FloatingFocusManager` moves focus in an effect, hence the wait. + await waitFor(() => expect(usernameField()).toHaveFocus()); + }); + + it('asks to open from the trigger', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderView({ open: false, onOpenChange, trigger: }); + + await user.click(screen.getByRole('button', { name: 'Edit username' })); + + expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); + }); + + it('reports each keystroke, holding nothing itself', async () => { + const onUsernameChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onUsernameChange }); + + await user.type(usernameField(), 'x'); + + expect(onUsernameChange).toHaveBeenCalledWith('prestonxyzx'); + // Controlled: the rendered value only moves when the caller says so. + expect(usernameField()).toHaveValue('prestonxyz'); + }); + + it('submits from the action and from enter in the field', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + renderView({ onSubmit }); + + await user.click(saveButton()); + // One field, so native implicit submission carries Enter with no submit button in the form. + await user.type(usernameField(), '{Enter}'); + + expect(onSubmit).toHaveBeenCalledTimes(2); + }); + + it('asks to close from cancel', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderView({ onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('announces the failure in a negative banner', () => { + renderView({ error: { message: 'Your username could not be updated.' } }); + + const banner = screen.getByRole('alert'); + expect(banner).toHaveAttribute('data-color', 'negative'); + expect(banner).toHaveTextContent('Your username could not be updated.'); + expect(usernameField()).not.toHaveAttribute('aria-invalid', 'true'); + }); + + it('renders a field-scoped failure with no banner', () => { + renderView({ error: { fields: { username: 'That username is taken.' } } }); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(screen.getByText('That username is taken.')).toBeInTheDocument(); + expect(usernameField()).toHaveAttribute('aria-invalid', 'true'); + }); + + it('withholds the save while the caller says the value is unacceptable', async () => { + const onSubmit = vi.fn(); + const user = userEvent.setup(); + renderView({ canSave: false, onSubmit }); + + // Inert but still reachable, so the reason stays discoverable by keyboard. + expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); + await user.click(saveButton()); + await user.type(usernameField(), '{Enter}'); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('stays inert while the save runs', async () => { + const onSubmit = vi.fn(); + const onUsernameChange = vi.fn(); + const user = userEvent.setup(); + renderView({ isSaving: true, onSubmit, onUsernameChange }); + + await user.type(usernameField(), 'ada'); + + expect(usernameField()).toBeDisabled(); + expect(onUsernameChange).not.toHaveBeenCalled(); + // Busy, not unavailable: the pending affordance is `isPending`, not a second disabled state. + expect(saveButton()).toHaveAttribute('aria-busy', 'true'); + await user.click(saveButton()); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); 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 a3627213e59..391198f30e5 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 @@ -33,7 +33,7 @@ describe('UserProfileProfilePanelView', () => { renderView({ onProfilePictureChange: vi.fn(), onSubmitName: () => Promise.resolve(), - onUsernameChange: vi.fn(), + onSubmitUsername: () => Promise.resolve(), }); expect(screen.getByRole('heading', { level: 3, name: 'Account' })).toBeInTheDocument(); @@ -372,6 +372,23 @@ describe('UserProfileProfilePanelView', () => { await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Edit name' })).not.toBeInTheDocument()); }); + it('drives the edit-username dialog from the section, seeded with the saved username', async () => { + const onSubmitUsername = vi.fn(() => Promise.resolve()); + const user = userEvent.setup(); + renderView({ username: 'prestonxyz', onSubmitUsername }); + + await user.click(screen.getByRole('button', { name: 'Edit username' })); + const dialog = screen.getByRole('dialog', { name: 'Edit username' }); + expect(within(dialog).getByLabelText('Username')).toHaveValue('prestonxyz'); + + await user.clear(within(dialog).getByLabelText('Username')); + await user.type(within(dialog).getByLabelText('Username'), 'preston'); + await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + expect(onSubmitUsername).toHaveBeenCalledWith('preston'); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Edit username' })).not.toBeInTheDocument()); + }); + it('matches the existing conditional contact and connected-account actions', async () => { const onVerifyEmail = vi.fn(); const onSetPrimaryEmail = vi.fn(); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index fe6b9ccc4b2..e3bd883524b 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -20,7 +20,7 @@ export const userProfileAccountSectionBase = { manage: 'Manage profile picture', change: 'Change avatar', remove: 'Remove avatar', - /** Shown under the description when the picker turns a file away. Keyed by rejection reason. */ + errors: { accept: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', size: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', @@ -30,7 +30,7 @@ export const userProfileAccountSectionBase = { name: { label: 'Name', edit: 'Edit name', - /** The dialog behind `edit`. */ + dialogTitle: 'Edit name', firstNameLabel: 'First name', lastNameLabel: 'Last name', @@ -40,13 +40,18 @@ export const userProfileAccountSectionBase = { username: { label: 'Username', edit: 'Edit username', + + dialogTitle: 'Edit username', + fieldLabel: 'Username', + cancel: 'Cancel', + save: 'Save changes', }, primary: 'Primary', add: 'Add', manage: 'Manage', setPrimary: 'Set as primary', completeVerification: 'Complete verification', - /** Names the action menu on one contact row, read as e.g. "Manage item1@clerk.dev". */ + manageValue: 'Manage {value}', email: { label: 'Email', 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 be6fe79a0ee..2a239bb7e55 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,8 @@ import type { UserProfileNameAttribute } from './user-profile-account-section.ty 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. */ @@ -51,22 +53,15 @@ export interface UserProfileAccountSectionViewProps { /** Passed alongside `name`, which cannot be split back into its two halves. */ firstName?: string; lastName?: string; - /** How the instance configures each half of the name; both enabled and optional by default. */ firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; emails: UserProfileEmail[]; phones: UserProfilePhone[]; onProfilePictureChange?: (file: File) => void; - /** - * Called with the files the picker turned away for type or size. The row already tells the user - * why, so this is for whatever else a consumer wants to do with them — logging, or a toast once - * there is one. - */ onProfilePictureReject?: (rejections: FileRejection[]) => void; onRemoveProfilePicture?: () => void; - /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. Omit to hide the action. */ onSubmitName?: (value: UserProfileEditNameValue) => Promise; - onUsernameChange?: (value: string) => void; + onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; @@ -95,7 +90,7 @@ export function UserProfileAccountSectionView({ onProfilePictureReject, onRemoveProfilePicture, onSubmitName, - onUsernameChange, + onSubmitUsername, onAddEmail, onManageEmail, onVerifyEmail, @@ -114,7 +109,6 @@ export function UserProfileAccountSectionView({ .slice(0, 2) .toUpperCase(); const [rejection, setRejection] = useState(null); - const updateUsername = onUsernameChange ? () => onUsernameChange(username) : undefined; return ( {m.username.label} {username} - {updateUsername ? ( + {onSubmitUsername ? ( - + ) : null} @@ -246,10 +236,6 @@ export function UserProfileAccountSectionView({ ); } -/** - * Sits inside `FileUpload.Root` so it can open the picker from a menu item, which is a plain - * callback rather than a `FileUpload.Trigger` button. - */ function ProfilePictureActions({ hasImage, canChange, @@ -336,6 +322,26 @@ function EditName({ ); } +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-edit-username.controller.test.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.test.ts new file mode 100644 index 00000000000..aca343d0963 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.test.ts @@ -0,0 +1,172 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { createActor } from '../../machine/createActor'; +import { UserProfileSaveError } from './user-profile-account-section.types'; +import { + userProfileEditUsernameMachine, + useUserProfileEditUsernameController, +} from './user-profile-edit-username.controller'; + +function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { + const actor = createActor(userProfileEditUsernameMachine, { context: { saveUsername, savedUsername } }).start(); + actor.send({ type: 'OPEN' }); + return actor; +} + +describe('userProfileEditUsernameMachine', () => { + it('seeds the field from the saved username on open', () => { + const actor = start(() => Promise.resolve()); + + expect(actor.getSnapshot().value).toBe('editing'); + expect(actor.getSnapshot().context.username).toBe('prestonxyz'); + }); + + it('returns to idle when the save lands, and can be opened again', async () => { + const actor = start(() => Promise.resolve()); + actor.send({ type: 'TYPE', value: 'preston' }); + actor.send({ type: 'SAVE' }); + expect(actor.getSnapshot().value).toBe('saving'); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); + expect(actor.getSnapshot().status).toBe('active'); + + actor.send({ type: 'OPEN' }); + expect(actor.getSnapshot().value).toBe('editing'); + }); + + it('re-seeds from the saved username on the next open, dropping what was typed', () => { + const actor = start(() => Promise.resolve()); + actor.send({ type: 'TYPE', value: 'ada' }); + actor.send({ type: 'CANCEL' }); + + actor.send({ type: 'OPEN' }); + + expect(actor.getSnapshot().context.username).toBe('prestonxyz'); + }); + + it('keeps what was typed when the save fails, so it can be corrected', async () => { + const actor = start(() => Promise.reject(new Error('That username is taken.'))); + actor.send({ type: 'TYPE', value: 'preston' }); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); + expect(actor.getSnapshot().context.username).toBe('preston'); + expect(actor.getSnapshot().context.error).toEqual({ message: 'That username is taken.', fields: undefined }); + }); + + it('carries field copy through when the rejection names the control', async () => { + const failure = new UserProfileSaveError('Your username could not be updated.', { + username: 'That username is taken.', + }); + const actor = start(() => Promise.reject(failure)); + actor.send({ type: 'TYPE', value: 'preston' }); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => + expect(actor.getSnapshot().context.error?.fields).toEqual({ username: 'That username is taken.' }), + ); + }); + + it('falls back to generic copy when the rejection is not an Error', async () => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test + const actor = start(() => Promise.reject('nope')); + actor.send({ type: 'TYPE', value: 'preston' }); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => + expect(actor.getSnapshot().context.error?.message).toBe('Something went wrong. Please try again.'), + ); + }); + + it('refuses to save a value that has not moved', () => { + const saveUsername = vi.fn(() => Promise.resolve()); + const actor = start(saveUsername); + + actor.send({ type: 'SAVE' }); + + // The guard holds the transition, so the rule survives a SAVE from anywhere, not just the button. + expect(actor.getSnapshot().value).toBe('editing'); + expect(saveUsername).not.toHaveBeenCalled(); + }); + + it('refuses to save an empty value, since clearing a username is not on offer', () => { + const saveUsername = vi.fn(() => Promise.resolve()); + const actor = start(saveUsername); + actor.send({ type: 'TYPE', value: '' }); + + actor.send({ type: 'SAVE' }); + + expect(actor.getSnapshot().value).toBe('editing'); + expect(saveUsername).not.toHaveBeenCalled(); + }); + + it('drops the error when the dialog is cancelled', async () => { + const actor = start(() => Promise.reject(new Error('nope'))); + actor.send({ type: 'TYPE', value: 'preston' }); + actor.send({ type: 'SAVE' }); + await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); + + actor.send({ type: 'CANCEL' }); + + expect(actor.getSnapshot().value).toBe('idle'); + expect(actor.getSnapshot().context.error).toBeUndefined(); + }); +}); + +describe('useUserProfileEditUsernameController', () => { + it('holds the dialog open across editing and saving, then closes on success', async () => { + const { result } = renderHook(() => + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), + ); + expect(result.current.isOpen).toBe(false); + + act(() => result.current.onOpenChange(true)); + expect(result.current.isOpen).toBe(true); + expect(result.current.username).toBe('prestonxyz'); + expect(result.current.isSaving).toBe(false); + + act(() => result.current.onUsernameChange('preston')); + expect(result.current.username).toBe('preston'); + + act(() => result.current.onSubmit()); + expect(result.current.isOpen).toBe(true); + expect(result.current.isSaving).toBe(true); + + await waitFor(() => expect(result.current.isOpen).toBe(false)); + }); + + it('saves the value it is currently holding', async () => { + const onSubmit = vi.fn(() => Promise.resolve()); + const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onUsernameChange('ada')); + act(() => result.current.onSubmit()); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ada')); + }); + + it('withholds the save until the value moves', () => { + const { result } = renderHook(() => + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), + ); + + act(() => result.current.onOpenChange(true)); + expect(result.current.canSave).toBe(false); + + act(() => result.current.onUsernameChange('ada')); + expect(result.current.canSave).toBe(true); + }); + + it('withholds the save on an empty value', () => { + const { result } = renderHook(() => + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), + ); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onUsernameChange('')); + + expect(result.current.canSave).toBe(false); + }); +}); 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 new file mode 100644 index 00000000000..a9cd97a4797 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -0,0 +1,111 @@ +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'; + +export interface UserProfileEditUsernameContext { + saveUsername: (username: string) => Promise; + savedUsername: string; + username: string; + error: UserProfileFormError | undefined; +} + +export type UserProfileEditUsernameEvent = + | { type: 'OPEN' } + | { type: 'TYPE'; value: string } + | { type: 'SAVE' } + | { type: 'CANCEL' }; + +const { createMachine, assign, fromPromise } = setup(); + +function notSeated(): Promise { + return Promise.reject(new Error('edit-username deps are not seated')); +} + +function isSaveable(context: UserProfileEditUsernameContext): boolean { + return context.username !== context.savedUsername && context.username !== ''; +} + +function toFormError(cause: unknown): UserProfileFormError { + if (cause instanceof UserProfileSaveError) { + return { message: cause.message, fields: cause.fields }; + } + if (cause instanceof Error) { + return { message: cause.message }; + } + return { message: 'Something went wrong. Please try again.' }; +} + +export const userProfileEditUsernameMachine = createMachine({ + id: 'editUsername', + initial: 'idle', + context: { + saveUsername: notSeated, + savedUsername: '', + username: '', + error: undefined, + }, + states: { + idle: { + on: { + OPEN: { + target: 'editing', + actions: assign(context => ({ username: context.savedUsername, error: undefined })), + }, + }, + }, + editing: { + on: { + TYPE: { actions: assign((_, event) => ({ username: event.value })) }, + SAVE: { target: 'saving', guard: isSaveable }, + CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + }, + }, + saving: { + invoke: fromPromise(context => context.saveUsername(context.username), { + onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + onError: { + target: 'editing', + actions: assign((_, event) => ({ error: toFormError(event.error) })), + }, + }), + }, + }, +}); + +export interface UserProfileEditUsernameControllerOptions { + username?: string; + onSubmit: (username: string) => Promise; +} + +export interface UserProfileEditUsernameController { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + username: string; + onUsernameChange: (value: string) => void; + onSubmit: () => void; + canSave: boolean; + isSaving: boolean; + error: UserProfileFormError | undefined; +} + +export function useUserProfileEditUsernameController({ + username = '', + onSubmit, +}: UserProfileEditUsernameControllerOptions): UserProfileEditUsernameController { + const [snapshot, send] = useMachine(userProfileEditUsernameMachine, { + context: { saveUsername: onSubmit, savedUsername: username }, + }); + + return { + isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', + onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), + username: snapshot.context.username, + onUsernameChange: value => send({ type: 'TYPE', value }), + onSubmit: () => send({ type: 'SAVE' }), + canSave: isSaveable(snapshot.context), + isSaving: snapshot.value === 'saving', + error: snapshot.context.error, + }; +} 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.view.tsx new file mode 100644 index 00000000000..28382a0dc9c --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.view.tsx @@ -0,0 +1,122 @@ +import type { FormEvent } from 'react'; +import { useId, useRef } from 'react'; + +import { Banner } from '../../components/banner'; +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import type { DialogTriggerProps } from '../../components/dialog'; +import { Dialog } from '../../components/dialog'; +import { Field } from '../../components/field'; +import { Input } from '../../components/input'; +import { userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; +import type { UserProfileFormError } from './user-profile-account-section.types'; + +export type UserProfileEditUsernameField = 'username'; + +export interface UserProfileEditUsernameViewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger?: DialogTriggerProps['render']; + username: string; + onUsernameChange: (value: string) => void; + canSave?: boolean; + isSaving?: boolean; + error?: UserProfileFormError; + onSubmit: () => void; +} + +export function UserProfileEditUsernameView({ + open, + onOpenChange, + trigger, + username, + onUsernameChange, + canSave = true, + isSaving = false, + error, + onSubmit, +}: UserProfileEditUsernameViewProps) { + const formId = useId(); + const usernameRef = useRef(null); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + if (canSave && !isSaving) { + onSubmit(); + } + }; + + return ( + + {trigger ? : null} + + + + {m.username.dialogTitle} + + + + } + > + {error?.message ? ( + + {error.message} + + ) : null} + + {m.username.fieldLabel} + onUsernameChange(event.target.value)} + /> + {error?.fields?.username ? {error.fields.username} : null} + + + + + {m.username.cancel} + + } + /> + + {m.username.save} + + + + + + ); +} 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 ffd3b4bf349..e77f736336e 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 @@ -55,7 +55,7 @@ export function UserProfileProfilePanelView({ onProfilePictureReject, onRemoveProfilePicture, onSubmitName, - onUsernameChange, + onSubmitUsername, onAddEmail, onManageEmail, onVerifyEmail, @@ -105,7 +105,7 @@ export function UserProfileProfilePanelView({ onVerifyEmail={onVerifyEmail} onVerifyPhone={onVerifyPhone} onSubmitName={onSubmitName} - onUsernameChange={onUsernameChange} + onSubmitUsername={onSubmitUsername} /> {connectedAccounts.length > 0 ? (