From c368d896a26451001d2a826064074806aeca215d Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Fri, 11 Sep 2026 11:41:00 -0600 Subject: [PATCH 1/2] feat(ui): add an edit-username dialog to the user-profile account section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Username row's "Edit username" button opens a card-sized dialog with a single field. Saving commits through a new `onSubmitUsername`, replacing `onUsernameChange`; a rejection keeps the dialog open, showing the reason in a negative banner and, when it is a `UserProfileSaveError` naming the control, under the field itself. The controller owns open state, the typed value, pending and the error, so the view holds nothing and re-seeding the field is the `OPEN` transition rather than an effect. A guard on `SAVE` is what enforces the rule — there is nothing to save until the value moves, and an empty value is never saved, since clearing a username is not something the surface offers. Follows the edit-name flow it sits beside: same folder, same `onSubmit` naming, same `UserProfileSaveError`. No per-attribute prop, unlike edit-name — a username is always required, and an instance that autoprovisions one withholds `onSubmitUsername` so the row renders its value without an action. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015nA7S7H8uK8qPzwm1PwzoR --- .changeset/user-profile-edit-username.md | 2 + .../fixtures/user-profile-edit-username.ts | 30 +++ .../src/stories/fixtures/user-profile.ts | 5 +- .../user-profile-account-section.stories.tsx | 20 +- .../user-profile-profile-panel.stories.tsx | 5 +- .../user-profile-edit-username.view.test.tsx | 142 +++++++++++++++ .../user-profile-profile-panel.view.test.tsx | 19 +- .../user-profile-account-section.messages.ts | 5 + .../user-profile-account-section.view.tsx | 43 +++-- ...r-profile-edit-username.controller.test.ts | 172 ++++++++++++++++++ .../user-profile-edit-username.controller.ts | 113 ++++++++++++ .../user-profile-edit-username.view.tsx | 128 +++++++++++++ .../user-profile-profile-panel.view.tsx | 4 +- 13 files changed, 667 insertions(+), 21 deletions(-) create mode 100644 .changeset/user-profile-edit-username.md create mode 100644 packages/swingset/src/stories/fixtures/user-profile-edit-username.ts create mode 100644 packages/ui/src/mosaic/user-profile/__tests__/user-profile-edit-username.view.test.tsx create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.test.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts create mode 100644 packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.view.tsx 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..76d5d5b0731 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -0,0 +1,30 @@ +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; + /** Rejects every save instead of committing it. */ + failWith?: UserProfileFormError; +} + +/** Stands in for the model. Everything else the dialog needs belongs to the controller. */ +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..2421d29391f 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,16 @@ export function EditNameFails() { /> ); } + +/** Every username save is rejected, so the dialog shows a failure without losing what was typed. */ +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..a1224534244 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 @@ -40,6 +40,11 @@ export const userProfileAccountSectionBase = { username: { label: 'Username', edit: 'Edit username', + /** The dialog behind `edit`. */ + dialogTitle: 'Edit username', + fieldLabel: 'Username', + cancel: 'Cancel', + save: 'Save changes', }, primary: 'Primary', add: 'Add', 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..7b30d259b55 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. */ @@ -66,7 +68,8 @@ export interface UserProfileAccountSectionViewProps { 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; + /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. Omit to hide the action. */ + onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; @@ -95,7 +98,7 @@ export function UserProfileAccountSectionView({ onProfilePictureReject, onRemoveProfilePicture, onSubmitName, - onUsernameChange, + onSubmitUsername, onAddEmail, onManageEmail, onVerifyEmail, @@ -114,7 +117,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} @@ -336,6 +334,27 @@ function EditName({ ); } +/** Split out so the controller is mounted only where the action exists. */ +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..dc9741b4b95 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -0,0 +1,113 @@ +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; + /** Injected every render. What `OPEN` seeds the field from. */ + 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')); +} + +/** Clearing a username is not something the surface offers, so an empty value is never saveable. */ +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..58586a2ba9d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-edit-username.view.tsx @@ -0,0 +1,128 @@ +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; +} + +/** + * Edits the user's username. Holds nothing, and validates nothing: acceptability arrives as + * `canSave`, and the username the API will actually take is the API's to decide, so a rejection + * comes back as `error`. + */ +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 ? ( Date: Fri, 11 Sep 2026 12:16:17 -0600 Subject: [PATCH 2/2] remove unnecessary comments --- .../stories/fixtures/user-profile-edit-username.ts | 2 -- .../user-profile-account-section.stories.tsx | 1 - .../user-profile-account-section.messages.ts | 8 ++++---- .../user-profile-account-section.view.tsx | 13 ------------- .../user-profile-edit-username.controller.ts | 2 -- .../user-profile-edit-username.view.tsx | 6 ------ 6 files changed, 4 insertions(+), 28 deletions(-) diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts index 76d5d5b0731..83fb0a83da9 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -5,11 +5,9 @@ import { useState } from 'react'; export interface UserProfileEditUsernameFixtureOptions { username?: string; latency?: number; - /** Rejects every save instead of committing it. */ failWith?: UserProfileFormError; } -/** Stands in for the model. Everything else the dialog needs belongs to the controller. */ export function useUserProfileEditUsernameFixture({ username: initialUsername = 'prestonxyz', latency = 800, 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 2421d29391f..200d1667d72 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -103,7 +103,6 @@ export function EditNameFails() { ); } -/** Every username save is rejected, so the dialog shows a failure without losing what was typed. */ export function EditUsernameFails() { return ( 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; - /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. Omit to hide the action. */ onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; onManageEmail?: (id: string) => void; @@ -244,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, @@ -334,7 +322,6 @@ function EditName({ ); } -/** Split out so the controller is mounted only where the action exists. */ function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { const controller = useUserProfileEditUsernameController({ username, onSubmit }); 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 dc9741b4b95..a9cd97a4797 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 @@ -6,7 +6,6 @@ import type { UserProfileEditUsernameField } from './user-profile-edit-username. export interface UserProfileEditUsernameContext { saveUsername: (username: string) => Promise; - /** Injected every render. What `OPEN` seeds the field from. */ savedUsername: string; username: string; error: UserProfileFormError | undefined; @@ -24,7 +23,6 @@ function notSeated(): Promise { return Promise.reject(new Error('edit-username deps are not seated')); } -/** Clearing a username is not something the surface offers, so an empty value is never saveable. */ function isSaveable(context: UserProfileEditUsernameContext): boolean { return context.username !== context.savedUsername && context.username !== ''; } 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 index 58586a2ba9d..28382a0dc9c 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.view.tsx @@ -25,11 +25,6 @@ export interface UserProfileEditUsernameViewProps { onSubmit: () => void; } -/** - * Edits the user's username. Holds nothing, and validates nothing: acceptability arrives as - * `canSave`, and the username the API will actually take is the API's to decide, so a rejection - * comes back as `error`. - */ export function UserProfileEditUsernameView({ open, onOpenChange, @@ -60,7 +55,6 @@ export function UserProfileEditUsernameView({ {trigger ? : null}