From 3df0be7cbaf9c07b1b875cb73c7cac9244c1f4fc Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:01:05 -0400 Subject: [PATCH 01/31] feat(mosaic): wire user profile account section to Clerk --- ...ofile-account-section.integration.test.tsx | 153 +++++++++++ ...ser-profile-account-section.model.test.tsx | 250 ++++++++++++++++++ .../user-profile-edit-name.controller.test.ts | 49 ++-- ...r-profile-edit-username.controller.test.ts | 59 +++-- .../user-profile-picture.controller.test.ts | 92 +++++++ .../user-profile-profile-panel.view.test.tsx | 23 +- .../user-profile-account-section.model.ts | 112 ++++++++ .../user-profile-account-section.tsx | 20 ++ .../user-profile-account-section.types.ts | 18 ++ .../user-profile-account-section.utils.ts | 47 ++++ .../user-profile-account-section.view.tsx | 43 +-- .../user-profile-edit-name.controller.ts | 25 +- .../user-profile-edit-username.controller.ts | 23 +- .../user-profile-name-row.view.tsx | 8 +- .../user-profile-picture-row.view.tsx | 21 +- .../user-profile-picture.controller.ts | 50 ++++ .../user-profile-username-row.view.tsx | 12 +- .../swingset/src/app/(clerk)/live-sidebar.tsx | 5 +- .../app/(clerk)/live/account-section/page.tsx | 37 +++ .../src/stories/fixtures/use-preview-image.ts | 2 + .../fixtures/user-profile-edit-name.ts | 6 +- .../fixtures/user-profile-edit-username.ts | 4 +- 22 files changed, 958 insertions(+), 101 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts create mode 100644 packages/swingset/src/app/(clerk)/live/account-section/page.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx new file mode 100644 index 00000000000..f3537baf54c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -0,0 +1,153 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type * as SharedReact from '@clerk/shared/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import type * as ReverificationModule from '../../reverification'; +import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; + +let usernameEnabled: boolean; +let namesEnabled: boolean; +let user: { + firstName: string; + lastName: string; + username: string; + imageUrl: string; + hasImage: boolean; + enterpriseAccounts: never[]; + primaryEmailAddressId: null; + primaryPhoneNumberId: null; + emailAddresses: never[]; + phoneNumbers: never[]; + setProfileImage: ReturnType; + update: ReturnType; +}; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: true, user }), + useSession: () => ({ session: { id: 'sess_1' } }), + useReverification: (fetcher: unknown) => fetcher, + useClerk: () => ({ + __internal_environment: { + userSettings: { + attributes: { + first_name: { enabled: namesEnabled, required: false }, + last_name: { enabled: namesEnabled, required: false }, + username: { enabled: usernameEnabled, required: false }, + }, + }, + }, + }), + }; +}); + +vi.mock('../../reverification', async importOriginal => ({ + ...(await importOriginal()), + Reverification: () => null, +})); + +function renderSection() { + return render( + + + , + ); +} + +beforeEach(() => { + usernameEnabled = true; + namesEnabled = true; + user = { + firstName: 'Preston', + lastName: 'Booth', + username: 'prestonxyz', + imageUrl: 'https://img.clerk.com/preston.png', + hasImage: false, + enterpriseAccounts: [], + primaryEmailAddressId: null, + primaryPhoneNumberId: null, + emailAddresses: [], + phoneNumbers: [], + setProfileImage: vi.fn(() => Promise.resolve({})), + update: vi.fn(() => Promise.resolve(user)), + }; +}); + +describe('UserProfileAccountSection', () => { + it('saves an edited name and closes the dialog', async () => { + const actor = userEvent.setup(); + renderSection(); + + await actor.click(screen.getByRole('button', { name: 'Edit name' })); + const dialog = screen.getByRole('dialog', { name: 'Edit name' }); + await actor.clear(within(dialog).getByLabelText('First name')); + await actor.type(within(dialog).getByLabelText('First name'), 'Pres'); + await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(user.update).toHaveBeenCalledExactlyOnceWith({ firstName: 'Pres', lastName: 'Booth' }); + }); + + it('keeps the username dialog open on the error the server names it for', async () => { + user.update.mockRejectedValue( + new ClerkAPIResponseError('failed', { + data: [ + { + code: 'form_identifier_exists', + message: 'Taken', + long_message: 'That username is taken. Please try another.', + meta: { param_name: 'username' }, + }, + ], + status: 422, + }), + ); + const actor = userEvent.setup(); + renderSection(); + + await actor.click(screen.getByRole('button', { name: 'Edit username' })); + const dialog = screen.getByRole('dialog', { name: 'Edit username' }); + await actor.type(within(dialog).getByLabelText('Username'), '2'); + await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + expect(await within(dialog).findByText('That username is taken. Please try another.')).toBeInTheDocument(); + expect(user.update).toHaveBeenCalledExactlyOnceWith({ username: 'prestonxyz2' }); + }); + + it('uploads a picked picture and shows why it failed', async () => { + user.setProfileImage.mockRejectedValue(new Error('Upload failed.')); + const actor = userEvent.setup(); + const { container } = renderSection(); + const file = new File(['x'], 'me.png', { type: 'image/png' }); + + const input = container.querySelector('input[type="file"]'); + if (!(input instanceof HTMLInputElement)) { + throw new Error('expected a file input'); + } + await actor.upload(input, file); + + expect(await screen.findByRole('alert')).toHaveTextContent('Upload failed.'); + expect(user.setProfileImage).toHaveBeenCalledExactlyOnceWith({ file }); + }); + + it('leaves the username row out when the instance does not use usernames', () => { + usernameEnabled = false; + renderSection(); + + expect(screen.queryByText('Username')).not.toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + it('leaves the name row out when the instance collects neither name', () => { + namesEnabled = false; + renderSection(); + + expect(screen.queryByText('Name')).not.toBeInTheDocument(); + expect(screen.getByText('Username')).toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx new file mode 100644 index 00000000000..6ddef651505 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -0,0 +1,250 @@ +import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; +import type * as SharedReact from '@clerk/shared/react'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileAccountSectionModel } from '../user-profile-account-section/user-profile-account-section.model'; + +interface FakeAttribute { + enabled: boolean; + required: boolean; + immutable?: boolean; + used_for_first_factor: boolean; + used_for_second_factor: boolean; +} + +let isUserLoaded: boolean; +let user: { + firstName: string | null; + lastName: string | null; + username: string | null; + imageUrl: string; + hasImage: boolean; + enterpriseAccounts: { active: boolean }[]; + primaryEmailAddressId: string | null; + primaryPhoneNumberId: string | null; + emailAddresses: { id: string; emailAddress: string; verification: { status: string | null } }[]; + phoneNumbers: { id: string; phoneNumber: string; verification: { status: string | null } }[]; + setProfileImage: ReturnType; + update: ReturnType; +} | null; +let attributes: Record<'first_name' | 'last_name' | 'username', FakeAttribute>; +let environmentHydrated: boolean; +let reverified: ReturnType; + +function attribute(overrides: Partial = {}): FakeAttribute { + return { enabled: true, required: false, used_for_first_factor: false, used_for_second_factor: false, ...overrides }; +} + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ session: { id: 'sess_1' } }), + useReverification: (fetcher: (...args: unknown[]) => Promise) => { + return (...args: unknown[]) => { + reverified(...args); + return fetcher(...args); + }; + }, + useClerk: () => ({ + __internal_environment: environmentHydrated ? { userSettings: { attributes } } : null, + }), + }; +}); + +function renderModel() { + return renderHook(() => useUserProfileAccountSectionModel()).result.current; +} + +function ready() { + const model = renderModel(); + if (model.status !== 'ready') { + throw new Error(`expected ready, got ${model.status}`); + } + return model; +} + +function apiError(paramName?: string) { + return new ClerkAPIResponseError('failed', { + data: [ + { + code: 'form_param_invalid', + message: 'Is invalid', + long_message: 'That value is invalid.', + meta: paramName ? { param_name: paramName } : undefined, + }, + ], + status: 422, + }); +} + +beforeEach(() => { + isUserLoaded = true; + environmentHydrated = true; + reverified = vi.fn(); + attributes = { first_name: attribute(), last_name: attribute(), username: attribute() }; + user = { + firstName: 'Preston', + lastName: 'Booth', + username: 'prestonxyz', + imageUrl: 'https://img.clerk.com/preston.png', + hasImage: true, + enterpriseAccounts: [], + primaryEmailAddressId: 'email_1', + primaryPhoneNumberId: null, + emailAddresses: [ + { id: 'email_2', emailAddress: 'other@clerk.dev', verification: { status: null } }, + { id: 'email_1', emailAddress: 'preston@clerk.dev', verification: { status: 'verified' } }, + ], + phoneNumbers: [{ id: 'phone_1', phoneNumber: '+18018888181', verification: { status: 'verified' } }], + setProfileImage: vi.fn(() => Promise.resolve({})), + update: vi.fn(() => Promise.resolve(user)), + }; +}); + +describe('useUserProfileAccountSectionModel', () => { + it('waits for the user and the environment', () => { + isUserLoaded = false; + expect(renderModel()).toEqual({ status: 'loading' }); + + isUserLoaded = true; + environmentHydrated = false; + expect(renderModel()).toEqual({ status: 'loading' }); + }); + + it('is hidden when nobody is signed in', () => { + user = null; + expect(renderModel()).toEqual({ status: 'hidden' }); + }); + + it('maps the user to plain row data', () => { + const model = ready(); + + expect(model).toMatchObject({ + name: 'Preston Booth', + firstName: 'Preston', + lastName: 'Booth', + username: 'prestonxyz', + imageUrl: 'https://img.clerk.com/preston.png', + hasImage: true, + firstNameAttribute: { enabled: true, required: false }, + lastNameAttribute: { enabled: true, required: false }, + emails: [ + { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'other@clerk.dev', isDefault: false, isVerified: false }, + ], + phones: [{ id: 'phone_1', value: '+18018888181', isDefault: false, isVerified: true }], + }); + expect(model.reverification).toEqual({ isActive: false }); + }); + + describe('profile picture', () => { + it('uploads the picked file', async () => { + const file = new File(['x'], 'me.png', { type: 'image/png' }); + await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ error: null }); + expect(user?.setProfileImage).toHaveBeenCalledWith({ file }); + }); + + it('removes an uploaded picture', async () => { + await ready().onRemoveProfilePicture?.(); + expect(user?.setProfileImage).toHaveBeenCalledWith({ file: null }); + }); + + it('offers no remove while the avatar is the generated default', () => { + if (user) { + user.hasImage = false; + } + expect(ready().onRemoveProfilePicture).toBeUndefined(); + }); + + it('returns the API message when the upload fails', async () => { + user?.setProfileImage.mockRejectedValue(apiError()); + const file = new File(['x'], 'me.png', { type: 'image/png' }); + await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ + error: { kind: 'form', message: 'That value is invalid.' }, + }); + }); + }); + + describe('name', () => { + it('saves both halves of the name', async () => { + await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ error: null }); + expect(user?.update).toHaveBeenCalledWith({ firstName: 'Pres', lastName: 'B' }); + }); + + it('maps a field error onto the control that caused it', async () => { + user?.update.mockRejectedValue(apiError('first_name')); + await expect(ready().onSubmitName?.({ firstName: '', lastName: 'B' })).resolves.toEqual({ + error: { kind: 'form', fields: { firstName: 'That value is invalid.' } }, + }); + }); + + it('is read only while an enterprise account is active', () => { + user?.enterpriseAccounts.push({ active: true }); + expect(ready().onSubmitName).toBeUndefined(); + }); + + it('passes the instance name attributes through for the row to hide itself', () => { + attributes.first_name = attribute({ enabled: false }); + attributes.last_name = attribute({ required: true }); + expect(ready()).toMatchObject({ + firstNameAttribute: { enabled: false, required: false }, + lastNameAttribute: { enabled: true, required: true }, + }); + }); + }); + + it('returns the message of a non-API failure', async () => { + user?.update.mockRejectedValue(new Error('Network down.')); + await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ + error: { kind: 'form', message: 'Network down.' }, + }); + }); + + describe('username', () => { + it('saves the username behind reverification', async () => { + await ready().onSubmitUsername?.('preston'); + expect(reverified).toHaveBeenCalledWith('preston'); + expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); + }); + + it('maps a username error onto the field', async () => { + user?.update.mockRejectedValue(apiError('username')); + await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ + error: { kind: 'form', fields: { username: 'That value is invalid.' } }, + }); + }); + + it('reports a cancelled reverification as cancelled, not as a failure', async () => { + user?.update.mockRejectedValue(new ClerkRuntimeError('cancelled', { code: 'reverification_cancelled' })); + await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ error: { kind: 'cancelled' } }); + }); + + it('is hidden when the instance does not use usernames', () => { + attributes.username = attribute({ enabled: false }); + expect(ready().username).toBeUndefined(); + }); + + it('stays available when usernames only sign in', () => { + attributes.username = attribute({ enabled: false, used_for_first_factor: true }); + expect(ready().username).toBe('prestonxyz'); + }); + + it('shows an immutable username without offering to edit it', () => { + attributes.username = attribute({ immutable: true }); + const model = ready(); + expect(model.username).toBe('prestonxyz'); + expect(model.onSubmitUsername).toBeUndefined(); + }); + + it('is hidden when immutable and never set', () => { + attributes.username = attribute({ immutable: true }); + if (user) { + user.username = null; + } + expect(ready().username).toBeUndefined(); + }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 8a9d757eb13..4a2d7e90a72 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -2,13 +2,24 @@ 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/user-profile-account-section.types'; +import type { + UserProfileFormError, + UserProfileSaveResult, +} from '../user-profile-account-section/user-profile-account-section.types'; import { userProfileEditNameMachine, useUserProfileEditNameController, } from '../user-profile-account-section/user-profile-edit-name.controller'; +import type { UserProfileEditNameField } from '../user-profile-account-section/user-profile-edit-name.dialog'; -function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { +type Result = UserProfileSaveResult; + +const saved = (): Promise => Promise.resolve({ error: null }); +const failed = (error: UserProfileFormError): Promise => + Promise.resolve({ error: { kind: 'form', ...error } }); +const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); + +function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { const actor = createActor(userProfileEditNameMachine, { context: { saveName, ...saved } }).start(); actor.send({ type: 'OPEN' }); return actor; @@ -16,7 +27,7 @@ function start(saveName: () => Promise, saved = { savedFirstName: 'Preston describe('userProfileEditNameMachine', () => { it('seeds both fields from the saved name on open', () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); expect(actor.getSnapshot().value).toBe('editing'); expect(actor.getSnapshot().context.firstName).toBe('Preston'); @@ -24,7 +35,7 @@ describe('userProfileEditNameMachine', () => { }); it('returns to idle when the save lands, and can be opened again', async () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); actor.send({ type: 'SAVE' }); expect(actor.getSnapshot().value).toBe('saving'); @@ -37,7 +48,7 @@ describe('userProfileEditNameMachine', () => { }); it('re-seeds from the saved name on the next open, dropping what was typed', () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); actor.send({ type: 'TYPE', field: 'firstName', value: 'Ada' }); actor.send({ type: 'CANCEL' }); @@ -47,7 +58,7 @@ describe('userProfileEditNameMachine', () => { }); it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => Promise.reject(new Error('Your name could not be updated.'))); + const actor = start(() => failed({ message: 'Your name could not be updated.' })); actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); actor.send({ type: 'SAVE' }); @@ -55,15 +66,13 @@ describe('userProfileEditNameMachine', () => { expect(actor.getSnapshot().context.lastName).toBe('Barton'); expect(actor.getSnapshot().context.error).toEqual({ message: 'Your name could not be updated.', - fields: undefined, }); }); - it('carries field copy through when the rejection names a control', async () => { - const failure = new UserProfileSaveError('Your name could not be updated.', { - firstName: 'First name is required.', - }); - const actor = start(() => Promise.reject(failure)); + it('carries field copy through when the error names a control', async () => { + const actor = start(() => + failed({ message: 'Your name could not be updated.', fields: { firstName: 'First name is required.' } }), + ); actor.send({ type: 'SAVE' }); await vi.waitFor(() => @@ -71,7 +80,7 @@ describe('userProfileEditNameMachine', () => { ); }); - it('falls back to generic copy when the rejection is not an Error', async () => { + it('falls back to generic copy when the save throws something that 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: 'SAVE' }); @@ -81,8 +90,16 @@ describe('userProfileEditNameMachine', () => { ); }); + it('stays open without a banner when the save is cancelled', async () => { + const actor = start(cancelled); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); + expect(actor.getSnapshot().context.error).toBeUndefined(); + }); + it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => Promise.reject(new Error('nope'))); + const actor = start(() => failed({ message: 'nope' })); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); @@ -96,7 +113,7 @@ describe('userProfileEditNameMachine', () => { describe('useUserProfileEditNameController', () => { it('holds the dialog open across editing and saving, then closes on success', async () => { const { result } = renderHook(() => - useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: () => Promise.resolve() }), + useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: saved }), ); expect(result.current.isOpen).toBe(false); @@ -116,7 +133,7 @@ describe('useUserProfileEditNameController', () => { }); it('saves the values it is currently holding', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); + const onSubmit = vi.fn(saved); const { result } = renderHook(() => useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }), ); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index c1c99cd6808..9994c7529f8 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -2,13 +2,24 @@ 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/user-profile-account-section.types'; +import type { + UserProfileFormError, + UserProfileSaveResult, +} from '../user-profile-account-section/user-profile-account-section.types'; import { userProfileEditUsernameMachine, useUserProfileEditUsernameController, } from '../user-profile-account-section/user-profile-edit-username.controller'; +import type { UserProfileEditUsernameField } from '../user-profile-account-section/user-profile-edit-username.dialog'; -function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { +type Result = UserProfileSaveResult; + +const saved = (): Promise => Promise.resolve({ error: null }); +const failed = (error: UserProfileFormError): Promise => + Promise.resolve({ error: { kind: 'form', ...error } }); +const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); + +function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { const actor = createActor(userProfileEditUsernameMachine, { context: { saveUsername, savedUsername } }).start(); actor.send({ type: 'OPEN' }); return actor; @@ -16,14 +27,14 @@ function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') describe('userProfileEditUsernameMachine', () => { it('seeds the field from the saved username on open', () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); 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()); + const actor = start(saved); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); expect(actor.getSnapshot().value).toBe('saving'); @@ -36,7 +47,7 @@ describe('userProfileEditUsernameMachine', () => { }); it('re-seeds from the saved username on the next open, dropping what was typed', () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); actor.send({ type: 'TYPE', value: 'ada' }); actor.send({ type: 'CANCEL' }); @@ -46,20 +57,19 @@ describe('userProfileEditUsernameMachine', () => { }); 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.'))); + const actor = start(() => failed({ message: '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 }); + expect(actor.getSnapshot().context.error).toEqual({ message: 'That username is taken.' }); }); - 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)); + it('carries field copy through when the error names the control', async () => { + const actor = start(() => + failed({ message: 'Your username could not be updated.', fields: { username: 'That username is taken.' } }), + ); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); @@ -68,7 +78,7 @@ describe('userProfileEditUsernameMachine', () => { ); }); - it('falls back to generic copy when the rejection is not an Error', async () => { + it('falls back to generic copy when the save throws something that 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' }); @@ -80,7 +90,7 @@ describe('userProfileEditUsernameMachine', () => { }); it('refuses to save a value that has not moved', () => { - const saveUsername = vi.fn(() => Promise.resolve()); + const saveUsername = vi.fn(saved); const actor = start(saveUsername); actor.send({ type: 'SAVE' }); @@ -91,7 +101,7 @@ describe('userProfileEditUsernameMachine', () => { }); it('refuses to save an empty value, since clearing a username is not on offer', () => { - const saveUsername = vi.fn(() => Promise.resolve()); + const saveUsername = vi.fn(saved); const actor = start(saveUsername); actor.send({ type: 'TYPE', value: '' }); @@ -101,8 +111,17 @@ describe('userProfileEditUsernameMachine', () => { expect(saveUsername).not.toHaveBeenCalled(); }); + it('stays open without a banner when the save is cancelled', async () => { + const actor = start(cancelled); + actor.send({ type: 'TYPE', value: 'preston' }); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); + expect(actor.getSnapshot().context.error).toBeUndefined(); + }); + it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => Promise.reject(new Error('nope'))); + const actor = start(() => failed({ message: 'nope' })); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); @@ -117,7 +136,7 @@ describe('userProfileEditUsernameMachine', () => { 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() }), + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), ); expect(result.current.isOpen).toBe(false); @@ -137,7 +156,7 @@ describe('useUserProfileEditUsernameController', () => { }); it('saves the value it is currently holding', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); + const onSubmit = vi.fn(saved); const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); act(() => result.current.onOpenChange(true)); @@ -149,7 +168,7 @@ describe('useUserProfileEditUsernameController', () => { it('withholds the save until the value moves', () => { const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), ); act(() => result.current.onOpenChange(true)); @@ -161,7 +180,7 @@ describe('useUserProfileEditUsernameController', () => { it('withholds the save on an empty value', () => { const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), ); act(() => result.current.onOpenChange(true)); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts new file mode 100644 index 00000000000..a1d749e7cca --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -0,0 +1,92 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { UserProfileSaveResult } from '../user-profile-account-section/user-profile-account-section.types'; +import type { UserProfilePictureControllerOptions } from '../user-profile-account-section/user-profile-picture.controller'; +import { useUserProfilePictureController } from '../user-profile-account-section/user-profile-picture.controller'; + +const file = new File(['x'], 'me.png', { type: 'image/png' }); + +function renderController(options: UserProfilePictureControllerOptions) { + return renderHook(() => useUserProfilePictureController(options)); +} + +describe('useUserProfilePictureController', () => { + it('leaves out the actions the model did not offer', () => { + const { result } = renderController({}); + expect(result.current.onChange).toBeUndefined(); + expect(result.current.onRemove).toBeUndefined(); + }); + + it('is pending while the upload runs', async () => { + const upload = createDeferredPromise(); + const onChange = vi.fn(() => upload.promise.then(() => ({ error: null }))); + const { result } = renderController({ onChange }); + + act(() => { + void result.current.onChange?.(file); + }); + expect(result.current.isPending).toBe(true); + expect(onChange).toHaveBeenCalledWith(file); + + await act(async () => { + upload.resolve(); + await upload.promise; + }); + expect(result.current.isPending).toBe(false); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('ignores a second pick while one is in flight', () => { + const onChange = vi.fn(() => new Promise(() => {})); + const { result } = renderController({ onChange }); + + act(() => { + void result.current.onChange?.(file); + }); + act(() => { + void result.current.onChange?.(file); + }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('shows why the upload failed, and clears it on the next attempt', async () => { + const onChange = vi + .fn() + .mockResolvedValueOnce({ error: { kind: 'form', message: 'Too big.' } }) + .mockResolvedValueOnce({ error: null }); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.errorMessage).toBe('Too big.'); + expect(result.current.isPending).toBe(false); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('shows nothing when the action is cancelled', async () => { + const onChange = vi.fn().mockResolvedValue({ error: { kind: 'cancelled' } }); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('shows the message of an unexpected throw', async () => { + const onChange = vi.fn().mockRejectedValue(new Error('Network down.')); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.errorMessage).toBe('Network down.'); + }); + + it('reports a failed removal the same way', async () => { + const onRemove = vi.fn().mockResolvedValue({ error: { kind: 'form', message: 'Nope.' } }); + const { result } = renderController({ onRemove }); + + await act(async () => result.current.onRemove?.()); + expect(result.current.errorMessage).toBe('Nope.'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index a12b39214b2..a2a46967cba 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -130,9 +130,9 @@ describe('UserProfileProfilePanelView', () => { it('composes the profile content without profile navigation', () => { renderView({ - onProfilePictureChange: vi.fn(), - onSubmitName: () => Promise.resolve(), - onSubmitUsername: () => Promise.resolve(), + onProfilePictureChange: vi.fn(() => Promise.resolve({ error: null })), + onSubmitName: () => Promise.resolve({ error: null }), + onSubmitUsername: () => Promise.resolve({ error: null }), }); expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); @@ -163,9 +163,12 @@ describe('UserProfileProfilePanelView', () => { }); it('uploads the picked file when no profile picture is set', async () => { - const onProfilePictureChange = vi.fn(); + const onProfilePictureChange = vi.fn(() => Promise.resolve({ error: null })); const user = userEvent.setup(); - const { container } = renderView({ onProfilePictureChange, onRemoveProfilePicture: vi.fn() }); + const { container } = renderView({ + onProfilePictureChange, + onRemoveProfilePicture: vi.fn(() => Promise.resolve({ error: null })), + }); expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); @@ -180,7 +183,7 @@ describe('UserProfileProfilePanelView', () => { }); it('turns away a file past the size the row advertises', async () => { - const onProfilePictureChange = vi.fn(); + const onProfilePictureChange = vi.fn(() => Promise.resolve({ error: null })); const onProfilePictureReject = vi.fn(); const user = userEvent.setup(); const { container } = renderView({ onProfilePictureChange, onProfilePictureReject }); @@ -200,7 +203,7 @@ describe('UserProfileProfilePanelView', () => { it('clears the rejection once an acceptable file is picked', async () => { const user = userEvent.setup(); - const { container } = renderView({ onProfilePictureChange: vi.fn() }); + const { container } = renderView({ onProfilePictureChange: vi.fn(() => Promise.resolve({ error: null })) }); const input = container.querySelector('input[type="file"]'); if (!input) { throw new Error('File picker not found'); @@ -371,7 +374,7 @@ describe('UserProfileProfilePanelView', () => { it('forwards profile and contact actions', async () => { const onAddEmail = vi.fn(); const onRemoveEmail = vi.fn(); - renderView({ onSubmitName: () => Promise.resolve(), onAddEmail, onRemoveEmail }); + renderView({ onSubmitName: () => Promise.resolve({ error: null }), onAddEmail, onRemoveEmail }); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Add email' })); @@ -390,7 +393,7 @@ describe('UserProfileProfilePanelView', () => { }); it('drives the edit-name dialog from the section, seeded with the saved name', async () => { - const onSubmitName = vi.fn(() => Promise.resolve()); + const onSubmitName = vi.fn(() => Promise.resolve({ error: null })); const user = userEvent.setup(); renderView({ firstName: 'Preston', lastName: 'Booth', onSubmitName }); @@ -408,7 +411,7 @@ describe('UserProfileProfilePanelView', () => { }); it('drives the edit-username dialog from the section, seeded with the saved username', async () => { - const onSubmitUsername = vi.fn(() => Promise.resolve()); + const onSubmitUsername = vi.fn(() => Promise.resolve({ error: null })); const user = userEvent.setup(); renderView({ username: 'prestonxyz', onSubmitUsername }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts new file mode 100644 index 00000000000..5b8266cef08 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -0,0 +1,112 @@ +import { getFullName } from '@clerk/shared/internal/clerk-js/user'; +import { useUser } from '@clerk/shared/react'; +import type { AttributeData, UserResource } from '@clerk/shared/types'; + +import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import type { ReverificationProps } from '../../reverification'; +import { useReverificationWithState } from '../../reverification'; +import type { + UserProfileEmail, + UserProfileNameAttribute, + UserProfilePhone, +} from './user-profile-account-section.types'; +import { isAttributeAvailable, toSaveResult } from './user-profile-account-section.utils'; +import type { UserProfileAccountSectionViewProps } from './user-profile-account-section.view'; +import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; +import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; + +type UserProfileAccountSectionData = Pick< + UserProfileAccountSectionViewProps, + | 'name' + | 'imageUrl' + | 'hasImage' + | 'firstName' + | 'lastName' + | 'firstNameAttribute' + | 'lastNameAttribute' + | 'username' + | 'emails' + | 'phones' + | 'onProfilePictureChange' + | 'onRemoveProfilePicture' + | 'onSubmitName' + | 'onSubmitUsername' +>; + +export type UserProfileAccountSectionModel = + | { status: 'loading' } + | { status: 'hidden' } + | (UserProfileAccountSectionData & { status: 'ready'; reverification: ReverificationProps }); + +const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; +const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; + +function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameAttribute { + return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; +} + +function primaryFirst(items: T[], primaryId: string | null): T[] { + return [...items.filter(item => item.id === primaryId), ...items.filter(item => item.id !== primaryId)]; +} + +function toEmails(user: UserResource): UserProfileEmail[] { + return primaryFirst(user.emailAddresses, user.primaryEmailAddressId).map(email => ({ + id: email.id, + value: email.emailAddress, + isDefault: email.id === user.primaryEmailAddressId, + isVerified: email.verification.status === 'verified', + })); +} + +function toPhones(user: UserResource): UserProfilePhone[] { + return primaryFirst(user.phoneNumbers, user.primaryPhoneNumberId).map(phone => ({ + id: phone.id, + value: phone.phoneNumber, + isDefault: phone.id === user.primaryPhoneNumberId, + isVerified: phone.verification.status === 'verified', + })); +} + +export function useUserProfileAccountSectionModel(): UserProfileAccountSectionModel { + const { isLoaded, user } = useUser(); + const environment = useMosaicEnvironment(); + const [updateUsername, reverification] = useReverificationWithState((username: string) => user?.update({ username })); + + if (!isLoaded || !environment) { + return { status: 'loading' }; + } + + if (!user) { + return { status: 'hidden' }; + } + + const { attributes } = environment.userSettings; + const usernameAttribute = attributes.username; + const usernameImmutable = Boolean(usernameAttribute?.immutable); + const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); + const nameReadOnly = user.enterpriseAccounts.some(account => account.active); + + return { + status: 'ready', + reverification, + name: getFullName(user), + firstName: user.firstName ?? '', + lastName: user.lastName ?? '', + firstNameAttribute: toNameAttribute(attributes.first_name), + lastNameAttribute: toNameAttribute(attributes.last_name), + imageUrl: user.imageUrl, + hasImage: user.hasImage, + username: showUsername ? (user.username ?? '') : undefined, + emails: toEmails(user), + phones: toPhones(user), + onProfilePictureChange: file => toSaveResult(() => user.setProfileImage({ file })), + onRemoveProfilePicture: user.hasImage ? () => toSaveResult(() => user.setProfileImage({ file: null })) : undefined, + onSubmitName: nameReadOnly + ? undefined + : value => toSaveResult(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), + onSubmitUsername: + showUsername && !usernameImmutable + ? username => toSaveResult(() => updateUsername(username), USERNAME_FIELDS) + : undefined, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx new file mode 100644 index 00000000000..27e9d37fcd9 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx @@ -0,0 +1,20 @@ +import { Reverification } from '../../reverification'; +import { useUserProfileAccountSectionModel } from './user-profile-account-section.model'; +import { UserProfileAccountSectionView } from './user-profile-account-section.view'; + +export function UserProfileAccountSection() { + const model = useUserProfileAccountSectionModel(); + + if (model.status !== 'ready') { + return null; + } + + const { status: _status, reverification, ...viewProps } = model; + + return ( + <> + + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts index 6b820314fb2..6f077a315a1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts @@ -8,6 +8,24 @@ export interface UserProfileFormError { fields?: Partial>; } +export type UserProfileSaveFailure = + | { kind: 'cancelled' } + | ({ kind: 'form' } & UserProfileFormError); + +export interface UserProfileSaveResult { + error: UserProfileSaveFailure | null; +} + +export function formErrorOf( + failure: UserProfileSaveFailure | null, +): UserProfileFormError | undefined { + if (failure?.kind !== 'form') { + return undefined; + } + const { kind: _kind, ...error } = failure; + return error; +} + /** One name attribute as the instance configures it. Supplied from `userSettings.attributes.first_name` and `last_name`. */ export interface UserProfileNameAttribute { /** @default true */ diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts new file mode 100644 index 00000000000..86a80f17f49 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts @@ -0,0 +1,47 @@ +import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; +import type { AttributeData } from '@clerk/shared/types'; + +import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; + +export function isAttributeAvailable(attribute: AttributeData | undefined): boolean { + return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor); +} + +function snakeToCamel(value: string): string { + return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + +export function toUserProfileFormError( + cause: unknown, + fields: readonly TField[], +): UserProfileFormError { + if (!isClerkAPIResponseError(cause)) { + return { message: cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' }; + } + const error: UserProfileFormError = {}; + for (const apiError of cause.errors) { + const text = apiError.longMessage || apiError.message; + const field = fields.find(f => apiError.meta?.paramName && snakeToCamel(apiError.meta.paramName) === f); + if (field) { + error.fields = { ...error.fields, [field]: error.fields?.[field] ?? text }; + } else { + error.message ??= text; + } + } + return error; +} + +export async function toSaveResult( + run: () => Promise, + fields: readonly TField[] = [], +): Promise> { + try { + await run(); + return { error: null }; + } catch (cause) { + if (isReverificationCancelledError(cause)) { + return { error: { kind: 'cancelled' } }; + } + return { error: { kind: 'form', ...toUserProfileFormError(cause, fields) } }; + } +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 890216a2425..9f81d81b85b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -8,8 +8,10 @@ import type { UserProfileEmail, UserProfileNameAttribute, UserProfilePhone, + UserProfileSaveResult, } from './user-profile-account-section.types'; -import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; import { UserProfileEmailRowView } from './user-profile-email-row.view'; import { UserProfileNameRowView } from './user-profile-name-row.view'; import { UserProfilePhoneRowView } from './user-profile-phone-row.view'; @@ -28,7 +30,7 @@ export interface UserProfileAccountSectionViewProps { */ hasImage?: boolean; name: string; - username: string; + username?: string; /** Passed alongside `name`, which cannot be split back into its two halves. */ firstName?: string; lastName?: string; @@ -36,11 +38,11 @@ export interface UserProfileAccountSectionViewProps { lastNameAttribute?: UserProfileNameAttribute; emails: UserProfileEmail[]; phones: UserProfilePhone[]; - onProfilePictureChange?: (file: File) => void; + onProfilePictureChange?: (file: File) => Promise; onProfilePictureReject?: (rejections: FileRejection[]) => void; - onRemoveProfilePicture?: () => void; - onSubmitName?: (value: UserProfileEditNameValue) => Promise; - onSubmitUsername?: (username: string) => Promise; + onRemoveProfilePicture?: () => Promise; + onSubmitName?: (value: UserProfileEditNameValue) => Promise>; + onSubmitUsername?: (username: string) => Promise>; onAddEmail?: () => void; onSendEmailCode?: (emailAddress: string) => Promise; onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; @@ -88,6 +90,7 @@ export function UserProfileAccountSectionView({ onRemovePhone, }: UserProfileAccountSectionViewProps) { const m = useMessages('userProfileAccountSection'); + const showName = firstNameAttribute?.enabled !== false || lastNameAttribute?.enabled !== false; const phoneRow = ( - - + {showName ? ( + + ) : null} + {username !== undefined ? ( + + ) : null} {!allowMultipleAccounts ? emailRow : null} {!allowMultipleAccounts ? phoneRow : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index 3c4c7f6adbd..7c9c1447f33 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -1,11 +1,11 @@ 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 { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; +import { formErrorOf } from './user-profile-account-section.types'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { - saveName: (value: UserProfileEditNameValue) => Promise; + saveName: (value: UserProfileEditNameValue) => Promise>; /** Injected every render. What `OPEN` seeds the fields from. */ savedFirstName: string; savedLastName: string; @@ -27,9 +27,6 @@ function notSeated(): Promise { } function toFormError(cause: unknown): UserProfileFormError { - if (cause instanceof UserProfileSaveError) { - return { message: cause.message, fields: cause.fields }; - } if (cause instanceof Error) { return { message: cause.message }; } @@ -69,7 +66,17 @@ export const userProfileEditNameMachine = createMachine({ }, saving: { invoke: fromPromise(context => context.saveName({ firstName: context.firstName, lastName: context.lastName }), { - onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + onDone: [ + { + guard: (_, event) => event.output.error !== null, + + target: 'editing', + + actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), + }, + + { target: 'idle', actions: assign(() => ({ error: undefined })) }, + ], onError: { target: 'editing', actions: assign((_, event) => ({ error: toFormError(event.error) })), @@ -82,8 +89,8 @@ export const userProfileEditNameMachine = createMachine({ export interface UserProfileEditNameControllerOptions { firstName?: string; lastName?: string; - /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ - onSubmit: (value: UserProfileEditNameValue) => Promise; + /** Resolve with `{ error: null }` to close the dialog, or with an error to keep it open showing why. */ + onSubmit: (value: UserProfileEditNameValue) => Promise>; } export interface UserProfileEditNameController { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index 367bc9db0d7..d87efc4f547 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -1,11 +1,11 @@ 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 { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; +import { formErrorOf } from './user-profile-account-section.types'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { - saveUsername: (username: string) => Promise; + saveUsername: (username: string) => Promise>; savedUsername: string; username: string; error: UserProfileFormError | undefined; @@ -28,9 +28,6 @@ function isSaveable(context: UserProfileEditUsernameContext): boolean { } function toFormError(cause: unknown): UserProfileFormError { - if (cause instanceof UserProfileSaveError) { - return { message: cause.message, fields: cause.fields }; - } if (cause instanceof Error) { return { message: cause.message }; } @@ -64,7 +61,17 @@ export const userProfileEditUsernameMachine = createMachine({ }, saving: { invoke: fromPromise(context => context.saveUsername(context.username), { - onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + onDone: [ + { + guard: (_, event) => event.output.error !== null, + + target: 'editing', + + actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), + }, + + { target: 'idle', actions: assign(() => ({ error: undefined })) }, + ], onError: { target: 'editing', actions: assign((_, event) => ({ error: toFormError(event.error) })), @@ -76,7 +83,7 @@ export const userProfileEditUsernameMachine = createMachine({ export interface UserProfileEditUsernameControllerOptions { username?: string; - onSubmit: (username: string) => Promise; + onSubmit: (username: string) => Promise>; } export interface UserProfileEditUsernameController { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx index 5f83e5dad9d..b2807f66877 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -1,9 +1,9 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { UserProfileNameAttribute } from './user-profile-account-section.types'; +import type { UserProfileNameAttribute, UserProfileSaveResult } from './user-profile-account-section.types'; import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; -import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; export interface UserProfileNameRowViewProps { @@ -12,7 +12,7 @@ export interface UserProfileNameRowViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit?: (value: UserProfileEditNameValue) => Promise; + onSubmit?: (value: UserProfileEditNameValue) => Promise>; } export function UserProfileNameRowView({ @@ -58,7 +58,7 @@ function EditName({ lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit: (value: UserProfileEditNameValue) => Promise; + onSubmit: (value: UserProfileEditNameValue) => Promise>; }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx index ff827ccb985..d6fc89976d1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -8,6 +8,8 @@ import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; +import type { UserProfileSaveResult } from './user-profile-account-section.types'; +import { useUserProfilePictureController } from './user-profile-picture.controller'; const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; /** Matches the limit the row's own description advertises. */ @@ -18,9 +20,9 @@ export interface UserProfilePictureRowViewProps { imageUrl?: string; hasImage?: boolean; errorMessage?: string; - onChange?: (file: File) => void; + onChange?: (file: File) => Promise; onReject?: (rejections: FileRejection[]) => void; - onRemove?: () => void; + onRemove?: () => Promise; } export function UserProfilePictureRowView({ @@ -33,8 +35,9 @@ export function UserProfilePictureRowView({ onRemove, }: UserProfilePictureRowViewProps) { const m = useMessages('userProfileAccountSection'); + const controller = useUserProfilePictureController({ onChange, onRemove }); const [rejectionError, setRejectionError] = useState(); - const displayedError = errorMessage ?? rejectionError; + const displayedError = errorMessage ?? rejectionError ?? controller.errorMessage; const initials = name .split(/\s+/) .map(part => part[0]) @@ -46,6 +49,8 @@ export function UserProfilePictureRowView({ } onReject={rejections => { const rejection = rejections[0]; @@ -56,7 +61,7 @@ export function UserProfilePictureRowView({ const file = files[0]; if (file) { setRejectionError(undefined); - onChange?.(file); + void controller.onChange?.(file); } }} > @@ -75,9 +80,9 @@ export function UserProfilePictureRowView({ {m.picture.description} {displayedError} @@ -92,7 +97,7 @@ function ProfilePictureActions({ }: { hasImage: boolean; canChange: boolean; - onRemove?: () => void; + onRemove?: () => Promise; }) { const m = useMessages('userProfileAccountSection'); const { openFilePicker } = FileUpload.useFileUpload(); @@ -103,7 +108,7 @@ function ProfilePictureActions({ } if (hasImage && onRemove) { - actions.push({ label: m.picture.remove, icon: 'x', onClick: onRemove }); + actions.push({ label: m.picture.remove, icon: 'x', onClick: () => void onRemove() }); } if (actions.length > 0) { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts new file mode 100644 index 00000000000..ae3cf28a6c9 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts @@ -0,0 +1,50 @@ +import { useRef, useState } from 'react'; + +import type { UserProfileSaveResult } from './user-profile-account-section.types'; +import { formErrorOf } from './user-profile-account-section.types'; + +export interface UserProfilePictureControllerOptions { + onChange?: (file: File) => Promise; + onRemove?: () => Promise; +} + +export interface UserProfilePictureController { + onChange?: (file: File) => Promise; + onRemove?: () => Promise; + isPending: boolean; + errorMessage: string | undefined; +} + +export function useUserProfilePictureController({ + onChange, + onRemove, +}: UserProfilePictureControllerOptions): UserProfilePictureController { + const [isPending, setIsPending] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const inFlight = useRef(false); + + const run = async (action: () => Promise) => { + if (inFlight.current) { + return; + } + inFlight.current = true; + setIsPending(true); + setErrorMessage(undefined); + try { + const { error } = await action(); + setErrorMessage(formErrorOf(error)?.message); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Something went wrong. Please try again.'); + } finally { + inFlight.current = false; + setIsPending(false); + } + }; + + return { + onChange: onChange ? file => run(() => onChange(file)) : undefined, + onRemove: onRemove ? () => run(onRemove) : undefined, + isPending, + errorMessage, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx index 5e1aefc11f5..18aca4f8cc1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -1,12 +1,14 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; +import type { UserProfileSaveResult } from './user-profile-account-section.types'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; +import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; export interface UserProfileUsernameRowViewProps { username: string; - onSubmit?: (username: string) => Promise; + onSubmit?: (username: string) => Promise>; } export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { @@ -31,7 +33,13 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs ); } -function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { +function EditUsername({ + username, + onSubmit, +}: { + username: string; + onSubmit: (username: string) => Promise>; +}) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index 2e206b399de..458b373f2fd 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -18,7 +18,10 @@ import { SidebarRail, } from '@/components/ui/sidebar'; -const flows = [{ title: 'Reverification', href: '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/live/reverification' }]; +const flows = [ + { title: 'Account section', href: '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/live/account-section' }, + { title: 'Reverification', href: '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/live/reverification' }, +]; export function LiveSidebar(props: ComponentProps) { const pathname = usePathname(); diff --git a/packages/swingset/src/app/(clerk)/live/account-section/page.tsx b/packages/swingset/src/app/(clerk)/live/account-section/page.tsx new file mode 100644 index 00000000000..39fb5565780 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/account-section/page.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { UserProfileAccountSection } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function AccountSectionLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Account section

+

+ The user profile account section wired to the signed-in user. Changes to the picture, name, and username are + saved to the real account. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to use the live harness. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/stories/fixtures/use-preview-image.ts b/packages/swingset/src/stories/fixtures/use-preview-image.ts index 10b34995775..fd8618fee8f 100644 --- a/packages/swingset/src/stories/fixtures/use-preview-image.ts +++ b/packages/swingset/src/stories/fixtures/use-preview-image.ts @@ -24,6 +24,7 @@ export function usePreviewImage(initialUrl?: string) { const next = URL.createObjectURL(file); objectUrlRef.current = next; setImageUrl(next); + return Promise.resolve({ error: null }); }, [release], ); @@ -31,6 +32,7 @@ export function usePreviewImage(initialUrl?: string) { const clearImage = useCallback(() => { release(); setImageUrl(undefined); + return Promise.resolve({ error: null }); }, [release]); return { imageUrl, showFile, clearImage }; 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 52084a183a6..e7be9a4e51f 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -1,5 +1,4 @@ import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; -import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import type { UserProfileEditNameValue } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog'; import { useState } from 'react'; @@ -10,7 +9,7 @@ export interface UserProfileEditNameFixtureOptions { firstName?: string; lastName?: string; latency?: number; - /** Rejects every save instead of committing it. */ + /** Fails every save instead of committing it. */ failWith?: UserProfileFormError; } @@ -33,9 +32,10 @@ export function useUserProfileEditNameFixture({ onSubmitName: async (value: UserProfileEditNameValue) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); + return { error: { kind: 'form' as const, ...failWith } }; } setName(value); + return { error: null }; }, }; } 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 7732c594918..f54b5bacd35 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -1,5 +1,4 @@ import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; -import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -24,9 +23,10 @@ export function useUserProfileEditUsernameFixture({ onSubmitUsername: async (value: string) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); + return { error: { kind: 'form' as const, ...failWith } }; } setUsername(value); + return { error: null }; }, }; } From 5d54a39921e9ae9a7bebc57b1c991750835252c9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:07:00 -0400 Subject: [PATCH 02/31] refactor(mosaic): use shared snakeToCamel --- .../user-profile-account-section.utils.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts index 86a80f17f49..66a90b0e10f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts @@ -1,5 +1,6 @@ import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; import type { AttributeData } from '@clerk/shared/types'; +import { snakeToCamel } from '@clerk/shared/underscore'; import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; @@ -7,10 +8,6 @@ export function isAttributeAvailable(attribute: AttributeData | undefined): bool return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor); } -function snakeToCamel(value: string): string { - return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); -} - export function toUserProfileFormError( cause: unknown, fields: readonly TField[], From 3fd8b31d62422dfe6469f05a7d90d43b5aedb877 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:13:31 -0400 Subject: [PATCH 03/31] refactor(mosaic): move save result helpers to utils --- .../user-profile-edit-name.controller.test.ts | 9 +-- ...r-profile-edit-username.controller.test.ts | 9 +-- .../user-profile-picture.controller.test.ts | 4 +- .../user-profile-account-section.model.ts | 3 +- .../user-profile-account-section.types.ts | 28 +--------- .../user-profile-account-section.utils.ts | 39 ------------- .../user-profile-account-section.view.tsx | 10 ++-- .../user-profile-edit-name.controller.ts | 14 ++--- .../user-profile-edit-name.dialog.tsx | 5 +- .../user-profile-edit-username.controller.ts | 14 ++--- .../user-profile-edit-username.dialog.tsx | 4 +- .../user-profile-name-row.view.tsx | 7 ++- .../user-profile-picture-row.view.tsx | 6 +- .../user-profile-picture.controller.ts | 10 ++-- .../user-profile-username-row.view.tsx | 6 +- .../user-profile-profile-panel.view.tsx | 5 +- packages/mosaic/src/utils/save-result.ts | 56 +++++++++++++++++++ .../fixtures/user-profile-edit-name.ts | 4 +- .../fixtures/user-profile-edit-password.ts | 4 +- .../fixtures/user-profile-edit-username.ts | 4 +- .../user-profile-account-section.stories.tsx | 6 +- .../user-profile-password-section.stories.tsx | 4 +- 22 files changed, 118 insertions(+), 133 deletions(-) create mode 100644 packages/mosaic/src/utils/save-result.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 4a2d7e90a72..18b4163c7f0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -2,20 +2,17 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import type { - UserProfileFormError, - UserProfileSaveResult, -} from '../user-profile-account-section/user-profile-account-section.types'; +import type { FormError, SaveResult } from '../../../utils/save-result'; import { userProfileEditNameMachine, useUserProfileEditNameController, } from '../user-profile-account-section/user-profile-edit-name.controller'; import type { UserProfileEditNameField } from '../user-profile-account-section/user-profile-edit-name.dialog'; -type Result = UserProfileSaveResult; +type Result = SaveResult; const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: UserProfileFormError): Promise => +const failed = (error: FormError): Promise => Promise.resolve({ error: { kind: 'form', ...error } }); const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 9994c7529f8..56abdc5ed0a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -2,20 +2,17 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import type { - UserProfileFormError, - UserProfileSaveResult, -} from '../user-profile-account-section/user-profile-account-section.types'; +import type { FormError, SaveResult } from '../../../utils/save-result'; import { userProfileEditUsernameMachine, useUserProfileEditUsernameController, } from '../user-profile-account-section/user-profile-edit-username.controller'; import type { UserProfileEditUsernameField } from '../user-profile-account-section/user-profile-edit-username.dialog'; -type Result = UserProfileSaveResult; +type Result = SaveResult; const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: UserProfileFormError): Promise => +const failed = (error: FormError): Promise => Promise.resolve({ error: { kind: 'form', ...error } }); const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index a1d749e7cca..51749ffa094 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -2,7 +2,7 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { UserProfileSaveResult } from '../user-profile-account-section/user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; import type { UserProfilePictureControllerOptions } from '../user-profile-account-section/user-profile-picture.controller'; import { useUserProfilePictureController } from '../user-profile-account-section/user-profile-picture.controller'; @@ -39,7 +39,7 @@ describe('useUserProfilePictureController', () => { }); it('ignores a second pick while one is in flight', () => { - const onChange = vi.fn(() => new Promise(() => {})); + const onChange = vi.fn(() => new Promise(() => {})); const { result } = renderController({ onChange }); act(() => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index 5b8266cef08..96bef0b8866 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -3,6 +3,7 @@ import { useUser } from '@clerk/shared/react'; import type { AttributeData, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import { toSaveResult } from '../../../utils/save-result'; import type { ReverificationProps } from '../../reverification'; import { useReverificationWithState } from '../../reverification'; import type { @@ -10,7 +11,7 @@ import type { UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; -import { isAttributeAvailable, toSaveResult } from './user-profile-account-section.utils'; +import { isAttributeAvailable } from './user-profile-account-section.utils'; import type { UserProfileAccountSectionViewProps } from './user-profile-account-section.view'; import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts index 6f077a315a1..ff0a40d25cd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts @@ -1,31 +1,5 @@ import { FormSubmitError } from '../../../components/form'; -/** Plain data, so nothing downstream of the model imports a Clerk error. */ -export interface UserProfileFormError { - /** Rendered in the dialog's negative banner. */ - message?: string; - /** Rendered under the named control, which is also marked invalid. */ - fields?: Partial>; -} - -export type UserProfileSaveFailure = - | { kind: 'cancelled' } - | ({ kind: 'form' } & UserProfileFormError); - -export interface UserProfileSaveResult { - error: UserProfileSaveFailure | null; -} - -export function formErrorOf( - failure: UserProfileSaveFailure | null, -): UserProfileFormError | undefined { - if (failure?.kind !== 'form') { - return undefined; - } - const { kind: _kind, ...error } = failure; - return error; -} - /** One name attribute as the instance configures it. Supplied from `userSettings.attributes.first_name` and `last_name`. */ export interface UserProfileNameAttribute { /** @default true */ @@ -56,4 +30,4 @@ export interface UserProfileEmail { isDefault?: boolean; isVerified?: boolean; canRemove?: boolean; -} +} \ No newline at end of file diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts index 66a90b0e10f..fa2aabc8e4f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts @@ -1,44 +1,5 @@ -import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; import type { AttributeData } from '@clerk/shared/types'; -import { snakeToCamel } from '@clerk/shared/underscore'; - -import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; export function isAttributeAvailable(attribute: AttributeData | undefined): boolean { return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor); } - -export function toUserProfileFormError( - cause: unknown, - fields: readonly TField[], -): UserProfileFormError { - if (!isClerkAPIResponseError(cause)) { - return { message: cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' }; - } - const error: UserProfileFormError = {}; - for (const apiError of cause.errors) { - const text = apiError.longMessage || apiError.message; - const field = fields.find(f => apiError.meta?.paramName && snakeToCamel(apiError.meta.paramName) === f); - if (field) { - error.fields = { ...error.fields, [field]: error.fields?.[field] ?? text }; - } else { - error.message ??= text; - } - } - return error; -} - -export async function toSaveResult( - run: () => Promise, - fields: readonly TField[] = [], -): Promise> { - try { - await run(); - return { error: null }; - } catch (cause) { - if (isReverificationCancelledError(cause)) { - return { error: { kind: 'cancelled' } }; - } - return { error: { kind: 'form', ...toUserProfileFormError(cause, fields) } }; - } -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 9f81d81b85b..1fbe41cec7d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -3,12 +3,12 @@ import * as stylex from '@stylexjs/stylex'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; +import type { SaveResult } from '../../../utils/save-result'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, UserProfileNameAttribute, UserProfilePhone, - UserProfileSaveResult, } from './user-profile-account-section.types'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; @@ -38,11 +38,11 @@ export interface UserProfileAccountSectionViewProps { lastNameAttribute?: UserProfileNameAttribute; emails: UserProfileEmail[]; phones: UserProfilePhone[]; - onProfilePictureChange?: (file: File) => Promise; + onProfilePictureChange?: (file: File) => Promise; onProfilePictureReject?: (rejections: FileRejection[]) => void; - onRemoveProfilePicture?: () => Promise; - onSubmitName?: (value: UserProfileEditNameValue) => Promise>; - onSubmitUsername?: (username: string) => Promise>; + onRemoveProfilePicture?: () => Promise; + onSubmitName?: (value: UserProfileEditNameValue) => Promise>; + onSubmitUsername?: (username: string) => Promise>; onAddEmail?: () => void; onSendEmailCode?: (emailAddress: string) => Promise; onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index 7c9c1447f33..2ee8a2a24d7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -1,17 +1,17 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; -import { formErrorOf } from './user-profile-account-section.types'; +import type { FormError, SaveResult } from '../../../utils/save-result'; +import { formErrorOf } from '../../../utils/save-result'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { - saveName: (value: UserProfileEditNameValue) => Promise>; + saveName: (value: UserProfileEditNameValue) => Promise>; /** Injected every render. What `OPEN` seeds the fields from. */ savedFirstName: string; savedLastName: string; firstName: string; lastName: string; - error: UserProfileFormError | undefined; + error: FormError | undefined; } export type UserProfileEditNameEvent = @@ -26,7 +26,7 @@ function notSeated(): Promise { return Promise.reject(new Error('edit-name deps are not seated')); } -function toFormError(cause: unknown): UserProfileFormError { +function toFormError(cause: unknown): FormError { if (cause instanceof Error) { return { message: cause.message }; } @@ -90,7 +90,7 @@ export interface UserProfileEditNameControllerOptions { firstName?: string; lastName?: string; /** Resolve with `{ error: null }` to close the dialog, or with an error to keep it open showing why. */ - onSubmit: (value: UserProfileEditNameValue) => Promise>; + onSubmit: (value: UserProfileEditNameValue) => Promise>; } export interface UserProfileEditNameController { @@ -102,7 +102,7 @@ export interface UserProfileEditNameController { onLastNameChange: (value: string) => void; onSubmit: () => void; isSaving: boolean; - error: UserProfileFormError | undefined; + error: FormError | undefined; } export function useUserProfileEditNameController({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index 589a41516b5..cb3fff7314c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -9,7 +9,8 @@ import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; import { useMessages } from '../../../localization'; -import type { UserProfileFormError, UserProfileNameAttribute } from './user-profile-account-section.types'; +import type { FormError } from '../../../utils/save-result'; +import type { UserProfileNameAttribute } from './user-profile-account-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; @@ -31,7 +32,7 @@ export interface UserProfileEditNameDialogProps { onFirstNameChange: (value: string) => void; onLastNameChange: (value: string) => void; isSaving?: boolean; - error?: UserProfileFormError; + error?: FormError; onSubmit: () => void; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index d87efc4f547..16ce655a27d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -1,14 +1,14 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; -import { formErrorOf } from './user-profile-account-section.types'; +import type { FormError, SaveResult } from '../../../utils/save-result'; +import { formErrorOf } from '../../../utils/save-result'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { - saveUsername: (username: string) => Promise>; + saveUsername: (username: string) => Promise>; savedUsername: string; username: string; - error: UserProfileFormError | undefined; + error: FormError | undefined; } export type UserProfileEditUsernameEvent = @@ -27,7 +27,7 @@ function isSaveable(context: UserProfileEditUsernameContext): boolean { return context.username !== context.savedUsername && context.username !== ''; } -function toFormError(cause: unknown): UserProfileFormError { +function toFormError(cause: unknown): FormError { if (cause instanceof Error) { return { message: cause.message }; } @@ -83,7 +83,7 @@ export const userProfileEditUsernameMachine = createMachine({ export interface UserProfileEditUsernameControllerOptions { username?: string; - onSubmit: (username: string) => Promise>; + onSubmit: (username: string) => Promise>; } export interface UserProfileEditUsernameController { @@ -94,7 +94,7 @@ export interface UserProfileEditUsernameController { onSubmit: () => void; canSave: boolean; isSaving: boolean; - error: UserProfileFormError | undefined; + error: FormError | undefined; } export function useUserProfileEditUsernameController({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx index 7fa55d04cb8..ebc0e81ea32 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx @@ -9,7 +9,7 @@ import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; import { useMessages } from '../../../localization'; -import type { UserProfileFormError } from './user-profile-account-section.types'; +import type { FormError } from '../../../utils/save-result'; export type UserProfileEditUsernameField = 'username'; @@ -21,7 +21,7 @@ export interface UserProfileEditUsernameDialogProps { onUsernameChange: (value: string) => void; canSave?: boolean; isSaving?: boolean; - error?: UserProfileFormError; + error?: FormError; onSubmit: () => void; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx index b2807f66877..1b7b9cf44b1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -1,7 +1,8 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { UserProfileNameAttribute, UserProfileSaveResult } from './user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; +import type { UserProfileNameAttribute } from './user-profile-account-section.types'; import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; @@ -12,7 +13,7 @@ export interface UserProfileNameRowViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit?: (value: UserProfileEditNameValue) => Promise>; + onSubmit?: (value: UserProfileEditNameValue) => Promise>; } export function UserProfileNameRowView({ @@ -58,7 +59,7 @@ function EditName({ lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit: (value: UserProfileEditNameValue) => Promise>; + onSubmit: (value: UserProfileEditNameValue) => Promise>; }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx index d6fc89976d1..b99d53b2b16 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -8,7 +8,7 @@ import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; -import type { UserProfileSaveResult } from './user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; import { useUserProfilePictureController } from './user-profile-picture.controller'; const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; @@ -20,9 +20,9 @@ export interface UserProfilePictureRowViewProps { imageUrl?: string; hasImage?: boolean; errorMessage?: string; - onChange?: (file: File) => Promise; + onChange?: (file: File) => Promise; onReject?: (rejections: FileRejection[]) => void; - onRemove?: () => Promise; + onRemove?: () => Promise; } export function UserProfilePictureRowView({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts index ae3cf28a6c9..fe221ec8e43 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts @@ -1,11 +1,11 @@ import { useRef, useState } from 'react'; -import type { UserProfileSaveResult } from './user-profile-account-section.types'; -import { formErrorOf } from './user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; +import { formErrorOf } from '../../../utils/save-result'; export interface UserProfilePictureControllerOptions { - onChange?: (file: File) => Promise; - onRemove?: () => Promise; + onChange?: (file: File) => Promise; + onRemove?: () => Promise; } export interface UserProfilePictureController { @@ -23,7 +23,7 @@ export function useUserProfilePictureController({ const [errorMessage, setErrorMessage] = useState(); const inFlight = useRef(false); - const run = async (action: () => Promise) => { + const run = async (action: () => Promise) => { if (inFlight.current) { return; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx index 18aca4f8cc1..6592ee48453 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -1,14 +1,14 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { UserProfileSaveResult } from './user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; export interface UserProfileUsernameRowViewProps { username: string; - onSubmit?: (username: string) => Promise>; + onSubmit?: (username: string) => Promise>; } export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { @@ -38,7 +38,7 @@ function EditUsername({ onSubmit, }: { username: string; - onSubmit: (username: string) => Promise>; + onSubmit: (username: string) => Promise>; }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 05f8ccede3a..42d900b0764 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -19,10 +19,7 @@ import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-prof import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet }; -export type { - UserProfileFormError, - UserProfileNameAttribute, -} from './user-profile-account-section/user-profile-account-section.types'; +export type { UserProfileNameAttribute } from './user-profile-account-section/user-profile-account-section.types'; export type { UserProfileEditNameValue } from './user-profile-account-section/user-profile-edit-name.dialog'; export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/save-result.ts new file mode 100644 index 00000000000..97861e890ef --- /dev/null +++ b/packages/mosaic/src/utils/save-result.ts @@ -0,0 +1,56 @@ +import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; +import { snakeToCamel } from '@clerk/shared/underscore'; + +/** Plain data, so nothing downstream of the model imports a Clerk error. */ +export interface FormError { + /** Rendered in the dialog's negative banner. */ + message?: string; + /** Rendered under the named control, which is also marked invalid. */ + fields?: Partial>; +} + +export type SaveFailure = { kind: 'cancelled' } | ({ kind: 'form' } & FormError); + +export interface SaveResult { + error: SaveFailure | null; +} + +export function formErrorOf(failure: SaveFailure | null): FormError | undefined { + if (failure?.kind !== 'form') { + return undefined; + } + const { kind: _kind, ...error } = failure; + return error; +} + +export function toFormError(cause: unknown, fields: readonly TField[]): FormError { + if (!isClerkAPIResponseError(cause)) { + return { message: cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' }; + } + const error: FormError = {}; + for (const apiError of cause.errors) { + const text = apiError.longMessage || apiError.message; + const field = fields.find(f => apiError.meta?.paramName && snakeToCamel(apiError.meta.paramName) === f); + if (field) { + error.fields = { ...error.fields, [field]: error.fields?.[field] ?? text }; + } else { + error.message ??= text; + } + } + return error; +} + +export async function toSaveResult( + run: () => Promise, + fields: readonly TField[] = [], +): Promise> { + try { + await run(); + return { error: null }; + } catch (cause) { + if (isReverificationCancelledError(cause)) { + return { error: { kind: 'cancelled' } }; + } + return { error: { kind: 'form', ...toFormError(cause, fields) } }; + } +} 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 e7be9a4e51f..67ef43013ae 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -1,5 +1,5 @@ -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import type { UserProfileEditNameValue } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog'; +import type { FormError } from '@clerk/mosaic/utils/save-result'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -10,7 +10,7 @@ export interface UserProfileEditNameFixtureOptions { lastName?: string; latency?: number; /** Fails every save instead of committing it. */ - failWith?: UserProfileFormError; + failWith?: FormError; } /** Stands in for the model. Everything else the dialog needs belongs to the controller. */ diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index a00865cc05e..b9395091ae0 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -1,6 +1,6 @@ -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import type { UserProfileEditPasswordValue } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; +import type { FormError } from '@clerk/mosaic/utils/save-result'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { @@ -8,7 +8,7 @@ export interface UserProfileEditPasswordFixtureOptions { requiresCurrentPassword?: boolean; latency?: number; /** Rejects the first save so the next attempt can succeed. */ - failWith?: UserProfileFormError; + failWith?: FormError; } export function useUserProfileEditPasswordFixture({ 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 f54b5bacd35..73d049889d5 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -1,4 +1,4 @@ -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import type { FormError } from '@clerk/mosaic/utils/save-result'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -7,7 +7,7 @@ import { chaosName } from '@/lib/chaos'; export interface UserProfileEditUsernameFixtureOptions { username?: string; latency?: number; - failWith?: UserProfileFormError; + failWith?: FormError; } export function useUserProfileEditUsernameFixture({ 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 3b17983b1c4..1b9ec90d1a9 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,5 +1,4 @@ import { Button } from '@clerk/mosaic/components/button'; -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import type { UserProfileEmail, UserProfilePhone, @@ -8,6 +7,7 @@ import { UserProfileAccountSectionView } from '@clerk/mosaic/features/user-profi import type { UserProfileAddPhoneDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; import { UserProfileVerifyEmailLinkDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog'; import { UserProfileVerifyEmailSsoDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog'; +import type { FormError } from '@clerk/mosaic/utils/save-result'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -43,8 +43,8 @@ function AccountSection({ }: { allowMultipleAccounts: boolean; failAt?: UserProfileAddPhoneDialogProps['step']; - failWith?: UserProfileFormError; - usernameFailWith?: UserProfileFormError; + failWith?: FormError; + usernameFailWith?: FormError; failEmailVerification?: boolean; emailRemovalState?: 'pending' | 'error'; phoneRemovalState?: 'pending' | 'error'; diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index a9162984b5c..e15881511e6 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,5 +1,5 @@ import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; +import type { FormError } from '@clerk/mosaic/utils/save-result'; import type { StoryMeta } from '@/lib/types'; @@ -24,7 +24,7 @@ function PasswordSection({ }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - failWith?: UserProfileFormError; + failWith?: FormError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); From b116533d13780abc2ea02f2a4ea59da212adf0af Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:35:53 -0400 Subject: [PATCH 04/31] fix(mosaic): make save errors localizable and rethrow unexpected errors --- ...ofile-account-section.integration.test.tsx | 11 ++++- ...ser-profile-account-section.model.test.tsx | 27 ++++++++--- .../user-profile-edit-name.controller.test.ts | 28 ++++++----- .../user-profile-edit-name.dialog.test.tsx | 6 +-- ...r-profile-edit-username.controller.test.ts | 28 ++++++----- ...user-profile-edit-username.dialog.test.tsx | 18 +++++-- .../user-profile-picture.controller.test.ts | 26 ++++++---- .../user-profile-edit-name.controller.ts | 14 +----- .../user-profile-edit-name.dialog.tsx | 11 +++-- .../user-profile-edit-username.controller.ts | 14 +----- .../user-profile-edit-username.dialog.tsx | 9 ++-- .../user-profile-picture-row.view.tsx | 5 +- .../user-profile-picture.controller.ts | 19 ++++---- .../src/localization/errors.messages.ts | 14 ++++++ .../mosaic/src/localization/errors.test.tsx | 48 +++++++++++++++++++ packages/mosaic/src/localization/errors.ts | 14 ++++++ packages/mosaic/src/localization/index.ts | 2 + .../src/localization/localization.test-d.ts | 8 ++++ packages/mosaic/src/localization/registry.ts | 2 + packages/mosaic/src/utils/save-result.ts | 42 +++++++++++----- .../fixtures/user-profile-edit-password.ts | 7 ++- .../user-profile-account-section.stories.tsx | 8 ++-- .../user-profile-password-section.stories.tsx | 11 ++--- 23 files changed, 252 insertions(+), 120 deletions(-) create mode 100644 packages/mosaic/src/localization/errors.messages.ts create mode 100644 packages/mosaic/src/localization/errors.test.tsx create mode 100644 packages/mosaic/src/localization/errors.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index f3537baf54c..c4435f2e01d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -120,7 +120,12 @@ describe('UserProfileAccountSection', () => { }); it('uploads a picked picture and shows why it failed', async () => { - user.setProfileImage.mockRejectedValue(new Error('Upload failed.')); + user.setProfileImage.mockRejectedValue( + new ClerkAPIResponseError('failed', { + data: [{ code: 'avatar_file_size_exceeded', message: 'Too large', long_message: 'Too large.' }], + status: 413, + }), + ); const actor = userEvent.setup(); const { container } = renderSection(); const file = new File(['x'], 'me.png', { type: 'image/png' }); @@ -131,7 +136,9 @@ describe('UserProfileAccountSection', () => { } await actor.upload(input, file); - expect(await screen.findByRole('alert')).toHaveTextContent('Upload failed.'); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + ); expect(user.setProfileImage).toHaveBeenCalledExactlyOnceWith({ file }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 6ddef651505..56327346e6f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -163,7 +163,7 @@ describe('useUserProfileAccountSectionModel', () => { user?.setProfileImage.mockRejectedValue(apiError()); const file = new File(['x'], 'me.png', { type: 'image/png' }); await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ - error: { kind: 'form', message: 'That value is invalid.' }, + error: { kind: 'form', global: { code: 'form_param_invalid', message: 'That value is invalid.' } }, }); }); }); @@ -177,7 +177,12 @@ describe('useUserProfileAccountSectionModel', () => { it('maps a field error onto the control that caused it', async () => { user?.update.mockRejectedValue(apiError('first_name')); await expect(ready().onSubmitName?.({ firstName: '', lastName: 'B' })).resolves.toEqual({ - error: { kind: 'form', fields: { firstName: 'That value is invalid.' } }, + error: { + kind: 'form', + fields: { + firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, + }, + }, }); }); @@ -196,13 +201,18 @@ describe('useUserProfileAccountSectionModel', () => { }); }); - it('returns the message of a non-API failure', async () => { - user?.update.mockRejectedValue(new Error('Network down.')); + it('returns a Clerk runtime failure by its code, without the developer message', async () => { + user?.update.mockRejectedValue(new ClerkRuntimeError('Network down.', { code: 'network_error' })); await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ - error: { kind: 'form', message: 'Network down.' }, + error: { kind: 'form', global: { code: 'network_error' } }, }); }); + it('rethrows a failure that is not from Clerk', async () => { + user?.update.mockRejectedValue(new TypeError('boom')); + await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).rejects.toThrow('boom'); + }); + describe('username', () => { it('saves the username behind reverification', async () => { await ready().onSubmitUsername?.('preston'); @@ -213,7 +223,12 @@ describe('useUserProfileAccountSectionModel', () => { it('maps a username error onto the field', async () => { user?.update.mockRejectedValue(apiError('username')); await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ - error: { kind: 'form', fields: { username: 'That value is invalid.' } }, + error: { + kind: 'form', + fields: { + username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, + }, + }, }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 18b4163c7f0..65c987f2bb4 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -55,36 +55,40 @@ describe('userProfileEditNameMachine', () => { }); it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => failed({ message: 'Your name could not be updated.' })); + const actor = start(() => failed({ global: { message: 'Your name could not be updated.' } })); actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); expect(actor.getSnapshot().context.lastName).toBe('Barton'); expect(actor.getSnapshot().context.error).toEqual({ - message: 'Your name could not be updated.', + global: { message: 'Your name could not be updated.' }, }); }); it('carries field copy through when the error names a control', async () => { const actor = start(() => - failed({ message: 'Your name could not be updated.', fields: { firstName: 'First name is required.' } }), + failed({ + global: { message: 'Your name could not be updated.' }, + fields: { firstName: { message: 'First name is required.' } }, + }), ); actor.send({ type: 'SAVE' }); await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ firstName: 'First name is required.' }), + expect(actor.getSnapshot().context.error?.fields).toEqual({ firstName: { message: 'First name is required.' } }), ); }); - it('falls back to generic copy when the save throws something that 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')); + it('shows the generic banner and logs when the save throws unexpectedly', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new TypeError('boom'); + const actor = start(() => Promise.reject(failure)); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.message).toBe('Something went wrong. Please try again.'), - ); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: {} })); + expect(log).toHaveBeenCalledWith(failure); + log.mockRestore(); }); it('stays open without a banner when the save is cancelled', async () => { @@ -96,9 +100,9 @@ describe('userProfileEditNameMachine', () => { }); it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => failed({ message: 'nope' })); + const actor = start(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); + await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); actor.send({ type: 'CANCEL' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx index 07a83dcbba9..7f2e8e17859 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx @@ -133,8 +133,8 @@ describe('UserProfileEditNameDialog', () => { it('announces the failure in a negative banner and marks the blamed field invalid', () => { renderView({ error: { - message: 'Your name could not be updated.', - fields: { lastName: 'Last name must be 64 characters or fewer.' }, + global: { message: 'Your name could not be updated.' }, + fields: { lastName: { message: 'Last name must be 64 characters or fewer.' } }, }, }); @@ -147,7 +147,7 @@ describe('UserProfileEditNameDialog', () => { }); it('renders a field-scoped failure with no banner', () => { - renderView({ error: { fields: { firstName: 'First name is required.' } } }); + renderView({ error: { fields: { firstName: { message: 'First name is required.' } } } }); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(firstNameField()).toHaveAttribute('aria-invalid', 'true'); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 56abdc5ed0a..9831410fd64 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -54,36 +54,40 @@ describe('userProfileEditUsernameMachine', () => { }); it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => failed({ message: 'That username is taken.' })); + const actor = start(() => failed({ global: { message: '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.' }); + expect(actor.getSnapshot().context.error).toEqual({ global: { message: 'That username is taken.' } }); }); it('carries field copy through when the error names the control', async () => { const actor = start(() => - failed({ message: 'Your username could not be updated.', fields: { username: 'That username is taken.' } }), + failed({ + global: { message: 'Your username could not be updated.' }, + fields: { username: { message: 'That username is taken.' } }, + }), ); 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.' }), + expect(actor.getSnapshot().context.error?.fields).toEqual({ username: { message: 'That username is taken.' } }), ); }); - it('falls back to generic copy when the save throws something that 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')); + it('shows the generic banner and logs when the save throws unexpectedly', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new TypeError('boom'); + const actor = start(() => Promise.reject(failure)); 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.'), - ); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: {} })); + expect(log).toHaveBeenCalledWith(failure); + log.mockRestore(); }); it('refuses to save a value that has not moved', () => { @@ -118,10 +122,10 @@ describe('userProfileEditUsernameMachine', () => { }); it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => failed({ message: 'nope' })); + const actor = start(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); + await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); actor.send({ type: 'CANCEL' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx index ad079f44a49..8acd861593a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx @@ -3,11 +3,12 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../../components/button'; +import type { MosaicLocalization } from '../../../localization'; import { MosaicProvider } from '../../../MosaicProvider'; 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 = {}) { +function renderView(overrides: Partial = {}, localization?: MosaicLocalization) { const props: UserProfileEditUsernameDialogProps = { open: true, onOpenChange: vi.fn(), @@ -19,7 +20,7 @@ function renderView(overrides: Partial = {}) return { props, ...render( - + , ), @@ -95,7 +96,7 @@ describe('UserProfileEditUsernameDialog', () => { }); it('announces the failure in a negative banner', () => { - renderView({ error: { message: 'Your username could not be updated.' } }); + renderView({ error: { global: { message: 'Your username could not be updated.' } } }); const banner = screen.getByRole('alert'); expect(banner).toHaveAttribute('data-color', 'negative'); @@ -104,13 +105,22 @@ describe('UserProfileEditUsernameDialog', () => { }); it('renders a field-scoped failure with no banner', () => { - renderView({ error: { fields: { username: 'That username is taken.' } } }); + renderView({ error: { fields: { username: { message: '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('shows the translation for the error code over the message Clerk sent', () => { + renderView( + { error: { fields: { username: { code: 'form_identifier_exists', paramName: 'username', message: 'Taken.' } } } }, + { overrides: { 'errors.form_identifier_exists__username': 'Ese nombre de usuario ya existe.' } }, + ); + + expect(usernameField()).toHaveAccessibleDescription('Ese nombre de usuario ya existe.'); + }); + it('withholds the save while the caller says the value is unacceptable', async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index 51749ffa094..e5cd146e671 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -35,7 +35,7 @@ describe('useUserProfilePictureController', () => { await upload.promise; }); expect(result.current.isPending).toBe(false); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.error).toBeUndefined(); }); it('ignores a second pick while one is in flight', () => { @@ -54,16 +54,18 @@ describe('useUserProfilePictureController', () => { it('shows why the upload failed, and clears it on the next attempt', async () => { const onChange = vi .fn() - .mockResolvedValueOnce({ error: { kind: 'form', message: 'Too big.' } }) + .mockResolvedValueOnce({ + error: { kind: 'form', global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } }, + }) .mockResolvedValueOnce({ error: null }); const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.errorMessage).toBe('Too big.'); + expect(result.current.error).toEqual({ code: 'avatar_file_size_exceeded', message: 'Too big.' }); expect(result.current.isPending).toBe(false); await act(async () => result.current.onChange?.(file)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.error).toBeUndefined(); }); it('shows nothing when the action is cancelled', async () => { @@ -71,22 +73,26 @@ describe('useUserProfilePictureController', () => { const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.error).toBeUndefined(); }); - it('shows the message of an unexpected throw', async () => { - const onChange = vi.fn().mockRejectedValue(new Error('Network down.')); + it('shows the generic error and logs an unexpected throw', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new TypeError('boom'); + const onChange = vi.fn().mockRejectedValue(failure); const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.errorMessage).toBe('Network down.'); + expect(result.current.error).toEqual({}); + expect(log).toHaveBeenCalledWith(failure); + log.mockRestore(); }); it('reports a failed removal the same way', async () => { - const onRemove = vi.fn().mockResolvedValue({ error: { kind: 'form', message: 'Nope.' } }); + const onRemove = vi.fn().mockResolvedValue({ error: { kind: 'form', global: { message: 'Nope.' } } }); const { result } = renderController({ onRemove }); await act(async () => result.current.onRemove?.()); - expect(result.current.errorMessage).toBe('Nope.'); + expect(result.current.error).toEqual({ message: 'Nope.' }); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index 2ee8a2a24d7..e1f212d6642 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf } from '../../../utils/save-result'; +import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { @@ -26,13 +26,6 @@ function notSeated(): Promise { return Promise.reject(new Error('edit-name deps are not seated')); } -function toFormError(cause: unknown): FormError { - if (cause instanceof Error) { - return { message: cause.message }; - } - return { message: 'Something went wrong. Please try again.' }; -} - export const userProfileEditNameMachine = createMachine({ id: 'editName', initial: 'idle', @@ -69,17 +62,14 @@ export const userProfileEditNameMachine = createMachine({ onDone: [ { guard: (_, event) => event.output.error !== null, - target: 'editing', - actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), }, - { target: 'idle', actions: assign(() => ({ error: undefined })) }, ], onError: { target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), + actions: assign((_, event) => ({ error: unexpectedFormError(event.error) })), }, }), }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index cb3fff7314c..c9571d88231 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -8,7 +8,7 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; -import { useMessages } from '../../../localization'; +import { useErrorText, useMessages } from '../../../localization'; import type { FormError } from '../../../utils/save-result'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; @@ -56,6 +56,7 @@ export function UserProfileEditNameDialog({ onSubmit, }: UserProfileEditNameDialogProps) { const m = useMessages('userProfileAccountSection'); + const errorText = useErrorText(); const formId = useId(); const initialFocusRef = useRef(null); const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; @@ -94,12 +95,12 @@ export function UserProfileEditNameDialog({ /> } > - {error?.message ? ( + {error?.global ? ( - {error.message} + {errorText(error.global)} ) : null} {showFirstName ? ( @@ -116,7 +117,7 @@ export function UserProfileEditNameDialog({ onChange={event => onFirstNameChange(event.target.value)} /> - {error?.fields?.firstName} + {error?.fields?.firstName ? errorText(error.fields.firstName) : null} ) : null} @@ -134,7 +135,7 @@ export function UserProfileEditNameDialog({ onChange={event => onLastNameChange(event.target.value)} /> - {error?.fields?.lastName} + {error?.fields?.lastName ? errorText(error.fields.lastName) : null} ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index 16ce655a27d..d30b3c81867 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf } from '../../../utils/save-result'; +import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { @@ -27,13 +27,6 @@ function isSaveable(context: UserProfileEditUsernameContext): boolean { return context.username !== context.savedUsername && context.username !== ''; } -function toFormError(cause: unknown): FormError { - if (cause instanceof Error) { - return { message: cause.message }; - } - return { message: 'Something went wrong. Please try again.' }; -} - export const userProfileEditUsernameMachine = createMachine({ id: 'editUsername', initial: 'idle', @@ -64,17 +57,14 @@ export const userProfileEditUsernameMachine = createMachine({ onDone: [ { guard: (_, event) => event.output.error !== null, - target: 'editing', - actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), }, - { target: 'idle', actions: assign(() => ({ error: undefined })) }, ], onError: { target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), + actions: assign((_, event) => ({ error: unexpectedFormError(event.error) })), }, }), }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx index ebc0e81ea32..335559accbf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx @@ -8,7 +8,7 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; -import { useMessages } from '../../../localization'; +import { useErrorText, useMessages } from '../../../localization'; import type { FormError } from '../../../utils/save-result'; export type UserProfileEditUsernameField = 'username'; @@ -37,6 +37,7 @@ export function UserProfileEditUsernameDialog({ onSubmit, }: UserProfileEditUsernameDialogProps) { const m = useMessages('userProfileAccountSection'); + const errorText = useErrorText(); const formId = useId(); const usernameRef = useRef(null); @@ -73,12 +74,12 @@ export function UserProfileEditUsernameDialog({ /> } > - {error?.message ? ( + {error?.global ? ( - {error.message} + {errorText(error.global)} ) : null} @@ -91,7 +92,7 @@ export function UserProfileEditUsernameDialog({ onChange={event => onUsernameChange(event.target.value)} /> - {error?.fields?.username} + {error?.fields?.username ? errorText(error.fields.username) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx index b99d53b2b16..96f9f9610f1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -5,7 +5,7 @@ import { ActionMenu } from '../../../components/action-menu'; import { Avatar } from '../../../components/avatar'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; -import { useMessages } from '../../../localization'; +import { useErrorText, useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; import type { SaveResult } from '../../../utils/save-result'; @@ -35,9 +35,10 @@ export function UserProfilePictureRowView({ onRemove, }: UserProfilePictureRowViewProps) { const m = useMessages('userProfileAccountSection'); + const errorText = useErrorText(); const controller = useUserProfilePictureController({ onChange, onRemove }); const [rejectionError, setRejectionError] = useState(); - const displayedError = errorMessage ?? rejectionError ?? controller.errorMessage; + const displayedError = errorMessage ?? rejectionError ?? (controller.error ? errorText(controller.error) : undefined); const initials = name .split(/\s+/) .map(part => part[0]) diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts index fe221ec8e43..8221415ee03 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts @@ -1,7 +1,8 @@ import { useRef, useState } from 'react'; +import type { LocalizableError } from '../../../localization'; import type { SaveResult } from '../../../utils/save-result'; -import { formErrorOf } from '../../../utils/save-result'; +import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; export interface UserProfilePictureControllerOptions { onChange?: (file: File) => Promise; @@ -12,7 +13,7 @@ export interface UserProfilePictureController { onChange?: (file: File) => Promise; onRemove?: () => Promise; isPending: boolean; - errorMessage: string | undefined; + error: LocalizableError | undefined; } export function useUserProfilePictureController({ @@ -20,7 +21,7 @@ export function useUserProfilePictureController({ onRemove, }: UserProfilePictureControllerOptions): UserProfilePictureController { const [isPending, setIsPending] = useState(false); - const [errorMessage, setErrorMessage] = useState(); + const [error, setError] = useState(); const inFlight = useRef(false); const run = async (action: () => Promise) => { @@ -29,12 +30,12 @@ export function useUserProfilePictureController({ } inFlight.current = true; setIsPending(true); - setErrorMessage(undefined); + setError(undefined); try { - const { error } = await action(); - setErrorMessage(formErrorOf(error)?.message); - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Something went wrong. Please try again.'); + const result = await action(); + setError(formErrorOf(result.error)?.global); + } catch (cause) { + setError(unexpectedFormError(cause).global); } finally { inFlight.current = false; setIsPending(false); @@ -45,6 +46,6 @@ export function useUserProfilePictureController({ onChange: onChange ? file => run(() => onChange(file)) : undefined, onRemove: onRemove ? () => run(onRemove) : undefined, isPending, - errorMessage, + error, }; } diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts new file mode 100644 index 00000000000..faac31e6c9b --- /dev/null +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -0,0 +1,14 @@ +export const errorMessages: { readonly generic: string } & Readonly> = { + generic: 'Something went wrong. Please try again.', + action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.", + avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + avatar_file_type_invalid: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', + form_new_password_matches_current: 'New password cannot be the same as the current password.', + form_password_length_too_short: 'Your password is too short. It must be at least 8 characters long.', + form_password_matches_identifier: + 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', + form_password_not_strong_enough: 'Your password is not strong enough.', + form_password_pwned: + 'This password has been found as part of a breach and can not be used, please try another password instead.', + form_username_needs_non_number_char: 'Your username must contain at least one non-numeric character.', +}; diff --git a/packages/mosaic/src/localization/errors.test.tsx b/packages/mosaic/src/localization/errors.test.tsx new file mode 100644 index 00000000000..e7194b31835 --- /dev/null +++ b/packages/mosaic/src/localization/errors.test.tsx @@ -0,0 +1,48 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { MosaicCatalog } from './catalog'; +import { MosaicLocalizationProvider, resolveLocalization } from './context'; +import { useErrorText } from './errors'; + +function errorText(overrides?: MosaicCatalog) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useErrorText(), { wrapper }).result.current; +} + +describe('useErrorText', () => { + it('prefers the message for the code on that field', () => { + const text = errorText({ + 'errors.form_identifier_exists__username': 'Nombre de usuario en uso.', + 'errors.form_identifier_exists': 'Ya existe.', + }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe( + 'Nombre de usuario en uso.', + ); + }); + + it('falls back to the message for the code', () => { + const text = errorText({ 'errors.form_identifier_exists': 'Ya existe.' }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe('Ya existe.'); + }); + + it('ships English for known codes', () => { + expect(errorText()({ code: 'avatar_file_type_invalid', message: 'api text' })).toBe( + 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', + ); + }); + + it('falls back to the message Clerk sent for a code it has no text for', () => { + expect(errorText()({ code: 'form_identifier_exists', message: 'That username is taken.' })).toBe( + 'That username is taken.', + ); + }); + + it('falls back to the generic message when there is nothing else', () => { + expect(errorText({ 'errors.generic': 'Algo salió mal.' })({})).toBe('Algo salió mal.'); + expect(errorText()({ code: 'toString' })).toBe('Something went wrong. Please try again.'); + }); +}); diff --git a/packages/mosaic/src/localization/errors.ts b/packages/mosaic/src/localization/errors.ts new file mode 100644 index 00000000000..9dd17f65dc5 --- /dev/null +++ b/packages/mosaic/src/localization/errors.ts @@ -0,0 +1,14 @@ +import { useMessages } from './context'; + +export interface LocalizableError { + code?: string; + paramName?: string; + message?: string; +} + +export function useErrorText(): (error: LocalizableError) => string { + const messages = useMessages('errors'); + const lookup = (key: string | undefined) => (key && Object.hasOwn(messages, key) ? messages[key] : undefined); + return ({ code, paramName, message }) => + lookup(code && paramName ? `${code}__${paramName}` : undefined) ?? lookup(code) ?? message ?? messages.generic; +} diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts index fbfc32e732c..cfff089fc6d 100644 --- a/packages/mosaic/src/localization/index.ts +++ b/packages/mosaic/src/localization/index.ts @@ -1,4 +1,6 @@ export type { MosaicCatalog, MosaicLocalization } from './catalog'; +export type { LocalizableError } from './errors'; +export { useErrorText } from './errors'; export { MosaicLocalizationProvider, resolveLocalization, useLocale, useMessages } from './context'; export type { MosaicMessages } from './registry'; export { mosaicMessages } from './registry'; diff --git a/packages/mosaic/src/localization/localization.test-d.ts b/packages/mosaic/src/localization/localization.test-d.ts index fe2150c3c46..c3844e7ee94 100644 --- a/packages/mosaic/src/localization/localization.test-d.ts +++ b/packages/mosaic/src/localization/localization.test-d.ts @@ -24,6 +24,14 @@ describe('MosaicCatalog', () => { void value; }); + test('accepts text for any error code Clerk may return', () => { + const value: MosaicCatalog = { + 'errors.form_identifier_exists__username': 'Nombre de usuario en uso.', + errors: { generic: 'Algo salió mal.', form_param_nil: 'Obligatorio.' }, + }; + void value; + }); + test('rejects unknown keys and paths', () => { const value: MosaicCatalog = { // @ts-expect-error typo in the path diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 34a933c8880..6e4a6cae586 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -27,8 +27,10 @@ import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/ import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section.messages'; import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; +import { errorMessages } from './errors.messages'; export const mosaicMessages = { + errors: errorMessages, form: formMessages, membersTableTab: membersTableTabMessages, invitationsTableTab: invitationsTableTabMessages, diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/save-result.ts index 97861e890ef..a80771b01f4 100644 --- a/packages/mosaic/src/utils/save-result.ts +++ b/packages/mosaic/src/utils/save-result.ts @@ -1,12 +1,11 @@ -import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; +import { isClerkAPIResponseError, isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; import { snakeToCamel } from '@clerk/shared/underscore'; -/** Plain data, so nothing downstream of the model imports a Clerk error. */ +import type { LocalizableError } from '../localization'; + export interface FormError { - /** Rendered in the dialog's negative banner. */ - message?: string; - /** Rendered under the named control, which is also marked invalid. */ - fields?: Partial>; + global?: LocalizableError; + fields?: Partial>; } export type SaveFailure = { kind: 'cancelled' } | ({ kind: 'form' } & FormError); @@ -23,18 +22,31 @@ export function formErrorOf(failure: SaveFailure return error; } -export function toFormError(cause: unknown, fields: readonly TField[]): FormError { +export function unexpectedFormError(cause: unknown): FormError { + console.error(cause); + return { global: {} }; +} + +function toFormError(cause: unknown, fields: readonly TField[]): FormError | undefined { + if (isClerkRuntimeError(cause)) { + return { global: { code: cause.code, ...(cause.longMessage ? { message: cause.longMessage } : {}) } }; + } if (!isClerkAPIResponseError(cause)) { - return { message: cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' }; + return undefined; } const error: FormError = {}; for (const apiError of cause.errors) { - const text = apiError.longMessage || apiError.message; - const field = fields.find(f => apiError.meta?.paramName && snakeToCamel(apiError.meta.paramName) === f); + const paramName = apiError.meta?.paramName; + const localizable: LocalizableError = { + code: apiError.code, + ...(paramName ? { paramName } : {}), + message: apiError.longMessage || apiError.message, + }; + const field = fields.find(f => paramName && snakeToCamel(paramName) === f); if (field) { - error.fields = { ...error.fields, [field]: error.fields?.[field] ?? text }; + error.fields = { ...error.fields, [field]: error.fields?.[field] ?? localizable }; } else { - error.message ??= text; + error.global ??= localizable; } } return error; @@ -51,6 +63,10 @@ export async function toSaveResult( if (isReverificationCancelledError(cause)) { return { error: { kind: 'cancelled' } }; } - return { error: { kind: 'form', ...toFormError(cause, fields) } }; + const error = toFormError(cause, fields); + if (!error) { + throw cause; + } + return { error: { kind: 'form', ...error } }; } } diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index b9395091ae0..a7711b2469a 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -1,6 +1,5 @@ -import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import type { UserProfileEditPasswordValue } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; -import type { FormError } from '@clerk/mosaic/utils/save-result'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { @@ -8,7 +7,7 @@ export interface UserProfileEditPasswordFixtureOptions { requiresCurrentPassword?: boolean; latency?: number; /** Rejects the first save so the next attempt can succeed. */ - failWith?: FormError; + failWith?: UserProfileSaveError; } export function useUserProfileEditPasswordFixture({ @@ -27,7 +26,7 @@ export function useUserProfileEditPasswordFixture({ await new Promise(resolve => setTimeout(resolve, latency)); if (failWith && !hasFailed) { setHasFailed(true); - throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); + throw failWith; } setHasPassword(true); }, 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 1b9ec90d1a9..c1190e3a4c1 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -207,8 +207,8 @@ export function EditNameFails() { ); @@ -219,8 +219,8 @@ export function EditUsernameFails() { ); diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index e15881511e6..58d71efb577 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,5 +1,5 @@ +import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; -import type { FormError } from '@clerk/mosaic/utils/save-result'; import type { StoryMeta } from '@/lib/types'; @@ -24,7 +24,7 @@ function PasswordSection({ }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - failWith?: FormError; + failWith?: UserProfileSaveError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); @@ -73,10 +73,9 @@ export function ManagedByEnterprise() { export function EditPasswordFails() { return ( ); } From 5c9d8006b79999cb299455923253b44f3f67647e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:41:15 -0400 Subject: [PATCH 05/31] chore(repo): add empty changeset --- .changeset/mosaic-user-profile-account-wireup.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/mosaic-user-profile-account-wireup.md diff --git a/.changeset/mosaic-user-profile-account-wireup.md b/.changeset/mosaic-user-profile-account-wireup.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-account-wireup.md @@ -0,0 +1,2 @@ +--- +--- From 30a2e4beec61bfed8a78e1fbb6aa2e67af112323 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 17:02:49 -0400 Subject: [PATCH 06/31] refactor(mosaic): make unexpected save errors explicit and drop dead picture error prop --- .../user-profile-edit-name.controller.test.ts | 2 +- ...r-profile-edit-username.controller.test.ts | 2 +- .../user-profile-picture-row.view.test.tsx | 19 +++++++++---------- .../user-profile-picture.controller.test.ts | 2 +- .../user-profile-edit-name.controller.ts | 4 ++-- .../user-profile-edit-username.controller.ts | 4 ++-- .../user-profile-picture-row.view.tsx | 4 +--- .../user-profile-picture.controller.ts | 5 +++-- packages/mosaic/src/utils/save-result.ts | 5 +---- 9 files changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 65c987f2bb4..43c484355ad 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -86,7 +86,7 @@ describe('userProfileEditNameMachine', () => { const actor = start(() => Promise.reject(failure)); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: {} })); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); expect(log).toHaveBeenCalledWith(failure); log.mockRestore(); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 9831410fd64..c602e44dd84 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -85,7 +85,7 @@ describe('userProfileEditUsernameMachine', () => { actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: {} })); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); expect(log).toHaveBeenCalledWith(failure); log.mockRestore(); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx index 9b6e43d131c..9fb25d9d760 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx @@ -18,18 +18,17 @@ function renderView(overrides: Partial = {}) { } 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.'); + it('shows why removing the picture failed', async () => { + const user = userEvent.setup(); + renderView({ + hasImage: true, + onRemove: vi.fn().mockResolvedValue({ error: { kind: 'form', global: { code: 'action_blocked' } } }), + }); - rerender( - - - , - ); + await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(await screen.findByRole('alert')).toHaveTextContent("This action couldn't be completed."); }); it('offers Upload while the avatar is only a generated default', () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index e5cd146e671..bb068591fd0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -83,7 +83,7 @@ describe('useUserProfilePictureController', () => { const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.error).toEqual({}); + expect(result.current.error).toEqual({ code: 'generic' }); expect(log).toHaveBeenCalledWith(failure); log.mockRestore(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index e1f212d6642..4f95b1f36cf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; +import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { @@ -69,7 +69,7 @@ export const userProfileEditNameMachine = createMachine({ ], onError: { target: 'editing', - actions: assign((_, event) => ({ error: unexpectedFormError(event.error) })), + actions: [(_, event) => console.error(event.error), assign(() => ({ error: { global: UNEXPECTED_ERROR } }))], }, }), }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index d30b3c81867..a68664daf8f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; +import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { @@ -64,7 +64,7 @@ export const userProfileEditUsernameMachine = createMachine({ ], onError: { target: 'editing', - actions: assign((_, event) => ({ error: unexpectedFormError(event.error) })), + actions: [(_, event) => console.error(event.error), assign(() => ({ error: { global: UNEXPECTED_ERROR } }))], }, }), }, diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx index 96f9f9610f1..967d5499406 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -19,7 +19,6 @@ export interface UserProfilePictureRowViewProps { name: string; imageUrl?: string; hasImage?: boolean; - errorMessage?: string; onChange?: (file: File) => Promise; onReject?: (rejections: FileRejection[]) => void; onRemove?: () => Promise; @@ -29,7 +28,6 @@ export function UserProfilePictureRowView({ name, imageUrl, hasImage = false, - errorMessage, onChange, onReject, onRemove, @@ -38,7 +36,7 @@ export function UserProfilePictureRowView({ const errorText = useErrorText(); const controller = useUserProfilePictureController({ onChange, onRemove }); const [rejectionError, setRejectionError] = useState(); - const displayedError = errorMessage ?? rejectionError ?? (controller.error ? errorText(controller.error) : undefined); + const displayedError = rejectionError ?? (controller.error ? errorText(controller.error) : undefined); const initials = name .split(/\s+/) .map(part => part[0]) diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts index 8221415ee03..bfcc0775d49 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import type { LocalizableError } from '../../../localization'; import type { SaveResult } from '../../../utils/save-result'; -import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; +import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; export interface UserProfilePictureControllerOptions { onChange?: (file: File) => Promise; @@ -35,7 +35,8 @@ export function useUserProfilePictureController({ const result = await action(); setError(formErrorOf(result.error)?.global); } catch (cause) { - setError(unexpectedFormError(cause).global); + console.error(cause); + setError(UNEXPECTED_ERROR); } finally { inFlight.current = false; setIsPending(false); diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/save-result.ts index a80771b01f4..e4bedf8baed 100644 --- a/packages/mosaic/src/utils/save-result.ts +++ b/packages/mosaic/src/utils/save-result.ts @@ -22,10 +22,7 @@ export function formErrorOf(failure: SaveFailure return error; } -export function unexpectedFormError(cause: unknown): FormError { - console.error(cause); - return { global: {} }; -} +export const UNEXPECTED_ERROR: LocalizableError = { code: 'generic' }; function toFormError(cause: unknown, fields: readonly TField[]): FormError | undefined { if (isClerkRuntimeError(cause)) { From 3f7110b7e146642974c2ca9a575d3509c786fe4b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 07:40:55 -0400 Subject: [PATCH 07/31] refactor(mosaic): drop reverification from the account section --- ...ofile-account-section.integration.test.tsx | 8 ------ ...ser-profile-account-section.model.test.tsx | 26 +++---------------- .../user-profile-edit-name.controller.test.ts | 12 +-------- ...r-profile-edit-username.controller.test.ts | 13 +--------- .../user-profile-picture-row.view.test.tsx | 2 +- .../user-profile-picture.controller.test.ts | 12 ++------- .../user-profile-account-section.model.ts | 8 ++---- .../user-profile-account-section.tsx | 10 ++----- .../user-profile-edit-name.controller.ts | 4 +-- .../user-profile-edit-username.controller.ts | 4 +-- .../user-profile-picture.controller.ts | 4 +-- packages/mosaic/src/utils/save-result.ts | 19 +++----------- .../fixtures/user-profile-edit-name.ts | 2 +- .../fixtures/user-profile-edit-username.ts | 2 +- 14 files changed, 24 insertions(+), 102 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index c4435f2e01d..9799f401658 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -5,7 +5,6 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; -import type * as ReverificationModule from '../../reverification'; import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; let usernameEnabled: boolean; @@ -30,8 +29,6 @@ vi.mock('@clerk/shared/react', async importOriginal => { return { ...actual, useUser: () => ({ isLoaded: true, user }), - useSession: () => ({ session: { id: 'sess_1' } }), - useReverification: (fetcher: unknown) => fetcher, useClerk: () => ({ __internal_environment: { userSettings: { @@ -46,11 +43,6 @@ vi.mock('@clerk/shared/react', async importOriginal => { }; }); -vi.mock('../../reverification', async importOriginal => ({ - ...(await importOriginal()), - Reverification: () => null, -})); - function renderSection() { return render( diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 56327346e6f..019c8bf8a4e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -30,7 +30,6 @@ let user: { } | null; let attributes: Record<'first_name' | 'last_name' | 'username', FakeAttribute>; let environmentHydrated: boolean; -let reverified: ReturnType; function attribute(overrides: Partial = {}): FakeAttribute { return { enabled: true, required: false, used_for_first_factor: false, used_for_second_factor: false, ...overrides }; @@ -41,13 +40,6 @@ vi.mock('@clerk/shared/react', async importOriginal => { return { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), - useSession: () => ({ session: { id: 'sess_1' } }), - useReverification: (fetcher: (...args: unknown[]) => Promise) => { - return (...args: unknown[]) => { - reverified(...args); - return fetcher(...args); - }; - }, useClerk: () => ({ __internal_environment: environmentHydrated ? { userSettings: { attributes } } : null, }), @@ -83,7 +75,6 @@ function apiError(paramName?: string) { beforeEach(() => { isUserLoaded = true; environmentHydrated = true; - reverified = vi.fn(); attributes = { first_name: attribute(), last_name: attribute(), username: attribute() }; user = { firstName: 'Preston', @@ -137,7 +128,6 @@ describe('useUserProfileAccountSectionModel', () => { ], phones: [{ id: 'phone_1', value: '+18018888181', isDefault: false, isVerified: true }], }); - expect(model.reverification).toEqual({ isActive: false }); }); describe('profile picture', () => { @@ -163,7 +153,7 @@ describe('useUserProfileAccountSectionModel', () => { user?.setProfileImage.mockRejectedValue(apiError()); const file = new File(['x'], 'me.png', { type: 'image/png' }); await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ - error: { kind: 'form', global: { code: 'form_param_invalid', message: 'That value is invalid.' } }, + error: { global: { code: 'form_param_invalid', message: 'That value is invalid.' } }, }); }); }); @@ -178,7 +168,6 @@ describe('useUserProfileAccountSectionModel', () => { user?.update.mockRejectedValue(apiError('first_name')); await expect(ready().onSubmitName?.({ firstName: '', lastName: 'B' })).resolves.toEqual({ error: { - kind: 'form', fields: { firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, }, @@ -204,7 +193,7 @@ describe('useUserProfileAccountSectionModel', () => { it('returns a Clerk runtime failure by its code, without the developer message', async () => { user?.update.mockRejectedValue(new ClerkRuntimeError('Network down.', { code: 'network_error' })); await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ - error: { kind: 'form', global: { code: 'network_error' } }, + error: { global: { code: 'network_error' } }, }); }); @@ -214,9 +203,8 @@ describe('useUserProfileAccountSectionModel', () => { }); describe('username', () => { - it('saves the username behind reverification', async () => { - await ready().onSubmitUsername?.('preston'); - expect(reverified).toHaveBeenCalledWith('preston'); + it('saves the username', async () => { + await expect(ready().onSubmitUsername?.('preston')).resolves.toEqual({ error: null }); expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); }); @@ -224,7 +212,6 @@ describe('useUserProfileAccountSectionModel', () => { user?.update.mockRejectedValue(apiError('username')); await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ error: { - kind: 'form', fields: { username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, }, @@ -232,11 +219,6 @@ describe('useUserProfileAccountSectionModel', () => { }); }); - it('reports a cancelled reverification as cancelled, not as a failure', async () => { - user?.update.mockRejectedValue(new ClerkRuntimeError('cancelled', { code: 'reverification_cancelled' })); - await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ error: { kind: 'cancelled' } }); - }); - it('is hidden when the instance does not use usernames', () => { attributes.username = attribute({ enabled: false }); expect(ready().username).toBeUndefined(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 43c484355ad..96ef9d9523e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -12,9 +12,7 @@ import type { UserProfileEditNameField } from '../user-profile-account-section/u type Result = SaveResult; const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: FormError): Promise => - Promise.resolve({ error: { kind: 'form', ...error } }); -const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); +const failed = (error: FormError): Promise => Promise.resolve({ error }); function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { const actor = createActor(userProfileEditNameMachine, { context: { saveName, ...saved } }).start(); @@ -91,14 +89,6 @@ describe('userProfileEditNameMachine', () => { log.mockRestore(); }); - it('stays open without a banner when the save is cancelled', async () => { - const actor = start(cancelled); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); - expect(actor.getSnapshot().context.error).toBeUndefined(); - }); - it('drops the error when the dialog is cancelled', async () => { const actor = start(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'SAVE' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index c602e44dd84..2106956cf68 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -12,9 +12,7 @@ import type { UserProfileEditUsernameField } from '../user-profile-account-secti type Result = SaveResult; const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: FormError): Promise => - Promise.resolve({ error: { kind: 'form', ...error } }); -const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); +const failed = (error: FormError): Promise => Promise.resolve({ error }); function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { const actor = createActor(userProfileEditUsernameMachine, { context: { saveUsername, savedUsername } }).start(); @@ -112,15 +110,6 @@ describe('userProfileEditUsernameMachine', () => { expect(saveUsername).not.toHaveBeenCalled(); }); - it('stays open without a banner when the save is cancelled', async () => { - const actor = start(cancelled); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); - expect(actor.getSnapshot().context.error).toBeUndefined(); - }); - it('drops the error when the dialog is cancelled', async () => { const actor = start(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'TYPE', value: 'preston' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx index 9fb25d9d760..fbef0a3f8d7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx @@ -22,7 +22,7 @@ describe('UserProfilePictureRowView', () => { const user = userEvent.setup(); renderView({ hasImage: true, - onRemove: vi.fn().mockResolvedValue({ error: { kind: 'form', global: { code: 'action_blocked' } } }), + onRemove: vi.fn().mockResolvedValue({ error: { global: { code: 'action_blocked' } } }), }); await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index bb068591fd0..366a10a56dd 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -55,7 +55,7 @@ describe('useUserProfilePictureController', () => { const onChange = vi .fn() .mockResolvedValueOnce({ - error: { kind: 'form', global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } }, + error: { global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } }, }) .mockResolvedValueOnce({ error: null }); const { result } = renderController({ onChange }); @@ -68,14 +68,6 @@ describe('useUserProfilePictureController', () => { expect(result.current.error).toBeUndefined(); }); - it('shows nothing when the action is cancelled', async () => { - const onChange = vi.fn().mockResolvedValue({ error: { kind: 'cancelled' } }); - const { result } = renderController({ onChange }); - - await act(async () => result.current.onChange?.(file)); - expect(result.current.error).toBeUndefined(); - }); - it('shows the generic error and logs an unexpected throw', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}); const failure = new TypeError('boom'); @@ -89,7 +81,7 @@ describe('useUserProfilePictureController', () => { }); it('reports a failed removal the same way', async () => { - const onRemove = vi.fn().mockResolvedValue({ error: { kind: 'form', global: { message: 'Nope.' } } }); + const onRemove = vi.fn().mockResolvedValue({ error: { global: { message: 'Nope.' } } }); const { result } = renderController({ onRemove }); await act(async () => result.current.onRemove?.()); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index 96bef0b8866..9f15ec482db 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -4,8 +4,6 @@ import type { AttributeData, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import { toSaveResult } from '../../../utils/save-result'; -import type { ReverificationProps } from '../../reverification'; -import { useReverificationWithState } from '../../reverification'; import type { UserProfileEmail, UserProfileNameAttribute, @@ -37,7 +35,7 @@ type UserProfileAccountSectionData = Pick< export type UserProfileAccountSectionModel = | { status: 'loading' } | { status: 'hidden' } - | (UserProfileAccountSectionData & { status: 'ready'; reverification: ReverificationProps }); + | (UserProfileAccountSectionData & { status: 'ready' }); const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; @@ -71,7 +69,6 @@ function toPhones(user: UserResource): UserProfilePhone[] { export function useUserProfileAccountSectionModel(): UserProfileAccountSectionModel { const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); - const [updateUsername, reverification] = useReverificationWithState((username: string) => user?.update({ username })); if (!isLoaded || !environment) { return { status: 'loading' }; @@ -89,7 +86,6 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'ready', - reverification, name: getFullName(user), firstName: user.firstName ?? '', lastName: user.lastName ?? '', @@ -107,7 +103,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo : value => toSaveResult(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), onSubmitUsername: showUsername && !usernameImmutable - ? username => toSaveResult(() => updateUsername(username), USERNAME_FIELDS) + ? username => toSaveResult(() => user.update({ username }), USERNAME_FIELDS) : undefined, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx index 27e9d37fcd9..4b902832b22 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx @@ -1,4 +1,3 @@ -import { Reverification } from '../../reverification'; import { useUserProfileAccountSectionModel } from './user-profile-account-section.model'; import { UserProfileAccountSectionView } from './user-profile-account-section.view'; @@ -9,12 +8,7 @@ export function UserProfileAccountSection() { return null; } - const { status: _status, reverification, ...viewProps } = model; + const { status: _status, ...viewProps } = model; - return ( - <> - - - - ); + return ; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index 4f95b1f36cf..4c15962b310 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; +import { UNEXPECTED_ERROR } from '../../../utils/save-result'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { @@ -63,7 +63,7 @@ export const userProfileEditNameMachine = createMachine({ { guard: (_, event) => event.output.error !== null, target: 'editing', - actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), + actions: assign((_, event) => ({ error: event.output.error ?? undefined })), }, { target: 'idle', actions: assign(() => ({ error: undefined })) }, ], diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index a68664daf8f..893fc935b96 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; +import { UNEXPECTED_ERROR } from '../../../utils/save-result'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { @@ -58,7 +58,7 @@ export const userProfileEditUsernameMachine = createMachine({ { guard: (_, event) => event.output.error !== null, target: 'editing', - actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), + actions: assign((_, event) => ({ error: event.output.error ?? undefined })), }, { target: 'idle', actions: assign(() => ({ error: undefined })) }, ], diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts index bfcc0775d49..074adf1713e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import type { LocalizableError } from '../../../localization'; import type { SaveResult } from '../../../utils/save-result'; -import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; +import { UNEXPECTED_ERROR } from '../../../utils/save-result'; export interface UserProfilePictureControllerOptions { onChange?: (file: File) => Promise; @@ -33,7 +33,7 @@ export function useUserProfilePictureController({ setError(undefined); try { const result = await action(); - setError(formErrorOf(result.error)?.global); + setError(result.error?.global); } catch (cause) { console.error(cause); setError(UNEXPECTED_ERROR); diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/save-result.ts index e4bedf8baed..afd19950908 100644 --- a/packages/mosaic/src/utils/save-result.ts +++ b/packages/mosaic/src/utils/save-result.ts @@ -1,4 +1,4 @@ -import { isClerkAPIResponseError, isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; +import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; import { snakeToCamel } from '@clerk/shared/underscore'; import type { LocalizableError } from '../localization'; @@ -8,18 +8,8 @@ export interface FormError { fields?: Partial>; } -export type SaveFailure = { kind: 'cancelled' } | ({ kind: 'form' } & FormError); - export interface SaveResult { - error: SaveFailure | null; -} - -export function formErrorOf(failure: SaveFailure | null): FormError | undefined { - if (failure?.kind !== 'form') { - return undefined; - } - const { kind: _kind, ...error } = failure; - return error; + error: FormError | null; } export const UNEXPECTED_ERROR: LocalizableError = { code: 'generic' }; @@ -57,13 +47,10 @@ export async function toSaveResult( await run(); return { error: null }; } catch (cause) { - if (isReverificationCancelledError(cause)) { - return { error: { kind: 'cancelled' } }; - } const error = toFormError(cause, fields); if (!error) { throw cause; } - return { error: { kind: 'form', ...error } }; + return { error }; } } 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 67ef43013ae..8cff25098ce 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -32,7 +32,7 @@ export function useUserProfileEditNameFixture({ onSubmitName: async (value: UserProfileEditNameValue) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - return { error: { kind: 'form' as const, ...failWith } }; + return { error: failWith }; } setName(value); return { error: null }; 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 73d049889d5..e639a441204 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -23,7 +23,7 @@ export function useUserProfileEditUsernameFixture({ onSubmitUsername: async (value: string) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - return { error: { kind: 'form' as const, ...failWith } }; + return { error: failWith }; } setUsername(value); return { error: null }; From 6600231831f578e1f6828d2622a951597608e75f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 07:53:15 -0400 Subject: [PATCH 08/31] fix(mosaic): clear a rejected picture pick when removing --- .../user-profile-picture-row.view.test.tsx | 26 ++++++++++++++++++- .../user-profile-picture-row.view.tsx | 9 ++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx index fbef0a3f8d7..88e7d48c8c4 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -31,6 +31,30 @@ describe('UserProfilePictureRowView', () => { expect(await screen.findByRole('alert')).toHaveTextContent("This action couldn't be completed."); }); + it('replaces a rejected pick with the result of a removal', async () => { + const user = userEvent.setup(); + const { container } = renderView({ + hasImage: true, + onChange: vi.fn(), + onRemove: vi.fn().mockResolvedValue({ error: { global: { code: 'action_blocked' } } }), + }); + const input = container.querySelector('input[type="file"]'); + if (!(input instanceof HTMLInputElement)) { + throw new Error('expected a file input'); + } + + const tooBig = new File(['x'], 'big.png', { type: 'image/png' }); + Object.defineProperty(tooBig, 'size', { value: 11 * 1024 * 1024 }); + await user.upload(input, tooBig); + const rejection = screen.getByRole('alert').textContent; + + await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); + + await waitFor(() => expect(screen.getByRole('alert')).not.toHaveTextContent(rejection ?? '')); + expect(screen.getByRole('alert')).toHaveTextContent("This action couldn't be completed."); + }); + it('offers Upload while the avatar is only a generated default', () => { renderView({ hasImage: false, diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx index 967d5499406..88c888e7916 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -37,6 +37,13 @@ export function UserProfilePictureRowView({ const controller = useUserProfilePictureController({ onChange, onRemove }); const [rejectionError, setRejectionError] = useState(); const displayedError = rejectionError ?? (controller.error ? errorText(controller.error) : undefined); + const remove = controller.onRemove; + const handleRemove = remove + ? () => { + setRejectionError(undefined); + return remove(); + } + : undefined; const initials = name .split(/\s+/) .map(part => part[0]) @@ -81,7 +88,7 @@ export function UserProfilePictureRowView({ {displayedError} From c3fd5a1fb8e9cde175a6e35663e6bf4410248a1e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:03:12 -0400 Subject: [PATCH 09/31] fix(mosaic): offer to set a username the user does not have --- .../user-profile-username-row.view.test.tsx | 24 +++++++++++++++++++ .../user-profile-account-section.messages.ts | 2 ++ .../user-profile-edit-username.dialog.tsx | 4 +++- .../user-profile-username-row.view.tsx | 6 +++-- 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx new file mode 100644 index 00000000000..30c1fb16301 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx @@ -0,0 +1,24 @@ +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 { UserProfileUsernameRowView } from '../user-profile-account-section/user-profile-username-row.view'; + +describe('UserProfileUsernameRowView', () => { + it('offers to set a username the user does not have yet', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + expect(document.querySelector('.cl-section-description')).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Set username' })); + expect(screen.getByRole('dialog', { name: 'Set username' })).toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 985d6683d9a..5d0d444ac79 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -32,8 +32,10 @@ export const userProfileAccountSectionMessages = { username: { label: 'Username', edit: 'Edit username', + set: 'Set username', dialogTitle: 'Edit username', + setDialogTitle: 'Set username', fieldLabel: 'Username', cancel: 'Cancel', save: 'Save changes', diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx index 335559accbf..b00c06aa406 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx @@ -19,6 +19,7 @@ export interface UserProfileEditUsernameDialogProps { trigger?: DialogTriggerProps['render']; username: string; onUsernameChange: (value: string) => void; + title?: string; canSave?: boolean; isSaving?: boolean; error?: FormError; @@ -31,6 +32,7 @@ export function UserProfileEditUsernameDialog({ trigger, username, onUsernameChange, + title, canSave = true, isSaving = false, error, @@ -63,7 +65,7 @@ export function UserProfileEditUsernameDialog({ renderBranding={false} > - {m.username.dialogTitle} + {title ?? m.username.dialogTitle} {m.username.label} - {username} + {username ? {username} : null} {onSubmit ? ( @@ -42,18 +42,20 @@ function EditUsername({ }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); + const isSet = Boolean(username); return ( - {m.username.edit} + {isSet ? m.username.edit : m.username.set} } /> From 467fe635919135d5dc566c06037f77fd77749e63 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:07:34 -0400 Subject: [PATCH 10/31] fix(mosaic): hide contacts the instance does not collect --- ...ser-profile-account-section.model.test.tsx | 19 +++++++++++++++++-- .../user-profile-account-section.model.ts | 6 ++++-- .../user-profile-account-section.view.tsx | 17 +++++++++-------- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 019c8bf8a4e..601c434f885 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -28,7 +28,7 @@ let user: { setProfileImage: ReturnType; update: ReturnType; } | null; -let attributes: Record<'first_name' | 'last_name' | 'username', FakeAttribute>; +let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>; let environmentHydrated: boolean; function attribute(overrides: Partial = {}): FakeAttribute { @@ -75,7 +75,13 @@ function apiError(paramName?: string) { beforeEach(() => { isUserLoaded = true; environmentHydrated = true; - attributes = { first_name: attribute(), last_name: attribute(), username: attribute() }; + attributes = { + first_name: attribute(), + last_name: attribute(), + username: attribute(), + email_address: attribute(), + phone_number: attribute(), + }; user = { firstName: 'Preston', lastName: 'Booth', @@ -130,6 +136,15 @@ describe('useUserProfileAccountSectionModel', () => { }); }); + it('leaves out the contacts the instance does not collect', () => { + attributes.email_address = attribute({ enabled: false }); + attributes.phone_number = attribute({ enabled: false }); + const model = ready(); + + expect(model.emails).toBeUndefined(); + expect(model.phones).toBeUndefined(); + }); + describe('profile picture', () => { it('uploads the picked file', async () => { const file = new File(['x'], 'me.png', { type: 'image/png' }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index 9f15ec482db..0934fc2987e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -83,6 +83,8 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo const usernameImmutable = Boolean(usernameAttribute?.immutable); const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); const nameReadOnly = user.enterpriseAccounts.some(account => account.active); + const showEmails = isAttributeAvailable(attributes.email_address); + const showPhones = isAttributeAvailable(attributes.phone_number); return { status: 'ready', @@ -94,8 +96,8 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo imageUrl: user.imageUrl, hasImage: user.hasImage, username: showUsername ? (user.username ?? '') : undefined, - emails: toEmails(user), - phones: toPhones(user), + emails: showEmails ? toEmails(user) : undefined, + phones: showPhones ? toPhones(user) : undefined, onProfilePictureChange: file => toSaveResult(() => user.setProfileImage({ file })), onRemoveProfilePicture: user.hasImage ? () => toSaveResult(() => user.setProfileImage({ file: null })) : undefined, onSubmitName: nameReadOnly diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 1fbe41cec7d..75086ae876c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -36,8 +36,9 @@ export interface UserProfileAccountSectionViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - emails: UserProfileEmail[]; - phones: UserProfilePhone[]; + /** Left out when the instance does not collect the attribute, which drops the row. */ + emails?: UserProfileEmail[]; + phones?: UserProfilePhone[]; onProfilePictureChange?: (file: File) => Promise; onProfilePictureReject?: (rejections: FileRejection[]) => void; onRemoveProfilePicture?: () => Promise; @@ -91,7 +92,7 @@ export function UserProfileAccountSectionView({ }: UserProfileAccountSectionViewProps) { const m = useMessages('userProfileAccountSection'); const showName = firstNameAttribute?.enabled !== false || lastNameAttribute?.enabled !== false; - const phoneRow = ( + const phoneRow = phones ? ( - ); - const emailRow = ( + ) : null; + const emailRow = emails ? ( - ); + ) : null; return (
@@ -150,12 +151,12 @@ export function UserProfileAccountSectionView({ {!allowMultipleAccounts ? phoneRow : null} - {allowMultipleAccounts ? ( + {allowMultipleAccounts && emailRow ? ( {emailRow} ) : null} - {allowMultipleAccounts ? ( + {allowMultipleAccounts && phoneRow ? ( {phoneRow} From 3b0d8c7645191001ad3c4cb2c3e5c3ed4ce07fb9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:19:18 -0400 Subject: [PATCH 11/31] fix(mosaic): always separate the account section's contact cards --- .../user-profile-account-section.model.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index 0934fc2987e..acb9f6e6cdd 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -16,6 +16,7 @@ import type { UserProfileEditUsernameField } from './user-profile-edit-username. type UserProfileAccountSectionData = Pick< UserProfileAccountSectionViewProps, + | 'allowMultipleAccounts' | 'name' | 'imageUrl' | 'hasImage' @@ -88,6 +89,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'ready', + allowMultipleAccounts: true, name: getFullName(user), firstName: user.firstName ?? '', lastName: user.lastName ?? '', From 43c425c47b53d342d427d70835c383ae523ad70e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:30:51 -0400 Subject: [PATCH 12/31] fix(mosaic): drop the name row's description when there is no name --- .../user-profile-name-row.view.test.tsx | 20 +++++++++++++++++++ .../user-profile-name-row.view.tsx | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx new file mode 100644 index 00000000000..70aa39e3730 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx @@ -0,0 +1,20 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileNameRowView } from '../user-profile-account-section/user-profile-name-row.view'; + +describe('UserProfileNameRowView', () => { + it('leaves the description out when the user has no name', () => { + render( + + + , + ); + + expect(document.querySelector('.cl-section-description')).toBeNull(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx index 1b7b9cf44b1..60c215534d6 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -30,7 +30,7 @@ export function UserProfileNameRowView({ {m.name.label} - {name} + {name ? {name} : null} {onSubmit ? ( From bb77fa5bfefae2de25791eeb8256e5884008381a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:32:37 -0400 Subject: [PATCH 13/31] fix(mosaic): offer to set a name the user does not have --- .../__tests__/user-profile-name-row.view.test.tsx | 8 ++++++-- .../user-profile-account-section.messages.ts | 2 ++ .../user-profile-edit-name.dialog.tsx | 4 +++- .../user-profile-name-row.view.tsx | 6 +++++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx index 70aa39e3730..946a67215ba 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx @@ -1,11 +1,13 @@ -import { render } from '@testing-library/react'; +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 { UserProfileNameRowView } from '../user-profile-account-section/user-profile-name-row.view'; describe('UserProfileNameRowView', () => { - it('leaves the description out when the user has no name', () => { + it('offers to set a name the user does not have yet', async () => { + const user = userEvent.setup(); render( { ); expect(document.querySelector('.cl-section-description')).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Set name' })); + expect(screen.getByRole('dialog', { name: 'Set name' })).toBeInTheDocument(); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 5d0d444ac79..575b6bf9e40 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -22,8 +22,10 @@ export const userProfileAccountSectionMessages = { name: { label: 'Name', edit: 'Edit name', + set: 'Set name', dialogTitle: 'Edit name', + setDialogTitle: 'Set name', firstNameLabel: 'First name', lastNameLabel: 'Last name', cancel: 'Cancel', diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index c9571d88231..05ba7b04bd5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -31,6 +31,7 @@ export interface UserProfileEditNameDialogProps { lastNameAttribute?: UserProfileNameAttribute; onFirstNameChange: (value: string) => void; onLastNameChange: (value: string) => void; + title?: string; isSaving?: boolean; error?: FormError; onSubmit: () => void; @@ -51,6 +52,7 @@ export function UserProfileEditNameDialog({ lastNameAttribute = {}, onFirstNameChange, onLastNameChange, + title, isSaving = false, error, onSubmit, @@ -85,7 +87,7 @@ export function UserProfileEditNameDialog({ renderBranding={false} > - {m.name.dialogTitle} + {title ?? m.name.dialogTitle} - {m.name.edit} + {isSet ? m.name.edit : m.name.set} } /> From e51c8fb7b860e66547ee2c15b7299b6a147dcf3b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 14:01:27 -0400 Subject: [PATCH 14/31] refactor(mosaic): carry save failures on one rejection A save either resolves or rejects with a `SaveError` holding the structured, localizable payload. Drops the parallel `SaveResult` return channel and the password section's string bridge, which flattened error codes to messages and so could never reach the message catalog. --- packages/mosaic/.vitest/json/output.json | 39 ++++++++++++++++ ...ser-profile-account-section.model.test.tsx | 41 ++++++++++------- .../user-profile-edit-name.controller.test.ts | 11 +++-- ...r-profile-edit-username.controller.test.ts | 11 +++-- ...ser-profile-password-section.view.test.tsx | 7 +-- .../user-profile-picture-row.view.test.tsx | 5 ++- .../user-profile-picture.controller.test.ts | 14 +++--- .../user-profile-account-section.model.ts | 10 ++--- .../user-profile-account-section.view.tsx | 12 +++-- .../user-profile-edit-name.controller.ts | 21 +++------ .../user-profile-edit-name.dialog.tsx | 2 +- .../user-profile-edit-username.controller.ts | 19 +++----- .../user-profile-edit-username.dialog.tsx | 2 +- .../user-profile-name-row.view.tsx | 7 ++- .../user-profile-picture-row.view.tsx | 5 +-- .../user-profile-picture.controller.ts | 15 +++---- .../user-profile-username-row.view.tsx | 12 +---- .../utils/{save-result.ts => form-error.ts} | 44 ++++++++++++++----- 18 files changed, 157 insertions(+), 120 deletions(-) create mode 100644 packages/mosaic/.vitest/json/output.json rename packages/mosaic/src/utils/{save-result.ts => form-error.ts} (52%) diff --git a/packages/mosaic/.vitest/json/output.json b/packages/mosaic/.vitest/json/output.json new file mode 100644 index 00000000000..2bbcde4d5c3 --- /dev/null +++ b/packages/mosaic/.vitest/json/output.json @@ -0,0 +1,39 @@ +{ + "numTotalTestSuites": 1, + "numPassedTestSuites": 0, + "numFailedTestSuites": 1, + "numPendingTestSuites": 0, + "numTotalTests": 0, + "numPassedTests": 0, + "numFailedTests": 0, + "numPendingTests": 0, + "numTodoTests": 0, + "snapshot": { + "added": 0, + "failure": false, + "filesAdded": 0, + "filesRemoved": 0, + "filesRemovedList": [], + "filesUnmatched": 0, + "filesUpdated": 0, + "matched": 0, + "total": 0, + "unchecked": 0, + "uncheckedKeysByFile": [], + "unmatched": 0, + "updated": 0, + "didUpdate": false + }, + "startTime": 1790015468853, + "success": false, + "testResults": [ + { + "assertionResults": [], + "startTime": 1790015468853, + "endTime": 1790015468853, + "status": "failed", + "message": "Failed to resolve import \"../user-profile-account-section/user-profile-account-section.model\" from \"src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx\". Does the file exist?", + "name": "/Users/alex/Repos/javascript.carp-mosaic-user-profile-avatar-wireup/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx" + } + ] +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 601c434f885..d9623189b3d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -3,6 +3,8 @@ import type * as SharedReact from '@clerk/shared/react'; import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FormError } from '../../../utils/form-error'; +import { SaveError } from '../../../utils/form-error'; import { useUserProfileAccountSectionModel } from '../user-profile-account-section/user-profile-account-section.model'; interface FakeAttribute { @@ -50,6 +52,15 @@ function renderModel() { return renderHook(() => useUserProfileAccountSectionModel()).result.current; } +async function rejection(save: Promise | undefined): Promise { + try { + await save; + return undefined; + } catch (cause) { + return cause instanceof SaveError ? cause.formError : undefined; + } +} + function ready() { const model = renderModel(); if (model.status !== 'ready') { @@ -148,7 +159,7 @@ describe('useUserProfileAccountSectionModel', () => { describe('profile picture', () => { it('uploads the picked file', async () => { const file = new File(['x'], 'me.png', { type: 'image/png' }); - await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ error: null }); + await ready().onProfilePictureChange?.(file); expect(user?.setProfileImage).toHaveBeenCalledWith({ file }); }); @@ -167,25 +178,23 @@ describe('useUserProfileAccountSectionModel', () => { it('returns the API message when the upload fails', async () => { user?.setProfileImage.mockRejectedValue(apiError()); const file = new File(['x'], 'me.png', { type: 'image/png' }); - await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ - error: { global: { code: 'form_param_invalid', message: 'That value is invalid.' } }, + await expect(rejection(ready().onProfilePictureChange?.(file))).resolves.toEqual({ + global: { code: 'form_param_invalid', message: 'That value is invalid.' }, }); }); }); describe('name', () => { it('saves both halves of the name', async () => { - await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ error: null }); + await ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' }); expect(user?.update).toHaveBeenCalledWith({ firstName: 'Pres', lastName: 'B' }); }); it('maps a field error onto the control that caused it', async () => { user?.update.mockRejectedValue(apiError('first_name')); - await expect(ready().onSubmitName?.({ firstName: '', lastName: 'B' })).resolves.toEqual({ - error: { - fields: { - firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, - }, + await expect(rejection(ready().onSubmitName?.({ firstName: '', lastName: 'B' }))).resolves.toEqual({ + fields: { + firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, }, }); }); @@ -207,8 +216,8 @@ describe('useUserProfileAccountSectionModel', () => { it('returns a Clerk runtime failure by its code, without the developer message', async () => { user?.update.mockRejectedValue(new ClerkRuntimeError('Network down.', { code: 'network_error' })); - await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ - error: { global: { code: 'network_error' } }, + await expect(rejection(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' }))).resolves.toEqual({ + global: { code: 'network_error' }, }); }); @@ -219,17 +228,15 @@ describe('useUserProfileAccountSectionModel', () => { describe('username', () => { it('saves the username', async () => { - await expect(ready().onSubmitUsername?.('preston')).resolves.toEqual({ error: null }); + await ready().onSubmitUsername?.('preston'); expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); }); it('maps a username error onto the field', async () => { user?.update.mockRejectedValue(apiError('username')); - await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ - error: { - fields: { - username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, - }, + await expect(rejection(ready().onSubmitUsername?.('x'))).resolves.toEqual({ + fields: { + username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, }, }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 96ef9d9523e..c2ad1a81714 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -2,19 +2,18 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import type { FormError, SaveResult } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; +import { SaveError } from '../../../utils/form-error'; import { userProfileEditNameMachine, useUserProfileEditNameController, } from '../user-profile-account-section/user-profile-edit-name.controller'; import type { UserProfileEditNameField } from '../user-profile-account-section/user-profile-edit-name.dialog'; -type Result = SaveResult; +const saved = (): Promise => Promise.resolve(); +const failed = (error: FormError): Promise => Promise.reject(new SaveError(error)); -const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: FormError): Promise => Promise.resolve({ error }); - -function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { +function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { const actor = createActor(userProfileEditNameMachine, { context: { saveName, ...saved } }).start(); actor.send({ type: 'OPEN' }); return actor; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 2106956cf68..bd9d03dd7ad 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -2,19 +2,18 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import type { FormError, SaveResult } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; +import { SaveError } from '../../../utils/form-error'; import { userProfileEditUsernameMachine, useUserProfileEditUsernameController, } from '../user-profile-account-section/user-profile-edit-username.controller'; import type { UserProfileEditUsernameField } from '../user-profile-account-section/user-profile-edit-username.dialog'; -type Result = SaveResult; +const saved = (): Promise => Promise.resolve(); +const failed = (error: FormError): Promise => Promise.reject(new SaveError(error)); -const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: FormError): Promise => Promise.resolve({ error }); - -function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { +function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { const actor = createActor(userProfileEditUsernameMachine, { context: { saveUsername, savedUsername } }).start(); actor.send({ type: 'OPEN' }); return actor; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx index 3abe4b71818..7754247812a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; +import { SaveError } from '../../../utils/form-error'; import type { UserProfilePasswordSectionViewProps } from '../user-profile-password-section/user-profile-password-section.types'; import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; @@ -66,8 +66,9 @@ describe('UserProfilePasswordSectionView', () => { const onSubmitPassword = vi .fn() .mockRejectedValueOnce( - new UserProfileSaveError('Your password could not be updated.', { - currentPassword: 'Incorrect password.', + new SaveError({ + global: { message: 'Your password could not be updated.' }, + fields: { currentPassword: { message: 'Incorrect password.' } }, }), ) .mockResolvedValue(undefined); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx index 88e7d48c8c4..f71b5153422 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; +import { SaveError } from '../../../utils/form-error'; 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'; @@ -22,7 +23,7 @@ describe('UserProfilePictureRowView', () => { const user = userEvent.setup(); renderView({ hasImage: true, - onRemove: vi.fn().mockResolvedValue({ error: { global: { code: 'action_blocked' } } }), + onRemove: vi.fn().mockRejectedValue(new SaveError({ global: { code: 'action_blocked' } })), }); await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); @@ -36,7 +37,7 @@ describe('UserProfilePictureRowView', () => { const { container } = renderView({ hasImage: true, onChange: vi.fn(), - onRemove: vi.fn().mockResolvedValue({ error: { global: { code: 'action_blocked' } } }), + onRemove: vi.fn().mockRejectedValue(new SaveError({ global: { code: 'action_blocked' } })), }); const input = container.querySelector('input[type="file"]'); if (!(input instanceof HTMLInputElement)) { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index 366a10a56dd..a92f99ed67b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -2,7 +2,7 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { SaveResult } from '../../../utils/save-result'; +import { SaveError } from '../../../utils/form-error'; import type { UserProfilePictureControllerOptions } from '../user-profile-account-section/user-profile-picture.controller'; import { useUserProfilePictureController } from '../user-profile-account-section/user-profile-picture.controller'; @@ -21,7 +21,7 @@ describe('useUserProfilePictureController', () => { it('is pending while the upload runs', async () => { const upload = createDeferredPromise(); - const onChange = vi.fn(() => upload.promise.then(() => ({ error: null }))); + const onChange = vi.fn(() => upload.promise.then(() => undefined)); const { result } = renderController({ onChange }); act(() => { @@ -39,7 +39,7 @@ describe('useUserProfilePictureController', () => { }); it('ignores a second pick while one is in flight', () => { - const onChange = vi.fn(() => new Promise(() => {})); + const onChange = vi.fn(() => new Promise(() => {})); const { result } = renderController({ onChange }); act(() => { @@ -54,10 +54,8 @@ describe('useUserProfilePictureController', () => { it('shows why the upload failed, and clears it on the next attempt', async () => { const onChange = vi .fn() - .mockResolvedValueOnce({ - error: { global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } }, - }) - .mockResolvedValueOnce({ error: null }); + .mockRejectedValueOnce(new SaveError({ global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } })) + .mockResolvedValueOnce(undefined); const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); @@ -81,7 +79,7 @@ describe('useUserProfilePictureController', () => { }); it('reports a failed removal the same way', async () => { - const onRemove = vi.fn().mockResolvedValue({ error: { global: { message: 'Nope.' } } }); + const onRemove = vi.fn().mockRejectedValue(new SaveError({ global: { message: 'Nope.' } })); const { result } = renderController({ onRemove }); await act(async () => result.current.onRemove?.()); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index acb9f6e6cdd..d06f4fb9ddf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -3,7 +3,7 @@ import { useUser } from '@clerk/shared/react'; import type { AttributeData, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; -import { toSaveResult } from '../../../utils/save-result'; +import { save } from '../../../utils/form-error'; import type { UserProfileEmail, UserProfileNameAttribute, @@ -100,14 +100,14 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo username: showUsername ? (user.username ?? '') : undefined, emails: showEmails ? toEmails(user) : undefined, phones: showPhones ? toPhones(user) : undefined, - onProfilePictureChange: file => toSaveResult(() => user.setProfileImage({ file })), - onRemoveProfilePicture: user.hasImage ? () => toSaveResult(() => user.setProfileImage({ file: null })) : undefined, + onProfilePictureChange: file => save(() => user.setProfileImage({ file })), + onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, onSubmitName: nameReadOnly ? undefined - : value => toSaveResult(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), + : value => save(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), onSubmitUsername: showUsername && !usernameImmutable - ? username => toSaveResult(() => user.update({ username }), USERNAME_FIELDS) + ? username => save(() => user.update({ username }), USERNAME_FIELDS) : undefined, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 75086ae876c..1de473c3f6f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -3,15 +3,13 @@ import * as stylex from '@stylexjs/stylex'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; -import type { SaveResult } from '../../../utils/save-result'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; -import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileEmailRowView } from './user-profile-email-row.view'; import { UserProfileNameRowView } from './user-profile-name-row.view'; import { UserProfilePhoneRowView } from './user-profile-phone-row.view'; @@ -39,11 +37,11 @@ export interface UserProfileAccountSectionViewProps { /** Left out when the instance does not collect the attribute, which drops the row. */ emails?: UserProfileEmail[]; phones?: UserProfilePhone[]; - onProfilePictureChange?: (file: File) => Promise; + onProfilePictureChange?: (file: File) => Promise; onProfilePictureReject?: (rejections: FileRejection[]) => void; - onRemoveProfilePicture?: () => Promise; - onSubmitName?: (value: UserProfileEditNameValue) => Promise>; - onSubmitUsername?: (username: string) => Promise>; + onRemoveProfilePicture?: () => Promise; + onSubmitName?: (value: UserProfileEditNameValue) => Promise; + onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; onSendEmailCode?: (emailAddress: string) => Promise; onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index 4c15962b310..5ee4aa2610a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -1,11 +1,11 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { FormError, SaveResult } from '../../../utils/save-result'; -import { UNEXPECTED_ERROR } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; +import { toFormError } from '../../../utils/form-error'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { - saveName: (value: UserProfileEditNameValue) => Promise>; + saveName: (value: UserProfileEditNameValue) => Promise; /** Injected every render. What `OPEN` seeds the fields from. */ savedFirstName: string; savedLastName: string; @@ -59,17 +59,10 @@ export const userProfileEditNameMachine = createMachine({ }, saving: { invoke: fromPromise(context => context.saveName({ firstName: context.firstName, lastName: context.lastName }), { - onDone: [ - { - guard: (_, event) => event.output.error !== null, - target: 'editing', - actions: assign((_, event) => ({ error: event.output.error ?? undefined })), - }, - { target: 'idle', actions: assign(() => ({ error: undefined })) }, - ], + onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, onError: { target: 'editing', - actions: [(_, event) => console.error(event.error), assign(() => ({ error: { global: UNEXPECTED_ERROR } }))], + actions: assign((_, event) => ({ error: toFormError(event.error) })), }, }), }, @@ -79,8 +72,8 @@ export const userProfileEditNameMachine = createMachine({ export interface UserProfileEditNameControllerOptions { firstName?: string; lastName?: string; - /** Resolve with `{ error: null }` to close the dialog, or with an error to keep it open showing why. */ - onSubmit: (value: UserProfileEditNameValue) => Promise>; + /** Resolve to close the dialog, or reject with a `SaveError` to keep it open showing why. */ + onSubmit: (value: UserProfileEditNameValue) => Promise; } export interface UserProfileEditNameController { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index 05ba7b04bd5..7d2b6281c31 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -9,7 +9,7 @@ import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; import { useErrorText, useMessages } from '../../../localization'; -import type { FormError } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index 893fc935b96..e4dbe813c22 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -1,11 +1,11 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { FormError, SaveResult } from '../../../utils/save-result'; -import { UNEXPECTED_ERROR } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; +import { toFormError } from '../../../utils/form-error'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { - saveUsername: (username: string) => Promise>; + saveUsername: (username: string) => Promise; savedUsername: string; username: string; error: FormError | undefined; @@ -54,17 +54,10 @@ export const userProfileEditUsernameMachine = createMachine({ }, saving: { invoke: fromPromise(context => context.saveUsername(context.username), { - onDone: [ - { - guard: (_, event) => event.output.error !== null, - target: 'editing', - actions: assign((_, event) => ({ error: event.output.error ?? undefined })), - }, - { target: 'idle', actions: assign(() => ({ error: undefined })) }, - ], + onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, onError: { target: 'editing', - actions: [(_, event) => console.error(event.error), assign(() => ({ error: { global: UNEXPECTED_ERROR } }))], + actions: assign((_, event) => ({ error: toFormError(event.error) })), }, }), }, @@ -73,7 +66,7 @@ export const userProfileEditUsernameMachine = createMachine({ export interface UserProfileEditUsernameControllerOptions { username?: string; - onSubmit: (username: string) => Promise>; + onSubmit: (username: string) => Promise; } export interface UserProfileEditUsernameController { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx index b00c06aa406..b57855a91a5 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx @@ -9,7 +9,7 @@ import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; import { useErrorText, useMessages } from '../../../localization'; -import type { FormError } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; export type UserProfileEditUsernameField = 'username'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx index de36323a3d8..42a90278442 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -1,10 +1,9 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { SaveResult } from '../../../utils/save-result'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; -import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; export interface UserProfileNameRowViewProps { @@ -13,7 +12,7 @@ export interface UserProfileNameRowViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit?: (value: UserProfileEditNameValue) => Promise>; + onSubmit?: (value: UserProfileEditNameValue) => Promise; } export function UserProfileNameRowView({ @@ -62,7 +61,7 @@ function EditName({ lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit: (value: UserProfileEditNameValue) => Promise>; + onSubmit: (value: UserProfileEditNameValue) => Promise; }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx index 88c888e7916..eb48a29b2ac 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -8,7 +8,6 @@ import { Section } from '../../../components/section'; import { useErrorText, useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; -import type { SaveResult } from '../../../utils/save-result'; import { useUserProfilePictureController } from './user-profile-picture.controller'; const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; @@ -19,9 +18,9 @@ export interface UserProfilePictureRowViewProps { name: string; imageUrl?: string; hasImage?: boolean; - onChange?: (file: File) => Promise; + onChange?: (file: File) => Promise; onReject?: (rejections: FileRejection[]) => void; - onRemove?: () => Promise; + onRemove?: () => Promise; } export function UserProfilePictureRowView({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts index 074adf1713e..0b59b6fb643 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts @@ -1,12 +1,11 @@ import { useRef, useState } from 'react'; import type { LocalizableError } from '../../../localization'; -import type { SaveResult } from '../../../utils/save-result'; -import { UNEXPECTED_ERROR } from '../../../utils/save-result'; +import { toFormError } from '../../../utils/form-error'; export interface UserProfilePictureControllerOptions { - onChange?: (file: File) => Promise; - onRemove?: () => Promise; + onChange?: (file: File) => Promise; + onRemove?: () => Promise; } export interface UserProfilePictureController { @@ -24,7 +23,7 @@ export function useUserProfilePictureController({ const [error, setError] = useState(); const inFlight = useRef(false); - const run = async (action: () => Promise) => { + const run = async (action: () => Promise) => { if (inFlight.current) { return; } @@ -32,11 +31,9 @@ export function useUserProfilePictureController({ setIsPending(true); setError(undefined); try { - const result = await action(); - setError(result.error?.global); + await action(); } catch (cause) { - console.error(cause); - setError(UNEXPECTED_ERROR); + setError(toFormError(cause).global); } finally { inFlight.current = false; setIsPending(false); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx index e0f35042151..369abaa9b6b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -1,14 +1,12 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { SaveResult } from '../../../utils/save-result'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; export interface UserProfileUsernameRowViewProps { username: string; - onSubmit?: (username: string) => Promise>; + onSubmit?: (username: string) => Promise; } export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { @@ -33,13 +31,7 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs ); } -function EditUsername({ - username, - onSubmit, -}: { - username: string; - onSubmit: (username: string) => Promise>; -}) { +function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); const isSet = Boolean(username); diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/form-error.ts similarity index 52% rename from packages/mosaic/src/utils/save-result.ts rename to packages/mosaic/src/utils/form-error.ts index afd19950908..f5d9b13a258 100644 --- a/packages/mosaic/src/utils/save-result.ts +++ b/packages/mosaic/src/utils/form-error.ts @@ -8,13 +8,23 @@ export interface FormError { fields?: Partial>; } -export interface SaveResult { - error: FormError | null; -} - export const UNEXPECTED_ERROR: LocalizableError = { code: 'generic' }; -function toFormError(cause: unknown, fields: readonly TField[]): FormError | undefined { +/** What a save rejects with when the failure is the user's to fix; anything else propagates. */ +export class SaveError extends Error { + readonly formError: FormError; + + constructor(formError: FormError) { + super(formError.global?.message ?? 'Save failed'); + this.name = 'SaveError'; + this.formError = formError; + } +} + +function toClerkFormError( + cause: unknown, + fields: readonly TField[], +): FormError | undefined { if (isClerkRuntimeError(cause)) { return { global: { code: cause.code, ...(cause.longMessage ? { message: cause.longMessage } : {}) } }; } @@ -39,18 +49,30 @@ function toFormError(cause: unknown, fields: readonly TFi return error; } -export async function toSaveResult( +/** + * Runs a save and rejects with a `SaveError` the view can render. `fields` names the controls the + * failure may be routed to; an error that names none of them lands in `global`. + */ +export async function save( run: () => Promise, fields: readonly TField[] = [], -): Promise> { +): Promise { try { await run(); - return { error: null }; } catch (cause) { - const error = toFormError(cause, fields); - if (!error) { + const formError = toClerkFormError(cause, fields); + if (!formError) { throw cause; } - return { error }; + throw new SaveError(formError); + } +} + +/** Reads what a rejected save left for the view. An unrecognized rejection is the generic error. */ +export function toFormError(cause: unknown): FormError { + if (cause instanceof SaveError) { + return cause.formError; } + console.error(cause); + return { global: UNEXPECTED_ERROR }; } From b247231b638366a6bd72ba5f9ba8d612e4947027 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:47:20 -0400 Subject: [PATCH 15/31] fix(mosaic): offer to add the name and username the user has not set An empty row now reads "No name added" / "No username added" next to an Add action, rather than dropping the description and labelling the action Set. --- .../user-profile-name-row.view.test.tsx | 24 +++++++++++++++---- .../user-profile-username-row.view.test.tsx | 24 +++++++++++++++---- .../user-profile-account-section.messages.ts | 10 ++++---- .../user-profile-name-row.view.tsx | 6 ++--- .../user-profile-username-row.view.tsx | 6 ++--- 5 files changed, 52 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx index 946a67215ba..23caf7b8757 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx @@ -6,7 +6,7 @@ import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileNameRowView } from '../user-profile-account-section/user-profile-name-row.view'; describe('UserProfileNameRowView', () => { - it('offers to set a name the user does not have yet', async () => { + it('offers to add a name the user does not have yet', async () => { const user = userEvent.setup(); render( @@ -17,8 +17,24 @@ describe('UserProfileNameRowView', () => { , ); - expect(document.querySelector('.cl-section-description')).toBeNull(); - await user.click(screen.getByRole('button', { name: 'Set name' })); - expect(screen.getByRole('dialog', { name: 'Set name' })).toBeInTheDocument(); + expect(screen.getByText('No name added')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Add name' })); + expect(screen.getByRole('dialog', { name: 'Add name' })).toBeInTheDocument(); + }); + + it('offers to edit a name the user has', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + expect(screen.getByText('Preston Booth')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Edit name' })); + expect(screen.getByRole('dialog', { name: 'Edit name' })).toBeInTheDocument(); }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx index 30c1fb16301..9779b3210b0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx @@ -6,7 +6,7 @@ import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileUsernameRowView } from '../user-profile-account-section/user-profile-username-row.view'; describe('UserProfileUsernameRowView', () => { - it('offers to set a username the user does not have yet', async () => { + it('offers to add a username the user does not have yet', async () => { const user = userEvent.setup(); render( @@ -17,8 +17,24 @@ describe('UserProfileUsernameRowView', () => { , ); - expect(document.querySelector('.cl-section-description')).toBeNull(); - await user.click(screen.getByRole('button', { name: 'Set username' })); - expect(screen.getByRole('dialog', { name: 'Set username' })).toBeInTheDocument(); + expect(screen.getByText('No username added')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Add username' })); + expect(screen.getByRole('dialog', { name: 'Add username' })).toBeInTheDocument(); + }); + + it('offers to edit a username the user has', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + expect(screen.getByText('prestonxyz')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Edit username' })); + expect(screen.getByRole('dialog', { name: 'Edit username' })).toBeInTheDocument(); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 575b6bf9e40..3f17c4e172b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -21,11 +21,12 @@ export const userProfileAccountSectionMessages = { }, name: { label: 'Name', + empty: 'No name added', edit: 'Edit name', - set: 'Set name', + add: 'Add name', dialogTitle: 'Edit name', - setDialogTitle: 'Set name', + addDialogTitle: 'Add name', firstNameLabel: 'First name', lastNameLabel: 'Last name', cancel: 'Cancel', @@ -33,11 +34,12 @@ export const userProfileAccountSectionMessages = { }, username: { label: 'Username', + empty: 'No username added', edit: 'Edit username', - set: 'Set username', + add: 'Add username', dialogTitle: 'Edit username', - setDialogTitle: 'Set username', + addDialogTitle: 'Add username', fieldLabel: 'Username', cancel: 'Cancel', save: 'Save changes', diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx index 42a90278442..f9db05298f9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -29,7 +29,7 @@ export function UserProfileNameRowView({ {m.name.label} - {name ? {name} : null} + {name || m.name.empty} {onSubmit ? ( @@ -72,14 +72,14 @@ function EditName({ firstNameAttribute={firstNameAttribute} lastNameAttribute={lastNameAttribute} open={controller.isOpen} - title={isSet ? m.name.dialogTitle : m.name.setDialogTitle} + title={isSet ? m.name.dialogTitle : m.name.addDialogTitle} trigger={ } /> diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx index 369abaa9b6b..4031945d4f7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -16,7 +16,7 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs {m.username.label} - {username ? {username} : null} + {username || m.username.empty} {onSubmit ? ( @@ -40,14 +40,14 @@ function EditUsername({ username, onSubmit }: { username: string; onSubmit: (use - {isSet ? m.username.edit : m.username.set} + {isSet ? m.username.edit : m.username.add} } /> From ab6e1139875a450f8ccf4ced1007b31fff59e023 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:50:42 -0400 Subject: [PATCH 16/31] fix(repo): move the swingset fixtures onto the save rejection The account-section fixtures still returned the removed `SaveResult` and imported `utils/save-result`, which no longer exists, so swingset did not build. --- .../src/stories/fixtures/use-preview-image.ts | 4 ++-- .../src/stories/fixtures/user-profile-edit-name.ts | 6 +++--- .../stories/fixtures/user-profile-edit-password.ts | 12 ++++++++---- .../stories/fixtures/user-profile-edit-username.ts | 6 +++--- .../stories/user-profile-account-section.stories.tsx | 2 +- .../user-profile-password-section.stories.tsx | 12 +++++++----- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/swingset/src/stories/fixtures/use-preview-image.ts b/packages/swingset/src/stories/fixtures/use-preview-image.ts index fd8618fee8f..7a3f7c5495f 100644 --- a/packages/swingset/src/stories/fixtures/use-preview-image.ts +++ b/packages/swingset/src/stories/fixtures/use-preview-image.ts @@ -24,7 +24,7 @@ export function usePreviewImage(initialUrl?: string) { const next = URL.createObjectURL(file); objectUrlRef.current = next; setImageUrl(next); - return Promise.resolve({ error: null }); + return Promise.resolve(); }, [release], ); @@ -32,7 +32,7 @@ export function usePreviewImage(initialUrl?: string) { const clearImage = useCallback(() => { release(); setImageUrl(undefined); - return Promise.resolve({ error: null }); + return Promise.resolve(); }, [release]); return { imageUrl, showFile, clearImage }; 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 8cff25098ce..083b7436224 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -1,5 +1,6 @@ import type { UserProfileEditNameValue } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog'; -import type { FormError } from '@clerk/mosaic/utils/save-result'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; +import { SaveError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -32,10 +33,9 @@ export function useUserProfileEditNameFixture({ onSubmitName: async (value: UserProfileEditNameValue) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - return { error: failWith }; + throw new SaveError(failWith); } setName(value); - return { error: null }; }, }; } diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index a7711b2469a..621a0c283c7 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -1,5 +1,9 @@ -import type { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileEditPasswordValue } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; +import type { + UserProfileEditPasswordField, + UserProfileEditPasswordValue, +} from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; +import { SaveError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { @@ -7,7 +11,7 @@ export interface UserProfileEditPasswordFixtureOptions { requiresCurrentPassword?: boolean; latency?: number; /** Rejects the first save so the next attempt can succeed. */ - failWith?: UserProfileSaveError; + failWith?: FormError; } export function useUserProfileEditPasswordFixture({ @@ -26,7 +30,7 @@ export function useUserProfileEditPasswordFixture({ await new Promise(resolve => setTimeout(resolve, latency)); if (failWith && !hasFailed) { setHasFailed(true); - throw failWith; + throw new SaveError(failWith); } setHasPassword(true); }, 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 e639a441204..67a08f99aea 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -1,4 +1,5 @@ -import type { FormError } from '@clerk/mosaic/utils/save-result'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; +import { SaveError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -23,10 +24,9 @@ export function useUserProfileEditUsernameFixture({ onSubmitUsername: async (value: string) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - return { error: failWith }; + throw new SaveError(failWith); } setUsername(value); - return { error: null }; }, }; } 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 c1190e3a4c1..c4f212f6d34 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -7,7 +7,7 @@ import { UserProfileAccountSectionView } from '@clerk/mosaic/features/user-profi import type { UserProfileAddPhoneDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; import { UserProfileVerifyEmailLinkDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog'; import { UserProfileVerifyEmailSsoDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog'; -import type { FormError } from '@clerk/mosaic/utils/save-result'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index 58d71efb577..8b84910116f 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,5 +1,6 @@ -import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileEditPasswordField } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; import type { StoryMeta } from '@/lib/types'; @@ -24,7 +25,7 @@ function PasswordSection({ }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - failWith?: UserProfileSaveError; + failWith?: FormError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); @@ -73,9 +74,10 @@ export function ManagedByEnterprise() { export function EditPasswordFails() { return ( ); } From 79310f31181c5f04495866ba2f9908708ede7996 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:55:35 -0400 Subject: [PATCH 17/31] fix(mosaic): drop the removed save result from the panel view tests --- .../user-profile-profile-panel.view.test.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index a2a46967cba..a5f789f567a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -130,9 +130,9 @@ describe('UserProfileProfilePanelView', () => { it('composes the profile content without profile navigation', () => { renderView({ - onProfilePictureChange: vi.fn(() => Promise.resolve({ error: null })), - onSubmitName: () => Promise.resolve({ error: null }), - onSubmitUsername: () => Promise.resolve({ error: null }), + onProfilePictureChange: vi.fn(() => Promise.resolve()), + onSubmitName: () => Promise.resolve(), + onSubmitUsername: () => Promise.resolve(), }); expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); @@ -163,11 +163,11 @@ describe('UserProfileProfilePanelView', () => { }); it('uploads the picked file when no profile picture is set', async () => { - const onProfilePictureChange = vi.fn(() => Promise.resolve({ error: null })); + const onProfilePictureChange = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); const { container } = renderView({ onProfilePictureChange, - onRemoveProfilePicture: vi.fn(() => Promise.resolve({ error: null })), + onRemoveProfilePicture: vi.fn(() => Promise.resolve()), }); expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); @@ -183,7 +183,7 @@ describe('UserProfileProfilePanelView', () => { }); it('turns away a file past the size the row advertises', async () => { - const onProfilePictureChange = vi.fn(() => Promise.resolve({ error: null })); + const onProfilePictureChange = vi.fn(() => Promise.resolve()); const onProfilePictureReject = vi.fn(); const user = userEvent.setup(); const { container } = renderView({ onProfilePictureChange, onProfilePictureReject }); @@ -203,7 +203,7 @@ describe('UserProfileProfilePanelView', () => { it('clears the rejection once an acceptable file is picked', async () => { const user = userEvent.setup(); - const { container } = renderView({ onProfilePictureChange: vi.fn(() => Promise.resolve({ error: null })) }); + const { container } = renderView({ onProfilePictureChange: vi.fn(() => Promise.resolve()) }); const input = container.querySelector('input[type="file"]'); if (!input) { throw new Error('File picker not found'); @@ -374,7 +374,7 @@ describe('UserProfileProfilePanelView', () => { it('forwards profile and contact actions', async () => { const onAddEmail = vi.fn(); const onRemoveEmail = vi.fn(); - renderView({ onSubmitName: () => Promise.resolve({ error: null }), onAddEmail, onRemoveEmail }); + renderView({ onSubmitName: () => Promise.resolve(), onAddEmail, onRemoveEmail }); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Add email' })); @@ -393,7 +393,7 @@ describe('UserProfileProfilePanelView', () => { }); it('drives the edit-name dialog from the section, seeded with the saved name', async () => { - const onSubmitName = vi.fn(() => Promise.resolve({ error: null })); + const onSubmitName = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ firstName: 'Preston', lastName: 'Booth', onSubmitName }); @@ -411,7 +411,7 @@ describe('UserProfileProfilePanelView', () => { }); it('drives the edit-username dialog from the section, seeded with the saved username', async () => { - const onSubmitUsername = vi.fn(() => Promise.resolve({ error: null })); + const onSubmitUsername = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ username: 'prestonxyz', onSubmitUsername }); From ad289e8faa3db4176e8cd76d616982c243905a3b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:58:50 -0400 Subject: [PATCH 18/31] test(mosaic): leave the row gating to the model test --- ...ofile-account-section.integration.test.tsx | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index 9799f401658..f12cbbee267 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -7,8 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; -let usernameEnabled: boolean; -let namesEnabled: boolean; let user: { firstName: string; lastName: string; @@ -33,9 +31,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { __internal_environment: { userSettings: { attributes: { - first_name: { enabled: namesEnabled, required: false }, - last_name: { enabled: namesEnabled, required: false }, - username: { enabled: usernameEnabled, required: false }, + first_name: { enabled: true, required: false }, + last_name: { enabled: true, required: false }, + username: { enabled: true, required: false }, }, }, }, @@ -52,8 +50,6 @@ function renderSection() { } beforeEach(() => { - usernameEnabled = true; - namesEnabled = true; user = { firstName: 'Preston', lastName: 'Booth', @@ -133,20 +129,4 @@ describe('UserProfileAccountSection', () => { ); expect(user.setProfileImage).toHaveBeenCalledExactlyOnceWith({ file }); }); - - it('leaves the username row out when the instance does not use usernames', () => { - usernameEnabled = false; - renderSection(); - - expect(screen.queryByText('Username')).not.toBeInTheDocument(); - expect(screen.getByText('Name')).toBeInTheDocument(); - }); - - it('leaves the name row out when the instance collects neither name', () => { - namesEnabled = false; - renderSection(); - - expect(screen.queryByText('Name')).not.toBeInTheDocument(); - expect(screen.getByText('Username')).toBeInTheDocument(); - }); }); From a66d6664d895ee75ac9794232852959a57ed8acc Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 16:19:13 -0400 Subject: [PATCH 19/31] fix(mosaic): hold the name save until the name changes The picture row now names its local rejections by the same error codes the server uses, so one message serves both. --- .../user-profile-edit-name.controller.test.ts | 25 ++++++++++++++++--- .../user-profile-account-section.messages.ts | 6 ----- .../user-profile-edit-name.controller.ts | 8 +++++- .../user-profile-edit-name.dialog.tsx | 8 ++++-- .../user-profile-picture-row.view.tsx | 24 ++++++++++++------ .../src/localization/errors.messages.ts | 1 + 6 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index c2ad1a81714..c12e6f022a2 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -19,6 +19,13 @@ function start(saveName: () => Promise, saved = { savedFirstName: 'Preston return actor; } +/** The save is guarded on a change, so a test about the save has to make one first. */ +function startEdited(saveName: () => Promise) { + const actor = start(saveName); + actor.send({ type: 'TYPE', field: 'firstName', value: 'Ada' }); + return actor; +} + describe('userProfileEditNameMachine', () => { it('seeds both fields from the saved name on open', () => { const actor = start(saved); @@ -64,7 +71,7 @@ describe('userProfileEditNameMachine', () => { }); it('carries field copy through when the error names a control', async () => { - const actor = start(() => + const actor = startEdited(() => failed({ global: { message: 'Your name could not be updated.' }, fields: { firstName: { message: 'First name is required.' } }, @@ -80,7 +87,7 @@ describe('userProfileEditNameMachine', () => { it('shows the generic banner and logs when the save throws unexpectedly', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}); const failure = new TypeError('boom'); - const actor = start(() => Promise.reject(failure)); + const actor = startEdited(() => Promise.reject(failure)); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); @@ -89,7 +96,7 @@ describe('userProfileEditNameMachine', () => { }); it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => failed({ global: { message: 'nope' } })); + const actor = startEdited(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); @@ -134,4 +141,16 @@ describe('useUserProfileEditNameController', () => { await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ firstName: 'Ada', lastName: 'Booth' })); }); + + it('withholds the save while the name is unchanged', () => { + const { result } = renderHook(() => + useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: saved }), + ); + + act(() => result.current.onOpenChange(true)); + expect(result.current.canSave).toBe(false); + + act(() => result.current.onLastNameChange('Barton')); + expect(result.current.canSave).toBe(true); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 3f17c4e172b..cd9ddc712d0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -12,12 +12,6 @@ export const userProfileAccountSectionMessages = { manage: 'Manage profile picture', change: 'Change avatar', remove: 'Remove avatar', - - 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.', - overflow: 'Only one file can be uploaded at a time.', - }, }, name: { label: 'Name', diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index 5ee4aa2610a..6d798e464db 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -26,6 +26,10 @@ function notSeated(): Promise { return Promise.reject(new Error('edit-name deps are not seated')); } +function isSaveable(context: UserProfileEditNameContext): boolean { + return context.firstName !== context.savedFirstName || context.lastName !== context.savedLastName; +} + export const userProfileEditNameMachine = createMachine({ id: 'editName', initial: 'idle', @@ -53,7 +57,7 @@ export const userProfileEditNameMachine = createMachine({ editing: { on: { TYPE: { actions: assign((_, event) => ({ [event.field]: event.value })) }, - SAVE: 'saving', + SAVE: { target: 'saving', guard: isSaveable }, CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, }, }, @@ -84,6 +88,7 @@ export interface UserProfileEditNameController { onFirstNameChange: (value: string) => void; onLastNameChange: (value: string) => void; onSubmit: () => void; + canSave: boolean; isSaving: boolean; error: FormError | undefined; } @@ -105,6 +110,7 @@ export function useUserProfileEditNameController({ onFirstNameChange: value => send({ type: 'TYPE', field: 'firstName', value }), onLastNameChange: value => send({ type: 'TYPE', field: 'lastName', value }), onSubmit: () => send({ type: 'SAVE' }), + canSave: isSaveable(snapshot.context), isSaving: snapshot.value === 'saving', error: snapshot.context.error, }; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index 7d2b6281c31..6b65255090b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -32,6 +32,7 @@ export interface UserProfileEditNameDialogProps { onFirstNameChange: (value: string) => void; onLastNameChange: (value: string) => void; title?: string; + canSave?: boolean; isSaving?: boolean; error?: FormError; onSubmit: () => void; @@ -53,6 +54,7 @@ export function UserProfileEditNameDialog({ onFirstNameChange, onLastNameChange, title, + canSave = true, isSaving = false, error, onSubmit, @@ -64,10 +66,10 @@ export function UserProfileEditNameDialog({ const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; const { enabled: showLastName = true, required: lastNameRequired = false } = lastNameAttribute; - // `isSaving` only cancels the press on the action; it does not stop a native submit. + // `canSave`/`isSaving` only cancel the press on the action; they do not stop a native submit. const handleSubmit = (event: FormEvent) => { event.preventDefault(); - if (!isSaving) { + if (canSave && !isSaving) { onSubmit(); } }; @@ -158,6 +160,8 @@ export function UserProfileEditNameDialog({ form={formId} fullWidth isPending={isSaving} + disabled={!canSave} + focusableWhenDisabled > {m.name.save} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx index eb48a29b2ac..ee7eab49fc3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -5,8 +5,9 @@ import { ActionMenu } from '../../../components/action-menu'; import { Avatar } from '../../../components/avatar'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; +import type { LocalizableError } from '../../../localization'; import { useErrorText, useMessages } from '../../../localization'; -import type { FileRejection } from '../../../primitives/file-upload'; +import type { FileRejection, FileRejectionReason } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; import { useUserProfilePictureController } from './user-profile-picture.controller'; @@ -14,6 +15,13 @@ 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; +/** Rejecting a pick locally reads the same as the server rejecting the upload. */ +const REJECTION_ERRORS: Record = { + accept: { code: 'avatar_file_type_invalid' }, + size: { code: 'avatar_file_size_exceeded' }, + overflow: { code: 'avatar_file_count_exceeded' }, +}; + export interface UserProfilePictureRowViewProps { name: string; imageUrl?: string; @@ -34,12 +42,12 @@ export function UserProfilePictureRowView({ const m = useMessages('userProfileAccountSection'); const errorText = useErrorText(); const controller = useUserProfilePictureController({ onChange, onRemove }); - const [rejectionError, setRejectionError] = useState(); - const displayedError = rejectionError ?? (controller.error ? errorText(controller.error) : undefined); + const [rejection, setRejection] = useState(); + const error = rejection ?? controller.error; const remove = controller.onRemove; const handleRemove = remove ? () => { - setRejectionError(undefined); + setRejection(undefined); return remove(); } : undefined; @@ -58,14 +66,14 @@ export function UserProfilePictureRowView({ aria-busy={controller.isPending || undefined} render={} onReject={rejections => { - const rejection = rejections[0]; - setRejectionError(rejection ? m.picture.errors[rejection.reason] : undefined); + const rejected = rejections[0]; + setRejection(rejected ? REJECTION_ERRORS[rejected.reason] : undefined); onReject?.(rejections); }} onValueChange={files => { const file = files[0]; if (file) { - setRejectionError(undefined); + setRejection(undefined); void controller.onChange?.(file); } }} @@ -90,7 +98,7 @@ export function UserProfilePictureRowView({ onRemove={handleRemove} /> - {displayedError} + {error ? errorText(error) : undefined} ); } diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index faac31e6c9b..722e6c32581 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -1,6 +1,7 @@ export const errorMessages: { readonly generic: string } & Readonly> = { generic: 'Something went wrong. Please try again.', action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.", + avatar_file_count_exceeded: 'Only one file can be uploaded at a time.', avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', avatar_file_type_invalid: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', form_new_password_matches_current: 'New password cannot be the same as the current password.', From b7248e315aac222b2f2828c51a5e6215e1c13e45 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 16:19:29 -0400 Subject: [PATCH 20/31] chore(repo): ignore the vitest cache directory --- packages/mosaic/.vitest/json/output.json | 39 ------------------------ 1 file changed, 39 deletions(-) delete mode 100644 packages/mosaic/.vitest/json/output.json diff --git a/packages/mosaic/.vitest/json/output.json b/packages/mosaic/.vitest/json/output.json deleted file mode 100644 index 2bbcde4d5c3..00000000000 --- a/packages/mosaic/.vitest/json/output.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "numTotalTestSuites": 1, - "numPassedTestSuites": 0, - "numFailedTestSuites": 1, - "numPendingTestSuites": 0, - "numTotalTests": 0, - "numPassedTests": 0, - "numFailedTests": 0, - "numPendingTests": 0, - "numTodoTests": 0, - "snapshot": { - "added": 0, - "failure": false, - "filesAdded": 0, - "filesRemoved": 0, - "filesRemovedList": [], - "filesUnmatched": 0, - "filesUpdated": 0, - "matched": 0, - "total": 0, - "unchecked": 0, - "uncheckedKeysByFile": [], - "unmatched": 0, - "updated": 0, - "didUpdate": false - }, - "startTime": 1790015468853, - "success": false, - "testResults": [ - { - "assertionResults": [], - "startTime": 1790015468853, - "endTime": 1790015468853, - "status": "failed", - "message": "Failed to resolve import \"../user-profile-account-section/user-profile-account-section.model\" from \"src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx\". Does the file exist?", - "name": "/Users/alex/Repos/javascript.carp-mosaic-user-profile-avatar-wireup/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx" - } - ] -} From 584760c04b2b3bd25846434544e22cc8f0048a01 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:30:44 -0400 Subject: [PATCH 21/31] feat(mosaic): state why a section row offers no action Section.Note takes an action's place in a row, carrying the .cl-section-note and .cl-section-note-icon slots that the feature-level managed-by label had no way to expose. It holds the trailing slot itself, so it needs no Section.Actions around it. --- .../mosaic/src/components/section/index.ts | 1 + .../src/components/section/section.styles.ts | 18 ++++++++ .../src/components/section/section.test.tsx | 40 ++++++++++++++++++ .../mosaic/src/components/section/section.tsx | 29 +++++++++++++ .../user-profile-name-row.view.test.tsx | 13 ++++++ .../user-profile-name-row.view.tsx | 11 ++++- .../user-profile/user-profile-managed-by.tsx | 41 +++++++++++++++++++ .../user-profile-password-row.view.tsx | 41 ++----------------- packages/swingset/src/stories/section.mdx | 2 + 9 files changed, 158 insertions(+), 38 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx diff --git a/packages/mosaic/src/components/section/index.ts b/packages/mosaic/src/components/section/index.ts index 8b920fdc6fe..65aa7c80800 100644 --- a/packages/mosaic/src/components/section/index.ts +++ b/packages/mosaic/src/components/section/index.ts @@ -9,6 +9,7 @@ export type { SectionLabelProps, SectionMediaProps, SectionMediaSize, + SectionNoteProps, SectionRootProps, SectionRowProps, SectionTitleProps, diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index a8978b6bf7f..143b8d304c9 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -128,6 +128,24 @@ export const styles = stylex.create({ flexShrink: 0, justifyContent: 'flex-end', }, + note: { + alignItems: 'center', + color: colorVars['--cl-color-foreground-secondary'], + columnGap: space['1'], + display: 'flex', + flexShrink: 0, + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-normal'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + noteIcon: { + alignItems: 'center', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + height: space['4'], + width: space['4'], + }, // Sits under the row's item rather than inside its content, so a message never shifts the // media and actions off the center line they share. error: { diff --git a/packages/mosaic/src/components/section/section.test.tsx b/packages/mosaic/src/components/section/section.test.tsx index 06d629f4ae9..d609d2ddc74 100644 --- a/packages/mosaic/src/components/section/section.test.tsx +++ b/packages/mosaic/src/components/section/section.test.tsx @@ -270,4 +270,44 @@ describe('Section', () => { rerender(); await waitFor(() => expect(screen.queryByTestId('error')).not.toBeInTheDocument()); }); + + it('states why a row has no action, with the leading glyph in its own slot', () => { + render( + + + + + + Name + + + } + > + Managed by Okta + + + + + , + ); + + const note = screen.getByTestId('note'); + expect(note).toHaveClass('cl-section-note'); + expect(note).toHaveTextContent('Managed by Okta'); + expect(note.querySelector('.cl-section-note-icon')).toContainElement(screen.getByRole('presentation')); + // Holds the trailing slot itself, so it needs no Section.Actions around it. + expect(note.parentElement).toBe(screen.getByTestId('item')); + }); + + it('leaves out the glyph slot when the note carries no icon', () => { + render(Managed by Acme SSO); + + expect(screen.getByTestId('note').querySelector('.cl-section-note-icon')).toBeNull(); + }); }); diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 201c410c958..cf88fc7a298 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -27,6 +27,7 @@ export type SectionContentProps = MosaicComponentProps<'div'>; export type SectionLabelProps = MosaicComponentProps<'div'>; export type SectionDescriptionProps = MosaicComponentProps<'div'>; export type SectionActionsProps = MosaicComponentProps<'div'>; +export type SectionNoteProps = MosaicComponentProps<'div'> & { icon?: React.ReactNode }; export type SectionErrorProps = MosaicComponentProps<'p'>; const mediaSizes = { @@ -227,6 +228,33 @@ const Actions = React.forwardRef(function S }); }); +/** + * Static text that takes an action's place in a row, stating why it offers none. Sits directly in + * `Section.Item` where a `Section.Actions` would; it holds its own trailing position, so it does not + * need one. `icon` renders into a fixed leading slot, sized to the text, for a logo or a lock. + */ +const Note = React.forwardRef(function SectionNote( + { icon, children, render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('section-note'), stylex.props(reset.base, styles.note, xstyle), rest), + children: ( + <> + {icon ? ( + {icon} + ) : null} + {children} + + ), + }, + }); +}); + /** * A row-level message, mirroring `Field.Error` for a row that holds no form control. Place it as a * sibling of `Section.Item` inside `Section.Row`, not inside `Section.Content`: the item stays a @@ -299,5 +327,6 @@ export const Section = { Label, Description, Actions, + Note, Error: SectionError, }; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx index 23caf7b8757..224dd3b36d9 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx @@ -37,4 +37,17 @@ describe('UserProfileNameRowView', () => { await user.click(screen.getByRole('button', { name: 'Edit name' })); expect(screen.getByRole('dialog', { name: 'Edit name' })).toBeInTheDocument(); }); + it('names the connection managing the name in place of the edit action', () => { + render( + + + , + ); + + expect(screen.getByText('Managed by Okta')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx index f9db05298f9..f734d91c7e3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -1,6 +1,8 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; -import { useMessages } from '../../../localization'; +import { fill, useMessages } from '../../../localization'; +import type { UserProfileManagedBy } from '../user-profile-managed-by'; +import { UserProfileManagedByLabel } from '../user-profile-managed-by'; 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'; @@ -12,6 +14,7 @@ export interface UserProfileNameRowViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; + managedBy?: UserProfileManagedBy; onSubmit?: (value: UserProfileEditNameValue) => Promise; } @@ -21,9 +24,11 @@ export function UserProfileNameRowView({ lastName, firstNameAttribute, lastNameAttribute, + managedBy, onSubmit, }: UserProfileNameRowViewProps) { const m = useMessages('userProfileAccountSection'); + return ( @@ -42,6 +47,10 @@ export function UserProfileNameRowView({ onSubmit={onSubmit} /> + ) : managedBy ? ( + + {fill(m.name.managedBy, { name: managedBy.name })} + ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx b/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx new file mode 100644 index 00000000000..d92fb71b680 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx @@ -0,0 +1,41 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Icon } from '../../components/icon'; +import { Section } from '../../components/section'; + +/** The enterprise connection a row's value comes from, which is why the row has nothing to edit. */ +export interface UserProfileManagedBy { + name: string; + iconUrl?: string; +} + +const styles = stylex.create({ + logo: { + height: '100%', + width: '100%', + }, +}); + +export function UserProfileManagedByLabel({ iconUrl, children }: { iconUrl?: string; children: string }) { + return ( + + ) : ( + + ) + } + > + {children} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 46012fc9643..96efcb2b33b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -1,16 +1,11 @@ -import * as stylex from '@stylexjs/stylex'; - import { Button } from '../../../components/button'; -import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; -import { Text } from '../../../components/text'; import { fill, useMessages } from '../../../localization'; +import { UserProfileManagedByLabel } from '../user-profile-managed-by'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordValue, - UserProfilePasswordManagedBy, UserProfilePasswordSectionViewProps, } from './user-profile-password-section.types'; @@ -29,9 +24,9 @@ export function UserProfilePasswordRowView({ {hasPassword ? m.masked : m.noPasswordSet} {managedBy ? ( - - - + + {fill(m.managedBy, { name: managedBy.name })} + ) : onSubmitPassword ? ( - {iconUrl ? ( - - ) : ( - - )} - } - size='sm' - xstyle={styles.managedByText} - > - {fill(m.managedBy, { name })} - -
- ); -} - function EditPassword({ hasPassword, requiresCurrentPassword, diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index bfe05b66695..ed09cd3e93d 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -26,6 +26,8 @@ import { Section } from '@clerk/mosaic/components/section'; `Section.Group` owns the outlined surface. Its direct `Section.Row` children own separation and may stack one or more `Section.Item` entries. Each item lays out optional media, flexible content, and trailing actions. `Section.Media` accepts `sm`, `md`, `lg`, and `xl` sizes. +When a row offers no action, `Section.Note` takes the place of its `Section.Actions` to say why — it sits directly in `Section.Item` and holds the trailing slot itself. Its `icon` prop renders into a fixed leading slot sized to the text, for a provider logo or a lock. + Use `Section.Items` for a nested value list beneath a row's header item. The row keeps a 12px header-to-list gap, and nested items stack without gaps with 4px vertical padding. `Section.Title` renders at the level from the nearest `HeadingLevelProvider`, or `h2` outside one. From 5d8de0097244ce522b989cff8f3e31c16acc2a1a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:30:51 -0400 Subject: [PATCH 22/31] feat(mosaic): show the picked avatar while it uploads FileUpload.useObjectUrl is the one implementation of file to URL, shared by ItemPreview and the profile picture row. The row shows the pick right away and drops it if the upload fails, so the avatar never shows a picture the account does not have. --- .../user-profile-picture.controller.test.ts | 98 ++++++++++++++++++- .../user-profile-picture-row.view.tsx | 2 +- .../user-profile-picture.controller.ts | 27 ++++- .../src/primitives/file-upload/README.md | 5 + .../file-upload/file-upload-item-preview.tsx | 20 +--- .../src/primitives/file-upload/parts.ts | 1 + .../primitives/file-upload/use-object-url.ts | 26 +++++ 7 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 packages/mosaic/src/primitives/file-upload/use-object-url.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index a92f99ed67b..c5abf01570d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -1,6 +1,6 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SaveError } from '../../../utils/form-error'; import type { UserProfilePictureControllerOptions } from '../user-profile-account-section/user-profile-picture.controller'; @@ -13,6 +13,20 @@ function renderController(options: UserProfilePictureControllerOptions) { } describe('useUserProfilePictureController', () => { + // jsdom ships neither half of the object URL API. + let nextUrl = 0; + const revoked: string[] = []; + + beforeEach(() => { + nextUrl = 0; + revoked.length = 0; + vi.stubGlobal('URL', { + ...URL, + createObjectURL: () => `blob:preview-${++nextUrl}`, + revokeObjectURL: (url: string) => revoked.push(url), + }); + }); + it('leaves out the actions the model did not offer', () => { const { result } = renderController({}); expect(result.current.onChange).toBeUndefined(); @@ -85,4 +99,86 @@ describe('useUserProfilePictureController', () => { await act(async () => result.current.onRemove?.()); expect(result.current.error).toEqual({ message: 'Nope.' }); }); + + it('shows the picked file while the upload is still running', async () => { + const upload = createDeferredPromise(); + const onChange = vi.fn(() => upload.promise.then(() => undefined)); + const { result } = renderController({ onChange }); + + expect(result.current.previewUrl).toBeUndefined(); + + act(() => { + void result.current.onChange?.(file); + }); + expect(result.current.previewUrl).toBe('blob:preview-1'); + + await act(async () => { + upload.resolve(); + await upload.promise; + }); + expect(result.current.previewUrl).toBe('blob:preview-1'); + }); + + it('drops the preview when the upload fails, so the avatar is never a lie', async () => { + const upload = createDeferredPromise(); + const onChange = vi.fn(() => upload.promise.then(() => undefined)); + const { result } = renderController({ onChange }); + + act(() => { + void result.current.onChange?.(file); + }); + expect(result.current.previewUrl).toBe('blob:preview-1'); + + await act(async () => { + upload.reject(new SaveError({ global: { message: 'Nope.' } })); + await upload.promise.catch(() => undefined); + }); + expect(result.current.previewUrl).toBeUndefined(); + expect(revoked).toEqual(['blob:preview-1']); + }); + + it('drops the preview once the picture is removed', async () => { + const onChange = vi.fn().mockResolvedValue(undefined); + const onRemove = vi.fn().mockResolvedValue(undefined); + const { result } = renderController({ onChange, onRemove }); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.previewUrl).toBe('blob:preview-1'); + + await act(async () => result.current.onRemove?.()); + expect(result.current.previewUrl).toBeUndefined(); + expect(revoked).toEqual(['blob:preview-1']); + }); + + it('revokes the previous preview when a second pick replaces it', async () => { + const onChange = vi.fn().mockResolvedValue(undefined); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + await act(async () => result.current.onChange?.(new File(['y'], 'other.png', { type: 'image/png' }))); + + expect(result.current.previewUrl).toBe('blob:preview-2'); + expect(revoked).toEqual(['blob:preview-1']); + }); + + it('keeps one preview when the same file is picked twice', async () => { + const onChange = vi.fn().mockResolvedValue(undefined); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + await act(async () => result.current.onChange?.(file)); + + expect(result.current.previewUrl).toBe('blob:preview-1'); + expect(revoked).toEqual([]); + }); + + it('revokes the preview on unmount', async () => { + const onChange = vi.fn().mockResolvedValue(undefined); + const { result, unmount } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + unmount(); + + expect(revoked).toEqual(['blob:preview-1']); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx index ee7eab49fc3..b18c69fbe3d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx @@ -83,7 +83,7 @@ export function UserProfilePictureRowView({ {initials} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts index 0b59b6fb643..765c08e981d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts @@ -1,6 +1,7 @@ import { useRef, useState } from 'react'; import type { LocalizableError } from '../../../localization'; +import { FileUpload } from '../../../primitives/file-upload'; import { toFormError } from '../../../utils/form-error'; export interface UserProfilePictureControllerOptions { @@ -12,6 +13,7 @@ export interface UserProfilePictureController { onChange?: (file: File) => Promise; onRemove?: () => Promise; isPending: boolean; + previewUrl: string | undefined; error: LocalizableError | undefined; } @@ -21,9 +23,11 @@ export function useUserProfilePictureController({ }: UserProfilePictureControllerOptions): UserProfilePictureController { const [isPending, setIsPending] = useState(false); const [error, setError] = useState(); + const [preview, setPreview] = useState(); + const previewUrl = FileUpload.useObjectUrl(preview); const inFlight = useRef(false); - const run = async (action: () => Promise) => { + const run = async (action: () => Promise, revert?: () => void) => { if (inFlight.current) { return; } @@ -33,6 +37,7 @@ export function useUserProfilePictureController({ try { await action(); } catch (cause) { + revert?.(); setError(toFormError(cause).global); } finally { inFlight.current = false; @@ -41,9 +46,25 @@ export function useUserProfilePictureController({ }; return { - onChange: onChange ? file => run(() => onChange(file)) : undefined, - onRemove: onRemove ? () => run(onRemove) : undefined, + onChange: onChange + ? file => + run( + () => { + setPreview(file); + return onChange(file); + }, + () => setPreview(undefined), + ) + : undefined, + onRemove: onRemove + ? () => + run(async () => { + await onRemove(); + setPreview(undefined); + }) + : undefined, isPending, + previewUrl, error, }; } diff --git a/packages/mosaic/src/primitives/file-upload/README.md b/packages/mosaic/src/primitives/file-upload/README.md index b7c785e4179..3a8bdae5783 100644 --- a/packages/mosaic/src/primitives/file-upload/README.md +++ b/packages/mosaic/src/primitives/file-upload/README.md @@ -116,6 +116,11 @@ added. `{ files, addFiles, removeFile, clearFiles, openFilePicker, disabled }` for reading the selection and driving custom UI. It must be called inside `FileUpload.Root`. +`FileUpload.useObjectUrl(file)` returns a URL for a locally picked file, revoked when the file is +replaced or the caller unmounts, and `undefined` when there is no file. `ItemPreview` uses it, and so +does any surface that shows the pick in its own element rather than a thumbnail — an avatar showing +the new image while the upload is still running. It does not need `FileUpload.Root`. + ## Props ### `FileUpload.Root` diff --git a/packages/mosaic/src/primitives/file-upload/file-upload-item-preview.tsx b/packages/mosaic/src/primitives/file-upload/file-upload-item-preview.tsx index 6c06be6f198..0a50f345ffc 100644 --- a/packages/mosaic/src/primitives/file-upload/file-upload-item-preview.tsx +++ b/packages/mosaic/src/primitives/file-upload/file-upload-item-preview.tsx @@ -1,16 +1,16 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { type ComponentProps, mergeProps, useRender } from '../utils'; import { useFileUploadItemContext } from './file-upload-context'; +import { useObjectUrl } from './use-object-url'; export type FileUploadItemPreviewProps = ComponentProps<'img'>; /** * Renders an `` thumbnail for the item's file when it is an image. For * non-image files it renders nothing, so it is safe to drop in unconditionally. - * The object URL is created on mount and revoked on unmount to avoid leaks. */ export const FileUploadItemPreview = React.forwardRef( function FileUploadItemPreview(props, ref) { @@ -18,19 +18,7 @@ export const FileUploadItemPreview = React.forwardRef(undefined); - - useEffect(() => { - if (!isImage) { - return; - } - const url = URL.createObjectURL(file); - setObjectUrl(url); - return () => { - URL.revokeObjectURL(url); - setObjectUrl(undefined); - }; - }, [file, isImage]); + const objectUrl = useObjectUrl(isImage ? file : undefined); const defaultProps: Record = { src: objectUrl, @@ -43,7 +31,7 @@ export const FileUploadItemPreview = React.forwardRef with an empty src. - enabled: isImage && objectUrl != null, + enabled: objectUrl != null, props: mergeProps<'img'>(defaultProps, otherProps), }); }, diff --git a/packages/mosaic/src/primitives/file-upload/parts.ts b/packages/mosaic/src/primitives/file-upload/parts.ts index cc9cf9e54a0..21ced5ec2e6 100644 --- a/packages/mosaic/src/primitives/file-upload/parts.ts +++ b/packages/mosaic/src/primitives/file-upload/parts.ts @@ -10,3 +10,4 @@ export { type FileUploadItemProps, FileUploadItem as Item } from './file-upload- export { type FileUploadItemPreviewProps, FileUploadItemPreview as ItemPreview } from './file-upload-item-preview'; export { type FileUploadItemDeleteProps, FileUploadItemDelete as ItemDelete } from './file-upload-item-delete'; export { useFileUpload } from './file-upload-context'; +export { useObjectUrl } from './use-object-url'; diff --git a/packages/mosaic/src/primitives/file-upload/use-object-url.ts b/packages/mosaic/src/primitives/file-upload/use-object-url.ts new file mode 100644 index 00000000000..0acd52cc38f --- /dev/null +++ b/packages/mosaic/src/primitives/file-upload/use-object-url.ts @@ -0,0 +1,26 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * The object URL for a locally picked file, revoked when the file is replaced or the caller + * unmounts. Answers `undefined` for no file, so a caller can fall back to an already stored image. + */ +export function useObjectUrl(file: File | undefined): string | undefined { + const [url, setUrl] = useState(); + + useEffect(() => { + if (!file) { + setUrl(undefined); + return; + } + const created = URL.createObjectURL(file); + setUrl(created); + return () => { + URL.revokeObjectURL(created); + setUrl(undefined); + }; + }, [file]); + + return url; +} From 46493e688005829c9176ef2284af5e70c0a3e392 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:30:57 -0400 Subject: [PATCH 23/31] fix(mosaic): name the username length the instance actually requires A LocalizableError can carry the values its message interpolates, so a save passes the instance settings a failure needs to read. The username save passes the configured bounds, which form_username_invalid_length fills in. --- ...ofile-account-section.integration.test.tsx | 1 + ...ser-profile-account-section.model.test.tsx | 37 +++++++++++++++---- .../user-profile-account-section.model.ts | 28 +++++++++++--- .../src/localization/errors.messages.ts | 1 + .../mosaic/src/localization/errors.test.tsx | 13 +++++++ packages/mosaic/src/localization/errors.ts | 11 +++++- packages/mosaic/src/utils/form-error.ts | 19 ++++++++-- 7 files changed, 92 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index f12cbbee267..e26e926d863 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -35,6 +35,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { last_name: { enabled: true, required: false }, username: { enabled: true, required: false }, }, + usernameSettings: { min_length: 4, max_length: 64 }, }, }, }), diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index d9623189b3d..4d99091666f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -22,7 +22,11 @@ let user: { username: string | null; imageUrl: string; hasImage: boolean; - enterpriseAccounts: { active: boolean }[]; + enterpriseAccounts: { + active: boolean; + provider: string; + enterpriseConnection: { name: string; logoPublicUrl: string | null } | null; + }[]; primaryEmailAddressId: string | null; primaryPhoneNumberId: string | null; emailAddresses: { id: string; emailAddress: string; verification: { status: string | null } }[]; @@ -31,6 +35,7 @@ let user: { update: ReturnType; } | null; let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>; +let usernameSettings: { min_length: number; max_length: number }; let environmentHydrated: boolean; function attribute(overrides: Partial = {}): FakeAttribute { @@ -43,7 +48,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), useClerk: () => ({ - __internal_environment: environmentHydrated ? { userSettings: { attributes } } : null, + __internal_environment: environmentHydrated ? { userSettings: { attributes, usernameSettings } } : null, }), }; }); @@ -86,6 +91,7 @@ function apiError(paramName?: string) { beforeEach(() => { isUserLoaded = true; environmentHydrated = true; + usernameSettings = { min_length: 4, max_length: 64 }; attributes = { first_name: attribute(), last_name: attribute(), @@ -199,9 +205,21 @@ describe('useUserProfileAccountSectionModel', () => { }); }); - it('is read only while an enterprise account is active', () => { - user?.enterpriseAccounts.push({ active: true }); - expect(ready().onSubmitName).toBeUndefined(); + it('names the connection managing the name instead of offering to edit it', () => { + user?.enterpriseAccounts.push({ + active: true, + provider: 'saml_okta', + enterpriseConnection: { name: 'Okta', logoPublicUrl: 'https://img.clerk.com/okta.svg' }, + }); + expect(ready()).toMatchObject({ + nameManagedBy: { name: 'Okta', iconUrl: 'https://img.clerk.com/okta.svg' }, + onSubmitName: undefined, + }); + }); + + it('falls back to the provider when the active account carries no connection', () => { + user?.enterpriseAccounts.push({ active: true, provider: 'saml_okta', enterpriseConnection: null }); + expect(ready().nameManagedBy).toEqual({ name: 'okta', iconUrl: undefined }); }); it('passes the instance name attributes through for the row to hide itself', () => { @@ -232,11 +250,16 @@ describe('useUserProfileAccountSectionModel', () => { expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); }); - it('maps a username error onto the field', async () => { + it('maps a username error onto the field, with the configured length bounds to fill its message', async () => { user?.update.mockRejectedValue(apiError('username')); await expect(rejection(ready().onSubmitUsername?.('x'))).resolves.toEqual({ fields: { - username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, + username: { + code: 'form_param_invalid', + paramName: 'username', + message: 'That value is invalid.', + params: { min_length: 4, max_length: 64 }, + }, }, }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index d06f4fb9ddf..b2525e2e238 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -1,9 +1,10 @@ import { getFullName } from '@clerk/shared/internal/clerk-js/user'; import { useUser } from '@clerk/shared/react'; -import type { AttributeData, UserResource } from '@clerk/shared/types'; +import type { AttributeData, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import { save } from '../../../utils/form-error'; +import type { UserProfileManagedBy } from '../user-profile-managed-by'; import type { UserProfileEmail, UserProfileNameAttribute, @@ -24,6 +25,7 @@ type UserProfileAccountSectionData = Pick< | 'lastName' | 'firstNameAttribute' | 'lastNameAttribute' + | 'nameManagedBy' | 'username' | 'emails' | 'phones' @@ -41,6 +43,17 @@ export type UserProfileAccountSectionModel = const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; +function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfileManagedBy | undefined { + if (!account) { + return undefined; + } + const connection = account.enterpriseConnection; + return { + name: connection?.name || account.provider.replace(/^(oauth_|saml_)/, ''), + iconUrl: connection?.logoPublicUrl ?? undefined, + }; +} + function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameAttribute { return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; } @@ -79,11 +92,11 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'hidden' }; } - const { attributes } = environment.userSettings; + const { attributes, usernameSettings } = environment.userSettings; const usernameAttribute = attributes.username; const usernameImmutable = Boolean(usernameAttribute?.immutable); const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); - const nameReadOnly = user.enterpriseAccounts.some(account => account.active); + const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); const showEmails = isAttributeAvailable(attributes.email_address); const showPhones = isAttributeAvailable(attributes.phone_number); @@ -95,6 +108,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo lastName: user.lastName ?? '', firstNameAttribute: toNameAttribute(attributes.first_name), lastNameAttribute: toNameAttribute(attributes.last_name), + nameManagedBy, imageUrl: user.imageUrl, hasImage: user.hasImage, username: showUsername ? (user.username ?? '') : undefined, @@ -102,12 +116,16 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo phones: showPhones ? toPhones(user) : undefined, onProfilePictureChange: file => save(() => user.setProfileImage({ file })), onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, - onSubmitName: nameReadOnly + onSubmitName: nameManagedBy ? undefined : value => save(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), onSubmitUsername: showUsername && !usernameImmutable - ? username => save(() => user.update({ username }), USERNAME_FIELDS) + ? username => + save(() => user.update({ username }), USERNAME_FIELDS, { + min_length: usernameSettings.min_length, + max_length: usernameSettings.max_length, + }) : undefined, }; } diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 722e6c32581..ce13a6a65ec 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -11,5 +11,6 @@ export const errorMessages: { readonly generic: string } & Readonly { ); }); + it('fills the values the error carries into its message', () => { + expect(errorText()({ code: 'form_username_invalid_length', params: { min_length: 4, max_length: 64 } })).toBe( + 'Your username must be between 4 and 64 characters long.', + ); + }); + + it('fills an override the same way', () => { + const text = errorText({ 'errors.form_username_invalid_length': 'Entre {min_length} y {max_length} caracteres.' }); + expect(text({ code: 'form_username_invalid_length', params: { min_length: 4, max_length: 64 } })).toBe( + 'Entre 4 y 64 caracteres.', + ); + }); + it('falls back to the generic message when there is nothing else', () => { expect(errorText({ 'errors.generic': 'Algo salió mal.' })({})).toBe('Algo salió mal.'); expect(errorText()({ code: 'toString' })).toBe('Something went wrong. Please try again.'); diff --git a/packages/mosaic/src/localization/errors.ts b/packages/mosaic/src/localization/errors.ts index 9dd17f65dc5..d11da404a36 100644 --- a/packages/mosaic/src/localization/errors.ts +++ b/packages/mosaic/src/localization/errors.ts @@ -1,14 +1,21 @@ import { useMessages } from './context'; +import type { MessageValues } from './messages'; +import { fill } from './messages'; export interface LocalizableError { code?: string; paramName?: string; message?: string; + /** Values for the `{placeholder}`s in whichever message the code resolves to. */ + params?: MessageValues; } export function useErrorText(): (error: LocalizableError) => string { const messages = useMessages('errors'); const lookup = (key: string | undefined) => (key && Object.hasOwn(messages, key) ? messages[key] : undefined); - return ({ code, paramName, message }) => - lookup(code && paramName ? `${code}__${paramName}` : undefined) ?? lookup(code) ?? message ?? messages.generic; + return ({ code, paramName, message, params }) => { + const template = + lookup(code && paramName ? `${code}__${paramName}` : undefined) ?? lookup(code) ?? message ?? messages.generic; + return params ? fill(template, params) : template; + }; } diff --git a/packages/mosaic/src/utils/form-error.ts b/packages/mosaic/src/utils/form-error.ts index f5d9b13a258..45dd494ae4d 100644 --- a/packages/mosaic/src/utils/form-error.ts +++ b/packages/mosaic/src/utils/form-error.ts @@ -1,7 +1,7 @@ import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; import { snakeToCamel } from '@clerk/shared/underscore'; -import type { LocalizableError } from '../localization'; +import type { LocalizableError, MessageValues } from '../localization'; export interface FormError { global?: LocalizableError; @@ -24,9 +24,16 @@ export class SaveError extends Error { function toClerkFormError( cause: unknown, fields: readonly TField[], + params: MessageValues | undefined, ): FormError | undefined { if (isClerkRuntimeError(cause)) { - return { global: { code: cause.code, ...(cause.longMessage ? { message: cause.longMessage } : {}) } }; + return { + global: { + code: cause.code, + ...(cause.longMessage ? { message: cause.longMessage } : {}), + ...(params ? { params } : {}), + }, + }; } if (!isClerkAPIResponseError(cause)) { return undefined; @@ -38,6 +45,7 @@ function toClerkFormError( code: apiError.code, ...(paramName ? { paramName } : {}), message: apiError.longMessage || apiError.message, + ...(params ? { params } : {}), }; const field = fields.find(f => paramName && snakeToCamel(paramName) === f); if (field) { @@ -51,16 +59,19 @@ function toClerkFormError( /** * Runs a save and rejects with a `SaveError` the view can render. `fields` names the controls the - * failure may be routed to; an error that names none of them lands in `global`. + * failure may be routed to; an error that names none of them lands in `global`. `params` carries + * the instance settings a failure's message may need to read — the length bounds a username was + * measured against, say — since only the caller's layer can resolve them. */ export async function save( run: () => Promise, fields: readonly TField[] = [], + params?: MessageValues, ): Promise { try { await run(); } catch (cause) { - const formError = toClerkFormError(cause, fields); + const formError = toClerkFormError(cause, fields, params); if (!formError) { throw cause; } From 3910ab4bd4e22db54c542e5c94b0ebe068d1c0e7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:31:05 -0400 Subject: [PATCH 24/31] feat(mosaic): name the connection managing a row's value The managed-by shape is one type shared by the name and password rows, and the enterprise-managed case has a swingset story of its own. --- .../user-profile-account-section.messages.ts | 1 + .../user-profile-account-section.view.tsx | 4 ++++ .../user-profile-password-section.styles.ts | 13 ------------- .../user-profile-password-section.types.ts | 9 +++------ .../user-profile-password-section.view.tsx | 2 +- .../user-profile-security-panel.view.tsx | 4 ++-- packages/swingset/src/lib/registry.ts | 2 ++ .../stories/user-profile-account-section.mdx | 10 ++++++++++ .../user-profile-account-section.stories.tsx | 17 +++++++++++++++++ 9 files changed, 40 insertions(+), 22 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index cd9ddc712d0..97ef3318fbf 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -18,6 +18,7 @@ export const userProfileAccountSectionMessages = { empty: 'No name added', edit: 'Edit name', add: 'Add name', + managedBy: 'Managed by {name}', dialogTitle: 'Edit name', addDialogTitle: 'Add name', diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 1de473c3f6f..bf3923075d0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -3,6 +3,7 @@ import * as stylex from '@stylexjs/stylex'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; +import type { UserProfileManagedBy } from '../user-profile-managed-by'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, @@ -34,6 +35,7 @@ export interface UserProfileAccountSectionViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; + nameManagedBy?: UserProfileManagedBy; /** Left out when the instance does not collect the attribute, which drops the row. */ emails?: UserProfileEmail[]; phones?: UserProfilePhone[]; @@ -67,6 +69,7 @@ export function UserProfileAccountSectionView({ lastName, firstNameAttribute, lastNameAttribute, + nameManagedBy, emails, phones, onProfilePictureChange, @@ -136,6 +139,7 @@ export function UserProfileAccountSectionView({ lastName={lastName} firstNameAttribute={firstNameAttribute} lastNameAttribute={lastNameAttribute} + managedBy={nameManagedBy} onSubmit={onSubmitName} /> ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts index 74b0946ba9d..047e4a2c042 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts @@ -28,17 +28,4 @@ export const styles = stylex.create({ checkboxDescription: { color: colorVars['--cl-color-foreground-secondary'], }, - managedBy: { - gap: space['1.5'], - alignItems: 'center', - display: 'flex', - }, - managedByIcon: { - flexShrink: 0, - height: space['4'], - width: space['4'], - }, - managedByText: { - color: colorVars['--cl-color-foreground-secondary'], - }, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index 894bd6ec9f7..df72789e738 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -1,3 +1,5 @@ +import type { UserProfileManagedBy } from '../user-profile-managed-by'; + export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; export interface UserProfileEditPasswordValues { @@ -13,16 +15,11 @@ export interface UserProfileEditPasswordValue { signOutOfOtherSessions: boolean; } -export interface UserProfilePasswordManagedBy { - name: string; - iconUrl?: string; -} - export interface UserProfilePasswordSectionViewProps { sectionTitle?: string; hasPassword?: boolean; requiresCurrentPassword?: boolean; /** Replaces the edit action with the enterprise provider’s name. */ - managedBy?: UserProfilePasswordManagedBy; + managedBy?: UserProfileManagedBy; onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index 3e1261091f2..6438506b5a0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -3,11 +3,11 @@ import { useMessages } from '../../../localization'; import { UserProfilePasswordRowView } from './user-profile-password-row.view'; import type { UserProfilePasswordSectionViewProps } from './user-profile-password-section.types'; +export type { UserProfileManagedBy } from '../user-profile-managed-by'; export type { UserProfileEditPasswordField, UserProfileEditPasswordValue, UserProfileEditPasswordValues, - UserProfilePasswordManagedBy, UserProfilePasswordSectionViewProps, } from './user-profile-password-section.types'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 0062d803e29..00f3130c54b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -15,7 +15,7 @@ import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view'; import type { UserProfileEditPasswordValue, - UserProfilePasswordManagedBy, + UserProfileManagedBy, UserProfilePasswordSectionViewProps, } from './user-profile-password-section/user-profile-password-section.view'; import { UserProfilePasswordSectionView } from './user-profile-password-section/user-profile-password-section.view'; @@ -26,8 +26,8 @@ export type { UserProfileEditPasswordValue, UserProfileMfaAddableMethod, UserProfileMfaMethod, + UserProfileManagedBy, UserProfilePasskey, - UserProfilePasswordManagedBy, }; export interface UserProfileSecurityPanelViewProps diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 663265d9514..b71e0d0488f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -315,6 +315,7 @@ import { EmailSsoVerification as UserProfileAccountSectionEmailSsoVerification, meta as userProfileAccountSectionMeta, MultipleAccounts as UserProfileAccountSectionMultipleAccounts, + NameManagedByConnection as UserProfileAccountSectionNameManagedByConnection, PhoneRemovalError as UserProfileAccountSectionPhoneRemovalError, PhoneRemovalPending as UserProfileAccountSectionPhoneRemovalPending, } from '../stories/user-profile-account-section.stories'; @@ -747,6 +748,7 @@ const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, MultipleAccounts: UserProfileAccountSectionMultipleAccounts, + NameManagedByConnection: UserProfileAccountSectionNameManagedByConnection, AddPhoneFails: UserProfileAccountSectionAddPhoneFails, AddEmailFails: UserProfileAccountSectionAddEmailFails, EmailLinkVerification: UserProfileAccountSectionEmailLinkVerification, diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index 3110e103387..ee41391ae5d 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -41,6 +41,16 @@ the verified address. ]} /> +## Name managed by a connection + +An enterprise connection owns the name, so the Name row names who manages it in place of an edit +action. The connection's logo leads the label, or a generic lock when it ships none. + + + ## Add phone failure Add a phone number to see a failed send request while keeping the entered number. 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 c4f212f6d34..3b53cdbebd0 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -40,6 +40,7 @@ function AccountSection({ failEmailVerification = false, emailRemovalState, phoneRemovalState, + nameManagedBy, }: { allowMultipleAccounts: boolean; failAt?: UserProfileAddPhoneDialogProps['step']; @@ -48,6 +49,7 @@ function AccountSection({ failEmailVerification?: boolean; emailRemovalState?: 'pending' | 'error'; phoneRemovalState?: 'pending' | 'error'; + nameManagedBy?: { name: string; iconUrl?: string }; }) { const [phoneRemovalFailed, setPhoneRemovalFailed] = useState(false); const [emailRemovalFailed, setEmailRemovalFailed] = useState(false); @@ -86,6 +88,8 @@ function AccountSection({ imageUrl={imageUrl} phones={phones} {...addPhone} + nameManagedBy={nameManagedBy} + onSubmitName={nameManagedBy ? undefined : editName.onSubmitName} onProfilePictureChange={showFile} onRemoveProfilePicture={clearImage} onManageEmail={() => undefined} @@ -120,6 +124,19 @@ export function Default() { return ; } +/** + * An enterprise connection owns the name, so the row names who manages it in place of an edit + * action. The connection's logo leads the label, or a generic lock when the IDP ships none. + */ +export function NameManagedByConnection() { + return ( + + ); +} + export function MultipleAccounts() { return ; } From 07b30e1ed2ca4d3c68bf0105cbec522f35a96b46 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 24 Sep 2026 13:07:12 -0400 Subject: [PATCH 25/31] refactor(mosaic): move the legacy save error to the password section The account section carries its failures on SaveError now, so UserProfileSaveError and UserProfileFormError live with their only remaining consumer. --- .../user-profile-password-section.view.test.tsx | 7 +++---- .../user-profile-account-section.types.ts | 12 +----------- ...ser-profile-edit-password.controller.test.ts | 2 +- .../user-profile-password-section.types.ts | 17 +++++++++++++++++ .../fixtures/user-profile-edit-password.ts | 9 ++++----- .../user-profile-password-section.stories.tsx | 9 ++++----- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx index 7754247812a..777e234ce10 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; -import { SaveError } from '../../../utils/form-error'; import type { UserProfilePasswordSectionViewProps } from '../user-profile-password-section/user-profile-password-section.types'; +import { UserProfileSaveError } from '../user-profile-password-section/user-profile-password-section.types'; import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; function renderView(props: UserProfilePasswordSectionViewProps = {}) { @@ -66,9 +66,8 @@ describe('UserProfilePasswordSectionView', () => { const onSubmitPassword = vi .fn() .mockRejectedValueOnce( - new SaveError({ - global: { message: 'Your password could not be updated.' }, - fields: { currentPassword: { message: 'Incorrect password.' } }, + new UserProfileSaveError('Your password could not be updated.', { + currentPassword: 'Incorrect password.', }), ) .mockResolvedValue(undefined); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts index ff0a40d25cd..7defa07f149 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts @@ -1,5 +1,3 @@ -import { FormSubmitError } from '../../../components/form'; - /** One name attribute as the instance configures it. Supplied from `userSettings.attributes.first_name` and `last_name`. */ export interface UserProfileNameAttribute { /** @default true */ @@ -8,14 +6,6 @@ export interface UserProfileNameAttribute { required?: boolean; } -/** What a save rejects with when the failure names a control; a plain `Error` shows only the banner. */ -export class UserProfileSaveError extends FormSubmitError> { - constructor(message: string, fields?: Partial>) { - super({ message, fields }); - this.name = 'UserProfileSaveError'; - } -} - export interface UserProfilePhone { id: string; value: string; @@ -30,4 +20,4 @@ export interface UserProfileEmail { isDefault?: boolean; isVerified?: boolean; canRemove?: boolean; -} \ No newline at end of file +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index b82f79e1d5a..7e7c21dd153 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -1,9 +1,9 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; +import { UserProfileSaveError } from './user-profile-password-section.types'; function deferred() { let resolve: () => void = () => {}; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index df72789e738..f7d795cc867 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -1,5 +1,22 @@ +import { FormSubmitError } from '../../../components/form'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; +/** Plain data, so nothing downstream of the view imports a Clerk error. */ +export interface UserProfileFormError { + /** Rendered in the dialog's negative banner. */ + message?: string; + /** Rendered under the named control, which is also marked invalid. */ + fields?: Partial>; +} + +/** What a save rejects with when the failure names a control; a plain `Error` shows only the banner. */ +export class UserProfileSaveError extends FormSubmitError> { + constructor(message: string, fields?: Partial>) { + super({ message, fields }); + this.name = 'UserProfileSaveError'; + } +} + export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; export interface UserProfileEditPasswordValues { diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index 621a0c283c7..597f2edbbb4 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -1,9 +1,8 @@ import type { - UserProfileEditPasswordField, UserProfileEditPasswordValue, + UserProfileFormError, } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; -import type { FormError } from '@clerk/mosaic/utils/form-error'; -import { SaveError } from '@clerk/mosaic/utils/form-error'; +import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { @@ -11,7 +10,7 @@ export interface UserProfileEditPasswordFixtureOptions { requiresCurrentPassword?: boolean; latency?: number; /** Rejects the first save so the next attempt can succeed. */ - failWith?: FormError; + failWith?: UserProfileFormError; } export function useUserProfileEditPasswordFixture({ @@ -30,7 +29,7 @@ export function useUserProfileEditPasswordFixture({ await new Promise(resolve => setTimeout(resolve, latency)); if (failWith && !hasFailed) { setHasFailed(true); - throw new SaveError(failWith); + throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); } setHasPassword(true); }, diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index 8b84910116f..8fd8a8c827c 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,6 +1,5 @@ -import type { UserProfileEditPasswordField } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; +import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; -import type { FormError } from '@clerk/mosaic/utils/form-error'; import type { StoryMeta } from '@/lib/types'; @@ -25,7 +24,7 @@ function PasswordSection({ }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - failWith?: FormError; + failWith?: UserProfileFormError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); @@ -75,8 +74,8 @@ export function EditPasswordFails() { return ( ); From 6dad14a31ed5694692a117bee846a0b085d70c24 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 11:16:56 -0400 Subject: [PATCH 26/31] feat(mosaic): localize save errors in useForm --- .../src/components/form/form.machine.ts | 28 +++++++++++++++++-- .../src/components/form/use-form.test.ts | 20 +++++++++++++ .../mosaic/src/components/form/use-form.ts | 4 ++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 6cca449f303..3228e696f73 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -1,5 +1,7 @@ +import type { LocalizableError } from '../../localization'; import { setup } from '../../machine/setup'; import type { TransitionResult } from '../../machine/types'; +import { SaveError } from '../../utils/form-error'; import { keysOf, mapKeys } from '../../utils/object'; import type { FieldFeedback, FormError, FormFieldErrors } from './form-submit-error'; import { FormSubmitError } from './form-submit-error'; @@ -33,6 +35,7 @@ export interface FormDeps { onSubmit: (values: TValues) => Promise; canSubmit: (values: TValues) => boolean; fallbackMessage: string; + errorText: (error: LocalizableError) => string; } export interface FormContext extends FormDeps { @@ -130,13 +133,32 @@ function displayableFields( return result; } +function savedFieldErrors( + context: FormContext, + fields: Partial> | undefined, +): FormFieldErrors { + const result: FormFieldErrors = {}; + for (const name of keysOf(context.values)) { + const error = typeof name === 'string' ? fields?.[name] : undefined; + if (error !== undefined) { + result[name] = context.errorText(error); + } + } + return result; +} + function toFormError(cause: unknown, context: FormContext): FormError { const error: FormError = cause instanceof FormSubmitError ? { message: cause.banner, fields: displayableFields(context, cause.fields) } - : cause instanceof Error - ? { message: cause.message } - : {}; + : cause instanceof SaveError + ? { + message: cause.formError.global ? context.errorText(cause.formError.global) : undefined, + fields: savedFieldErrors(context, cause.formError.fields), + } + : cause instanceof Error + ? { message: cause.message } + : {}; const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0; return visible ? error : { ...error, message: context.fallbackMessage }; } diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index b985ee5539c..653fd612c69 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { SaveError } from '../../utils/form-error'; import type { FieldFeedback } from './form-submit-error'; import { FormSubmitError } from './form-submit-error'; import { useForm } from './use-form'; @@ -101,6 +102,25 @@ describe('useForm', () => { expect(result.current.fields.username.feedback).toBeUndefined(); }); + it('localizes a SaveError onto the form message and field feedback', async () => { + const failure = new SaveError<'username'>({ + global: { code: 'unknown_code', message: 'Could not save' }, + fields: { username: { code: 'generic' } }, + }); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex' }, onSubmit: () => Promise.reject(failure) }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Could not save'); + expect(result.current.fields.username.feedback).toEqual({ + type: 'error', + message: 'Something went wrong. Please try again.', + }); + }); + it('maps a fields-only FormSubmitError onto field feedback with no form message', async () => { const failure = new FormSubmitError({ fields: { username: 'Taken', bio: 'Too long' } }); expect(failure.message).toBe('Taken Too long'); diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index cdb82fd408f..5e8e81118fe 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -1,6 +1,6 @@ import { useCallback, useId, useRef } from 'react'; -import { useMessages } from '../../localization'; +import { useErrorText, useMessages } from '../../localization'; import type { StateMachine } from '../../machine/types'; import { useMachine } from '../../machine/useMachine'; import { keysOf, mapKeys } from '../../utils/object'; @@ -84,6 +84,7 @@ const always = () => true; export function useForm(options: UseFormOptions): UseFormResult { const id = useId(); const m = useMessages('form'); + const errorText = useErrorText(); const elements = useRef(new Map()); const refs = useRef(new Map()); @@ -93,6 +94,7 @@ export function useForm(options: UseFormOptions onSubmit: options.onSubmit, canSubmit: options.canSubmit ?? always, fallbackMessage: m.error, + errorText, }; const machineRef = useRef, FormEvent> | null>(null); if (machineRef.current === null) { From 957a663dea708433c3247a4b379aa5bb09c56918 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 11:23:30 -0400 Subject: [PATCH 27/31] refactor(mosaic): drive the name form with useForm --- .../user-profile-edit-name.controller.test.ts | 183 ++++++------------ .../user-profile-edit-name.dialog.test.tsx | 137 ++++++------- .../user-profile-edit-name.controller.ts | 115 ++--------- .../user-profile-edit-name.dialog.tsx | 138 +++++++------ .../user-profile-name-row.view.tsx | 3 +- 5 files changed, 213 insertions(+), 363 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index c12e6f022a2..d746f5d7530 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -1,156 +1,93 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { createActor } from '../../../machine/createActor'; -import type { FormError } from '../../../utils/form-error'; import { SaveError } from '../../../utils/form-error'; -import { - userProfileEditNameMachine, - useUserProfileEditNameController, -} from '../user-profile-account-section/user-profile-edit-name.controller'; -import type { UserProfileEditNameField } from '../user-profile-account-section/user-profile-edit-name.dialog'; - -const saved = (): Promise => Promise.resolve(); -const failed = (error: FormError): Promise => Promise.reject(new SaveError(error)); - -function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { - const actor = createActor(userProfileEditNameMachine, { context: { saveName, ...saved } }).start(); - actor.send({ type: 'OPEN' }); - return actor; -} - -/** The save is guarded on a change, so a test about the save has to make one first. */ -function startEdited(saveName: () => Promise) { - const actor = start(saveName); - actor.send({ type: 'TYPE', field: 'firstName', value: 'Ada' }); - return actor; -} - -describe('userProfileEditNameMachine', () => { - it('seeds both fields from the saved name on open', () => { - const actor = start(saved); - - expect(actor.getSnapshot().value).toBe('editing'); - expect(actor.getSnapshot().context.firstName).toBe('Preston'); - expect(actor.getSnapshot().context.lastName).toBe('Booth'); - }); - - it('returns to idle when the save lands, and can be opened again', async () => { - const actor = start(saved); - actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); - 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'); - }); +import { useUserProfileEditNameController } from '../user-profile-account-section/user-profile-edit-name.controller'; +import type { UserProfileEditNameValue } from '../user-profile-account-section/user-profile-edit-name.dialog'; - it('re-seeds from the saved name on the next open, dropping what was typed', () => { - const actor = start(saved); - actor.send({ type: 'TYPE', field: 'firstName', value: 'Ada' }); - actor.send({ type: 'CANCEL' }); - - actor.send({ type: 'OPEN' }); - - expect(actor.getSnapshot().context.firstName).toBe('Preston'); - }); - - it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => failed({ global: { message: 'Your name could not be updated.' } })); - actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); - expect(actor.getSnapshot().context.lastName).toBe('Barton'); - expect(actor.getSnapshot().context.error).toEqual({ - global: { message: 'Your name could not be updated.' }, - }); +function deferred() { + let resolve: () => void = () => {}; + const promise = new Promise(r => { + resolve = r; }); + return { promise, resolve }; +} - it('carries field copy through when the error names a control', async () => { - const actor = startEdited(() => - failed({ - global: { message: 'Your name could not be updated.' }, - fields: { firstName: { message: 'First name is required.' } }, - }), - ); - actor.send({ type: 'SAVE' }); +function renderController(onSubmit: (value: UserProfileEditNameValue) => Promise = () => Promise.resolve()) { + const { result } = renderHook(() => + useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }), + ); + act(() => result.current.onOpenChange(true)); + return result; +} - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ firstName: { message: 'First name is required.' } }), +describe('useUserProfileEditNameController', () => { + it('opens on the saved name', () => { + const { result } = renderHook(() => + useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: vi.fn() }), ); - }); + expect(result.current.isOpen).toBe(false); - it('shows the generic banner and logs when the save throws unexpectedly', async () => { - const log = vi.spyOn(console, 'error').mockImplementation(() => {}); - const failure = new TypeError('boom'); - const actor = startEdited(() => Promise.reject(failure)); - actor.send({ type: 'SAVE' }); + act(() => result.current.onOpenChange(true)); - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); - expect(log).toHaveBeenCalledWith(failure); - log.mockRestore(); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values).toEqual({ firstName: 'Preston', lastName: 'Booth' }); }); - it('drops the error when the dialog is cancelled', async () => { - const actor = startEdited(() => failed({ global: { message: 'nope' } })); - actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); + it('withholds the save while the name is unchanged', () => { + const result = renderController(); + expect(result.current.form.canSubmit).toBe(false); - actor.send({ type: 'CANCEL' }); + act(() => result.current.form.setValue('lastName', 'Barton')); - expect(actor.getSnapshot().value).toBe('idle'); - expect(actor.getSnapshot().context.error).toBeUndefined(); + expect(result.current.form.canSubmit).toBe(true); }); -}); - -describe('useUserProfileEditNameController', () => { - it('holds the dialog open across editing and saving, then closes on success', async () => { - const { result } = renderHook(() => - useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: saved }), - ); - expect(result.current.isOpen).toBe(false); - act(() => result.current.onOpenChange(true)); - expect(result.current.isOpen).toBe(true); - expect(result.current.firstName).toBe('Preston'); - expect(result.current.isSaving).toBe(false); + it('saves the values it is holding, stays open while saving, then closes', async () => { + const request = deferred(); + const onSubmit = vi.fn(() => request.promise); + const result = renderController(onSubmit); + act(() => result.current.form.setValue('firstName', 'Ada')); - act(() => result.current.onLastNameChange('Barton')); - expect(result.current.lastName).toBe('Barton'); + act(() => result.current.form.submit()); + act(() => result.current.onOpenChange(false)); - act(() => result.current.onSubmit()); + expect(onSubmit).toHaveBeenCalledWith({ firstName: 'Ada', lastName: 'Booth' }); expect(result.current.isOpen).toBe(true); - expect(result.current.isSaving).toBe(true); - + expect(result.current.form.isSubmitting).toBe(true); + await act(async () => request.resolve()); await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - it('saves the values it is currently holding', async () => { - const onSubmit = vi.fn(saved); - const { result } = renderHook(() => - useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }), + it('keeps what was typed and shows why when the save fails', async () => { + const result = renderController(() => + Promise.reject( + new SaveError({ + global: { message: 'Your name could not be updated.' }, + fields: { lastName: { message: 'Last name is too long.' } }, + }), + ), ); + act(() => result.current.form.setValue('lastName', 'Barton')); - act(() => result.current.onOpenChange(true)); - act(() => result.current.onFirstNameChange('Ada')); - act(() => result.current.onSubmit()); + act(() => result.current.form.submit()); - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ firstName: 'Ada', lastName: 'Booth' })); + await waitFor(() => expect(result.current.form.error).toBe('Your name could not be updated.')); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values.lastName).toBe('Barton'); + expect(result.current.form.fields.lastName.feedback).toEqual({ type: 'error', message: 'Last name is too long.' }); }); - it('withholds the save while the name is unchanged', () => { - const { result } = renderHook(() => - useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: saved }), - ); + it('re-seeds from the saved name and drops the error on the next open', async () => { + const result = renderController(() => Promise.reject(new SaveError({ global: { message: 'nope' } }))); + act(() => result.current.form.setValue('firstName', 'Ada')); + act(() => result.current.form.submit()); + await waitFor(() => expect(result.current.form.error).toBe('nope')); + act(() => result.current.onOpenChange(false)); act(() => result.current.onOpenChange(true)); - expect(result.current.canSave).toBe(false); - act(() => result.current.onLastNameChange('Barton')); - expect(result.current.canSave).toBe(true); + expect(result.current.form.values.firstName).toBe('Preston'); + expect(result.current.form.error).toBeUndefined(); }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx index 7f2e8e17859..c507932a8ce 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx @@ -4,28 +4,41 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../../components/button'; import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileEditNameDialogProps } from '../user-profile-account-section/user-profile-edit-name.dialog'; +import { SaveError } from '../../../utils/form-error'; +import { useUserProfileEditNameController } from '../user-profile-account-section/user-profile-edit-name.controller'; +import type { + UserProfileEditNameDialogProps, + UserProfileEditNameValue, +} 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: UserProfileEditNameDialogProps = { +type ViewProps = Omit & { + onSubmit: (value: UserProfileEditNameValue) => Promise; +}; + +function View({ onSubmit, ...props }: ViewProps) { + const { form } = useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }); + return ( + + ); +} + +function renderView(overrides: Partial = {}) { + const props: ViewProps = { open: true, onOpenChange: vi.fn(), - firstName: 'Preston', - lastName: 'Booth', - onFirstNameChange: vi.fn(), - onLastNameChange: vi.fn(), - onSubmit: vi.fn(), + onSubmit: vi.fn(() => Promise.resolve()), ...overrides, }; - return { - props, - ...render( - - - , - ), - }; + render( + + + , + ); + return props; } const firstNameField = () => screen.getByLabelText('First name'); @@ -39,7 +52,7 @@ describe('UserProfileEditNameDialog', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('names the dialog and shows the values it was given', () => { + it('names the dialog and shows the saved name', () => { renderView(); expect(screen.getByRole('dialog', { name: 'Edit name' })).toBeInTheDocument(); @@ -50,67 +63,45 @@ describe('UserProfileEditNameDialog', () => { it('opens on the first name rather than the corner dismiss', async () => { renderView(); - // `FloatingFocusManager` moves focus in an effect, hence the wait. await waitFor(() => expect(firstNameField()).toHaveFocus()); }); it('asks to open from the trigger', async () => { - const onOpenChange = vi.fn(); const user = userEvent.setup(); - renderView({ open: false, onOpenChange, trigger: }); + const props = renderView({ open: false, trigger: }); await user.click(screen.getByRole('button', { name: 'Edit name' })); - expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); - }); - - it('reports each keystroke to its own field, holding nothing itself', async () => { - const onFirstNameChange = vi.fn(); - const onLastNameChange = vi.fn(); - const user = userEvent.setup(); - renderView({ onFirstNameChange, onLastNameChange }); - - await user.type(firstNameField(), 'x'); - await user.type(lastNameField(), 'y'); - - expect(onFirstNameChange).toHaveBeenCalledWith('Prestonx'); - expect(onLastNameChange).toHaveBeenCalledWith('Boothy'); - // Controlled: the rendered value only moves when the caller says so. - expect(firstNameField()).toHaveValue('Preston'); + expect(props.onOpenChange).toHaveBeenCalledWith(true, expect.anything()); }); - it('submits from the action, without validating', async () => { - const onSubmit = vi.fn(); + it('withholds the save until the name changes, then submits what was typed', async () => { const user = userEvent.setup(); - renderView({ firstName: '', onSubmit }); + const props = renderView(); + expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); + await user.type(lastNameField(), 'x'); await user.click(saveButton()); - expect(onSubmit).toHaveBeenCalledTimes(1); + expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith({ firstName: 'Preston', lastName: 'Boothx' }); }); - it('renders both fields optional and enabled unless told otherwise', () => { + it('renders both fields optional unless told otherwise', () => { renderView(); expect(firstNameField()).not.toBeRequired(); expect(lastNameField()).not.toBeRequired(); }); - it('marks a field required from its attribute', () => { - renderView({ firstNameAttribute: { required: true } }); - - expect(firstNameField()).toBeRequired(); - expect(lastNameField()).not.toBeRequired(); - }); - it('holds the submit while a required field is empty', async () => { - const onSubmit = vi.fn(); const user = userEvent.setup(); - renderView({ firstName: '', firstNameAttribute: { required: true }, onSubmit }); + const props = renderView({ firstNameAttribute: { required: true } }); + expect(firstNameField()).toBeRequired(); + await user.clear(firstNameField()); await user.click(saveButton()); - expect(onSubmit).not.toHaveBeenCalled(); + expect(props.onSubmit).not.toHaveBeenCalled(); }); it('drops a field the instance has disabled, and opens on the one that remains', async () => { @@ -121,51 +112,47 @@ describe('UserProfileEditNameDialog', () => { }); it('asks to close from cancel', async () => { - const onOpenChange = vi.fn(); const user = userEvent.setup(); - renderView({ onOpenChange }); + const props = renderView(); await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); }); - it('announces the failure in a negative banner and marks the blamed field invalid', () => { + it('announces the failure in a negative banner and marks the blamed field invalid', async () => { + const user = userEvent.setup(); renderView({ - error: { - global: { message: 'Your name could not be updated.' }, - fields: { lastName: { message: 'Last name must be 64 characters or fewer.' } }, - }, + onSubmit: () => + Promise.reject( + new SaveError({ + global: { message: 'Your name could not be updated.' }, + fields: { lastName: { message: 'Last name must be 64 characters or fewer.' } }, + }), + ), }); - const banner = screen.getByRole('alert'); + await user.type(lastNameField(), 'x'); + await user.click(saveButton()); + + const banner = await screen.findByRole('alert'); expect(banner).toHaveAttribute('data-color', 'negative'); expect(banner).toHaveTextContent('Your name could not be updated.'); - expect(screen.getByText('Last name must be 64 characters or fewer.')).toBeInTheDocument(); + expect(lastNameField()).toHaveAccessibleDescription('Last name must be 64 characters or fewer.'); expect(lastNameField()).toHaveAttribute('aria-invalid', 'true'); expect(firstNameField()).not.toHaveAttribute('aria-invalid', 'true'); }); - it('renders a field-scoped failure with no banner', () => { - renderView({ error: { fields: { firstName: { message: 'First name is required.' } } } }); - - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(firstNameField()).toHaveAttribute('aria-invalid', 'true'); - }); - it('stays inert while the save runs', async () => { - const onSubmit = vi.fn(); - const onFirstNameChange = vi.fn(); const user = userEvent.setup(); - renderView({ isSaving: true, onSubmit, onFirstNameChange }); + const props = renderView({ onSubmit: vi.fn(() => new Promise(() => {})) }); - await user.type(firstNameField(), 'Ada'); + await user.type(firstNameField(), 'x'); + await user.click(saveButton()); expect(firstNameField()).toBeDisabled(); - expect(onFirstNameChange).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(); + expect(props.onSubmit).toHaveBeenCalledOnce(); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts index 6d798e464db..6f52edda5f3 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts @@ -1,77 +1,8 @@ -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/useMachine'; -import type { FormError } from '../../../utils/form-error'; -import { toFormError } from '../../../utils/form-error'; -import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import { useState } from 'react'; -export interface UserProfileEditNameContext { - saveName: (value: UserProfileEditNameValue) => Promise; - /** Injected every render. What `OPEN` seeds the fields from. */ - savedFirstName: string; - savedLastName: string; - firstName: string; - lastName: string; - error: FormError | undefined; -} - -export type UserProfileEditNameEvent = - | { type: 'OPEN' } - | { type: 'TYPE'; field: UserProfileEditNameField; value: string } - | { type: 'SAVE' } - | { type: 'CANCEL' }; - -const { createMachine, assign, fromPromise } = setup(); - -function notSeated(): Promise { - return Promise.reject(new Error('edit-name deps are not seated')); -} - -function isSaveable(context: UserProfileEditNameContext): boolean { - return context.firstName !== context.savedFirstName || context.lastName !== context.savedLastName; -} - -export const userProfileEditNameMachine = createMachine({ - id: 'editName', - initial: 'idle', - context: { - saveName: notSeated, - savedFirstName: '', - savedLastName: '', - firstName: '', - lastName: '', - error: undefined, - }, - states: { - idle: { - on: { - OPEN: { - target: 'editing', - actions: assign(context => ({ - firstName: context.savedFirstName, - lastName: context.savedLastName, - error: undefined, - })), - }, - }, - }, - editing: { - on: { - TYPE: { actions: assign((_, event) => ({ [event.field]: event.value })) }, - SAVE: { target: 'saving', guard: isSaveable }, - CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, - }, - }, - saving: { - invoke: fromPromise(context => context.saveName({ firstName: context.firstName, lastName: context.lastName }), { - onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, - onError: { - target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), - }, - }), - }, - }, -}); +import type { UseFormResult } from '../../../components/form'; +import { useForm } from '../../../components/form'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameControllerOptions { firstName?: string; @@ -83,14 +14,7 @@ export interface UserProfileEditNameControllerOptions { export interface UserProfileEditNameController { isOpen: boolean; onOpenChange: (open: boolean) => void; - firstName: string; - lastName: string; - onFirstNameChange: (value: string) => void; - onLastNameChange: (value: string) => void; - onSubmit: () => void; - canSave: boolean; - isSaving: boolean; - error: FormError | undefined; + form: UseFormResult; } export function useUserProfileEditNameController({ @@ -98,20 +22,23 @@ export function useUserProfileEditNameController({ lastName = '', onSubmit, }: UserProfileEditNameControllerOptions): UserProfileEditNameController { - const [snapshot, send] = useMachine(userProfileEditNameMachine, { - context: { saveName: onSubmit, savedFirstName: firstName, savedLastName: lastName }, + const [isOpen, setIsOpen] = useState(false); + const form = useForm({ + initialValues: { firstName, lastName }, + canSubmit: values => values.firstName !== firstName || values.lastName !== lastName, + onSubmit: async values => { + await onSubmit(values); + setIsOpen(false); + }, }); - return { - isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', - onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), - firstName: snapshot.context.firstName, - lastName: snapshot.context.lastName, - onFirstNameChange: value => send({ type: 'TYPE', field: 'firstName', value }), - onLastNameChange: value => send({ type: 'TYPE', field: 'lastName', value }), - onSubmit: () => send({ type: 'SAVE' }), - canSave: isSaveable(snapshot.context), - isSaving: snapshot.value === 'saving', - error: snapshot.context.error, + const onOpenChange = (open: boolean) => { + if (!open && form.isSubmitting) { + return; + } + form.reset(); + setIsOpen(open); }; + + return { isOpen, onOpenChange, form }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx index 6b65255090b..391d5eb345f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx @@ -1,5 +1,6 @@ -import type { FormEvent } from 'react'; -import { useId, useRef } from 'react'; +import { useMergeRefs } from '@floating-ui/react'; +import type { RefObject } from 'react'; +import { useRef } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -7,9 +8,9 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; +import type { UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; -import { useErrorText, useMessages } from '../../../localization'; -import type { FormError } from '../../../utils/form-error'; +import { useMessages } from '../../../localization'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; @@ -24,56 +25,32 @@ export interface UserProfileEditNameDialogProps { onOpenChange: (open: boolean) => void; /** Rendering the opener here is what returns focus to it on close. */ trigger?: DialogTriggerProps['render']; - firstName: string; - lastName: string; /** A disabled attribute drops its field; a required one blocks the submit while empty. */ firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onFirstNameChange: (value: string) => void; - onLastNameChange: (value: string) => void; title?: string; - canSave?: boolean; - isSaving?: boolean; - error?: FormError; - onSubmit: () => void; + form: UseFormResult; } /** - * Edits the user's first and last name. Holds nothing, and validates nothing beyond the native - * `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`. + * Edits the user's first and last name. Validates nothing beyond the native `required` the + * instance asks for: the name the API will take is the API's to decide, so a rejection comes back + * through the form. */ export function UserProfileEditNameDialog({ open, onOpenChange, trigger, - firstName, - lastName, firstNameAttribute = {}, lastNameAttribute = {}, - onFirstNameChange, - onLastNameChange, title, - canSave = true, - isSaving = false, - error, - onSubmit, + form, }: UserProfileEditNameDialogProps) { const m = useMessages('userProfileAccountSection'); - const errorText = useErrorText(); - const formId = useId(); const initialFocusRef = useRef(null); const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; const { enabled: showLastName = true, required: lastNameRequired = false } = lastNameAttribute; - // `canSave`/`isSaving` only cancel the press on the action; they do not stop a native submit. - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - if (canSave && !isSaving) { - onSubmit(); - } - }; - return ( } > - {error?.global ? ( + {form.error ? ( - {errorText(error.global)} + {form.error} ) : null} {showFirstName ? ( - - {m.name.firstNameLabel} - onFirstNameChange(event.target.value)} - /> - - {error?.fields?.firstName ? errorText(error.fields.firstName) : null} - - + /> ) : null} {showLastName ? ( - - {m.name.lastNameLabel} - onLastNameChange(event.target.value)} - /> - - {error?.fields?.lastName ? errorText(error.fields.lastName) : null} - - + /> ) : null}
@@ -157,10 +118,10 @@ export function UserProfileEditNameDialog({ } /> {m.name.save} @@ -171,3 +132,40 @@ export function UserProfileEditNameDialog({ ); } + +function NameField({ + label, + autoComplete, + form, + inputRef, + name, + required, +}: { + label: string; + autoComplete: 'given-name' | 'family-name'; + form: UseFormResult; + inputRef?: RefObject; + name: UserProfileEditNameField; + required: boolean; +}) { + const { feedback } = form.fields[name]; + const error = feedback?.type === 'error' ? feedback.message : undefined; + const { ref, ...control } = form.register(name); + const mergedRef = useMergeRefs([ref, inputRef]); + + return ( + + {label} + + {error ? {error} : null} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx index f734d91c7e3..66e90712c9a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx @@ -77,10 +77,11 @@ function EditName({ return ( Date: Fri, 25 Sep 2026 11:23:31 -0400 Subject: [PATCH 28/31] refactor(mosaic): drive the username form with useForm --- ...r-profile-edit-username.controller.test.ts | 202 +++++------------- ...user-profile-edit-username.dialog.test.tsx | 132 ++++++------ .../user-profile-edit-username.controller.ts | 102 ++------- .../user-profile-edit-username.dialog.tsx | 68 +++--- .../user-profile-username-row.view.tsx | 3 +- 5 files changed, 175 insertions(+), 332 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index bd9d03dd7ad..018df00b0ae 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -1,180 +1,90 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { createActor } from '../../../machine/createActor'; -import type { FormError } from '../../../utils/form-error'; import { SaveError } from '../../../utils/form-error'; -import { - userProfileEditUsernameMachine, - useUserProfileEditUsernameController, -} from '../user-profile-account-section/user-profile-edit-username.controller'; -import type { UserProfileEditUsernameField } from '../user-profile-account-section/user-profile-edit-username.dialog'; - -const saved = (): Promise => Promise.resolve(); -const failed = (error: FormError): Promise => Promise.reject(new SaveError(error)); - -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(saved); - - 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(saved); - 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(saved); - 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(() => failed({ global: { message: '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({ global: { message: 'That username is taken.' } }); - }); - - it('carries field copy through when the error names the control', async () => { - const actor = start(() => - failed({ - global: { message: 'Your username could not be updated.' }, - fields: { username: { message: 'That username is taken.' } }, - }), - ); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ username: { message: 'That username is taken.' } }), - ); - }); - - it('shows the generic banner and logs when the save throws unexpectedly', async () => { - const log = vi.spyOn(console, 'error').mockImplementation(() => {}); - const failure = new TypeError('boom'); - const actor = start(() => Promise.reject(failure)); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); - expect(log).toHaveBeenCalledWith(failure); - log.mockRestore(); - }); - - it('refuses to save a value that has not moved', () => { - const saveUsername = vi.fn(saved); - const actor = start(saveUsername); +import { useUserProfileEditUsernameController } from '../user-profile-account-section/user-profile-edit-username.controller'; - 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(); +function deferred() { + let resolve: () => void = () => {}; + const promise = new Promise(r => { + resolve = r; }); + return { promise, resolve }; +} - it('refuses to save an empty value, since clearing a username is not on offer', () => { - const saveUsername = vi.fn(saved); - 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(() => failed({ global: { message: 'nope' } })); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); - - actor.send({ type: 'CANCEL' }); - - expect(actor.getSnapshot().value).toBe('idle'); - expect(actor.getSnapshot().context.error).toBeUndefined(); - }); -}); +function renderController(onSubmit: (username: string) => Promise = () => Promise.resolve()) { + const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); + act(() => result.current.onOpenChange(true)); + return result; +} describe('useUserProfileEditUsernameController', () => { - it('holds the dialog open across editing and saving, then closes on success', async () => { + it('opens on the saved username', () => { const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: vi.fn() }), ); 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); + expect(result.current.form.values.username).toBe('prestonxyz'); + }); - act(() => result.current.onUsernameChange('preston')); - expect(result.current.username).toBe('preston'); + it('withholds the save until the value moves, and on an empty value', () => { + const result = renderController(); + expect(result.current.form.canSubmit).toBe(false); - act(() => result.current.onSubmit()); - expect(result.current.isOpen).toBe(true); - expect(result.current.isSaving).toBe(true); + act(() => result.current.form.setValue('username', '')); + expect(result.current.form.canSubmit).toBe(false); - await waitFor(() => expect(result.current.isOpen).toBe(false)); + act(() => result.current.form.setValue('username', 'ada')); + expect(result.current.form.canSubmit).toBe(true); }); - it('saves the value it is currently holding', async () => { - const onSubmit = vi.fn(saved); - const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); + it('saves the value it is holding, stays open while saving, then closes', async () => { + const request = deferred(); + const onSubmit = vi.fn(() => request.promise); + const result = renderController(onSubmit); + act(() => result.current.form.setValue('username', 'ada')); - act(() => result.current.onOpenChange(true)); - act(() => result.current.onUsernameChange('ada')); - act(() => result.current.onSubmit()); + act(() => result.current.form.submit()); + act(() => result.current.onOpenChange(false)); - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ada')); + expect(onSubmit).toHaveBeenCalledWith('ada'); + expect(result.current.isOpen).toBe(true); + await act(async () => request.resolve()); + await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - it('withholds the save until the value moves', () => { - const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), + it('keeps what was typed and shows why when the save fails', async () => { + const result = renderController(() => + Promise.reject(new SaveError({ fields: { username: { message: 'That username is taken.' } } })), ); + act(() => result.current.form.setValue('username', 'ada')); - act(() => result.current.onOpenChange(true)); - expect(result.current.canSave).toBe(false); + act(() => result.current.form.submit()); - act(() => result.current.onUsernameChange('ada')); - expect(result.current.canSave).toBe(true); + await waitFor(() => + expect(result.current.form.fields.username.feedback).toEqual({ + type: 'error', + message: 'That username is taken.', + }), + ); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values.username).toBe('ada'); }); - it('withholds the save on an empty value', () => { - const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), - ); + it('re-seeds from the saved username and drops the error on the next open', async () => { + const result = renderController(() => Promise.reject(new SaveError({ global: { message: 'nope' } }))); + act(() => result.current.form.setValue('username', 'ada')); + act(() => result.current.form.submit()); + await waitFor(() => expect(result.current.form.error).toBe('nope')); + act(() => result.current.onOpenChange(false)); act(() => result.current.onOpenChange(true)); - act(() => result.current.onUsernameChange('')); - expect(result.current.canSave).toBe(false); + expect(result.current.form.values.username).toBe('prestonxyz'); + expect(result.current.form.error).toBeUndefined(); }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx index 8acd861593a..7b0d78bc041 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx @@ -5,26 +5,38 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../../components/button'; import type { MosaicLocalization } from '../../../localization'; import { MosaicProvider } from '../../../MosaicProvider'; +import { SaveError } from '../../../utils/form-error'; +import { useUserProfileEditUsernameController } from '../user-profile-account-section/user-profile-edit-username.controller'; 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 = {}, localization?: MosaicLocalization) { - const props: UserProfileEditUsernameDialogProps = { +type ViewProps = Omit & { + onSubmit: (username: string) => Promise; +}; + +function View({ onSubmit, ...props }: ViewProps) { + const { form } = useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit }); + return ( + + ); +} + +function renderView(overrides: Partial = {}, localization?: MosaicLocalization) { + const props: ViewProps = { open: true, onOpenChange: vi.fn(), - username: 'prestonxyz', - onUsernameChange: vi.fn(), - onSubmit: vi.fn(), + onSubmit: vi.fn(() => Promise.resolve()), ...overrides, }; - return { - props, - ...render( - - - , - ), - }; + render( + + + , + ); + return props; } const usernameField = () => screen.getByLabelText('Username'); @@ -37,7 +49,7 @@ describe('UserProfileEditUsernameDialog', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('names the dialog and shows the value it was given', () => { + it('names the dialog and shows the saved username', () => { renderView(); expect(screen.getByRole('dialog', { name: 'Edit username' })).toBeInTheDocument(); @@ -47,106 +59,98 @@ describe('UserProfileEditUsernameDialog', () => { 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: }); + const props = renderView({ open: false, trigger: }); await user.click(screen.getByRole('button', { name: 'Edit username' })); - expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); + expect(props.onOpenChange).toHaveBeenCalledWith(true, expect.anything()); }); - it('reports each keystroke, holding nothing itself', async () => { - const onUsernameChange = vi.fn(); + it('withholds the save until the value moves, keeping the action reachable', async () => { const user = userEvent.setup(); - renderView({ onUsernameChange }); + const props = renderView(); - await user.type(usernameField(), 'x'); + expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); + await user.click(saveButton()); + await user.type(usernameField(), '{Enter}'); - expect(onUsernameChange).toHaveBeenCalledWith('prestonxyzx'); - // Controlled: the rendered value only moves when the caller says so. - expect(usernameField()).toHaveValue('prestonxyz'); + expect(props.onSubmit).not.toHaveBeenCalled(); }); it('submits from the action and from enter in the field', async () => { - const onSubmit = vi.fn(); const user = userEvent.setup(); - renderView({ onSubmit }); + const props = renderView({ onSubmit: vi.fn(() => Promise.reject(new SaveError({ global: { message: 'no' } }))) }); + await user.type(usernameField(), 'x'); await user.click(saveButton()); - // One field, so native implicit submission carries Enter with no submit button in the form. + await screen.findByRole('alert'); await user.type(usernameField(), '{Enter}'); - expect(onSubmit).toHaveBeenCalledTimes(2); + await waitFor(() => expect(props.onSubmit).toHaveBeenCalledTimes(2)); + expect(props.onSubmit).toHaveBeenCalledWith('prestonxyzx'); }); it('asks to close from cancel', async () => { - const onOpenChange = vi.fn(); const user = userEvent.setup(); - renderView({ onOpenChange }); + const props = renderView(); await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); }); - it('announces the failure in a negative banner', () => { - renderView({ error: { global: { message: 'Your username could not be updated.' } } }); + it('announces a general failure in a negative banner', async () => { + const user = userEvent.setup(); + renderView({ + onSubmit: () => Promise.reject(new SaveError({ global: { message: 'Your username could not be updated.' } })), + }); - const banner = screen.getByRole('alert'); + await user.type(usernameField(), 'x'); + await user.click(saveButton()); + + const banner = await screen.findByRole('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: { message: '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('shows the translation for the error code over the message Clerk sent', () => { + it('shows the translation for the error code on the field, with no banner', async () => { + const user = userEvent.setup(); renderView( - { error: { fields: { username: { code: 'form_identifier_exists', paramName: 'username', message: 'Taken.' } } } }, + { + onSubmit: () => + Promise.reject( + new SaveError({ + fields: { username: { code: 'form_identifier_exists', paramName: 'username', message: 'Taken.' } }, + }), + ), + }, { overrides: { 'errors.form_identifier_exists__username': 'Ese nombre de usuario ya existe.' } }, ); - expect(usernameField()).toHaveAccessibleDescription('Ese nombre de usuario ya existe.'); - }); - - 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.type(usernameField(), 'x'); await user.click(saveButton()); - await user.type(usernameField(), '{Enter}'); - expect(onSubmit).not.toHaveBeenCalled(); + await waitFor(() => expect(usernameField()).toHaveAccessibleDescription('Ese nombre de usuario ya existe.')); + expect(usernameField()).toHaveAttribute('aria-invalid', 'true'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); 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 }); + const props = renderView({ onSubmit: vi.fn(() => new Promise(() => {})) }); - await user.type(usernameField(), 'ada'); + await user.type(usernameField(), 'x'); + await user.click(saveButton()); 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(); + expect(props.onSubmit).toHaveBeenCalledOnce(); }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index e4dbe813c22..c272a103d7e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -1,68 +1,8 @@ -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/useMachine'; -import type { FormError } from '../../../utils/form-error'; -import { toFormError } from '../../../utils/form-error'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; +import { useState } from 'react'; -export interface UserProfileEditUsernameContext { - saveUsername: (username: string) => Promise; - savedUsername: string; - username: string; - error: FormError | 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 !== ''; -} - -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) })), - }, - }), - }, - }, -}); +import type { UseFormResult } from '../../../components/form'; +import { useForm } from '../../../components/form'; +import type { UserProfileEditUsernameValue } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameControllerOptions { username?: string; @@ -72,30 +12,30 @@ export interface UserProfileEditUsernameControllerOptions { export interface UserProfileEditUsernameController { isOpen: boolean; onOpenChange: (open: boolean) => void; - username: string; - onUsernameChange: (value: string) => void; - onSubmit: () => void; - canSave: boolean; - isSaving: boolean; - error: FormError | undefined; + form: UseFormResult; } export function useUserProfileEditUsernameController({ username = '', onSubmit, }: UserProfileEditUsernameControllerOptions): UserProfileEditUsernameController { - const [snapshot, send] = useMachine(userProfileEditUsernameMachine, { - context: { saveUsername: onSubmit, savedUsername: username }, + const [isOpen, setIsOpen] = useState(false); + const form = useForm({ + initialValues: { username }, + canSubmit: values => values.username !== username && values.username !== '', + onSubmit: async values => { + await onSubmit(values.username); + setIsOpen(false); + }, }); - 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, + const onOpenChange = (open: boolean) => { + if (!open && form.isSubmitting) { + return; + } + form.reset(); + setIsOpen(open); }; + + return { isOpen, onOpenChange, form }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx index b57855a91a5..a7cb68a8f9f 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx @@ -1,5 +1,5 @@ -import type { FormEvent } from 'react'; -import { useId, useRef } from 'react'; +import { useMergeRefs } from '@floating-ui/react'; +import { useRef } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -7,48 +7,37 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; +import type { UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; -import { useErrorText, useMessages } from '../../../localization'; -import type { FormError } from '../../../utils/form-error'; +import { useMessages } from '../../../localization'; export type UserProfileEditUsernameField = 'username'; +export interface UserProfileEditUsernameValue { + username: string; +} + export interface UserProfileEditUsernameDialogProps { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; - username: string; - onUsernameChange: (value: string) => void; title?: string; - canSave?: boolean; - isSaving?: boolean; - error?: FormError; - onSubmit: () => void; + form: UseFormResult; } export function UserProfileEditUsernameDialog({ open, onOpenChange, trigger, - username, - onUsernameChange, title, - canSave = true, - isSaving = false, - error, - onSubmit, + form, }: UserProfileEditUsernameDialogProps) { const m = useMessages('userProfileAccountSection'); - const errorText = useErrorText(); - const formId = useId(); const usernameRef = useRef(null); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - if (canSave && !isSaving) { - onSubmit(); - } - }; + const { feedback } = form.fields.username; + const error = feedback?.type === 'error' ? feedback.message : undefined; + const { ref, ...control } = form.register('username'); + const mergedRef = useMergeRefs([ref, usernameRef]); return ( } > - {error?.global ? ( + {form.error ? ( - {errorText(error.global)} + {form.error} ) : null} - + {m.username.fieldLabel} onUsernameChange(event.target.value)} + {...control} /> - - {error?.fields?.username ? errorText(error.fields.username) : null} - + {error ? {error} : null} @@ -111,10 +99,10 @@ export function UserProfileEditUsernameDialog({ } /> {m.username.save} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx index 4031945d4f7..ef3decc5cab 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -38,8 +38,9 @@ function EditUsername({ username, onSubmit }: { username: string; onSubmit: (use return ( Date: Fri, 25 Sep 2026 11:28:08 -0400 Subject: [PATCH 29/31] fix(mosaic): show the generic message and log unrecognized save errors in useForm --- .../src/components/form/form.machine.ts | 16 +++--- .../src/components/form/use-form.test.ts | 52 +++++++++---------- ...r-profile-edit-password.controller.test.ts | 2 +- .../user-profile-password-section.types.ts | 2 +- packages/swingset/src/stories/use-form.mdx | 2 +- 5 files changed, 35 insertions(+), 39 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 3228e696f73..c90577f3d88 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -148,17 +148,17 @@ function savedFieldErrors( } function toFormError(cause: unknown, context: FormContext): FormError { + if (!(cause instanceof FormSubmitError) && !(cause instanceof SaveError)) { + console.error(cause); + return { message: context.fallbackMessage }; + } const error: FormError = cause instanceof FormSubmitError ? { message: cause.banner, fields: displayableFields(context, cause.fields) } - : cause instanceof SaveError - ? { - message: cause.formError.global ? context.errorText(cause.formError.global) : undefined, - fields: savedFieldErrors(context, cause.formError.fields), - } - : cause instanceof Error - ? { message: cause.message } - : {}; + : { + message: cause.formError.global ? context.errorText(cause.formError.global) : undefined, + fields: savedFieldErrors(context, cause.formError.fields), + }; const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0; return visible ? error : { ...error, message: context.fallbackMessage }; } diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index 653fd612c69..448fafd7248 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -136,36 +136,32 @@ describe('useForm', () => { expect(result.current.fields.bio.feedback).toEqual({ type: 'error', message: 'Too long' }); }); - it('shows only the message for a plain Error and a generic message otherwise', async () => { - const plain = renderHook(() => - useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error('Nope')) }), - ); - await act(async () => { - plain.result.current.submit(); - await flush(); - }); - expect(plain.result.current.error).toBe('Nope'); - - const cause: unknown = 'boom'; - const unknown = renderHook(() => - useForm({ - initialValues: { username: '' }, - onSubmit: async () => { - await Promise.resolve(); - throw cause; - }, - }), - ); - await act(async () => { - unknown.result.current.submit(); - await flush(); - }); - expect(unknown.result.current.error).toBe('Something went wrong. Please try again.'); + it('shows the generic message and logs the cause for an unrecognized rejection', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const causes: unknown[] = [new Error('Nope'), 'boom']; + for (const cause of causes) { + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: async () => { + await Promise.resolve(); + throw cause; + }, + }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Something went wrong. Please try again.'); + expect(log).toHaveBeenCalledWith(cause); + } + log.mockRestore(); }); it('falls back to the generic message when a submit error has nothing to show', async () => { const empty = renderHook(() => - useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error()) }), + useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new SaveError({})) }), ); await act(async () => { empty.result.current.submit(); @@ -201,7 +197,7 @@ describe('useForm', () => { useForm({ initialValues: { username: '' }, onSubmit: () => { - throw new Error('Nope'); + throw new FormSubmitError({ message: 'Nope' }); }, }), ); @@ -229,7 +225,7 @@ describe('useForm', () => { const { result } = renderHook(() => useForm({ initialValues: { username: '' }, - onSubmit: () => (fail ? Promise.reject(new Error('Nope')) : Promise.resolve()), + onSubmit: () => (fail ? Promise.reject(new FormSubmitError({ message: 'Nope' })) : Promise.resolve()), }), ); await act(async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 7e7c21dd153..d31856c2502 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -198,7 +198,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('keeps a failed save visible next to a fresh mismatch', async () => { - const { result } = renderController(() => Promise.reject(new Error('Incorrect password.'))); + const { result } = renderController(() => Promise.reject(new UserProfileSaveError('Incorrect password.'))); open(result); fill(result); act(() => result.current.form.submit()); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index f7d795cc867..ca85eaf3ba7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -9,7 +9,7 @@ export interface UserProfileFormError { fields?: Partial>; } -/** What a save rejects with when the failure names a control; a plain `Error` shows only the banner. */ +/** What a save rejects with to show its message and name the failing controls; anything else shows the generic error. */ export class UserProfileSaveError extends FormSubmitError> { constructor(message: string, fields?: Partial>) { super({ message, fields }); diff --git a/packages/swingset/src/stories/use-form.mdx b/packages/swingset/src/stories/use-form.mdx index 8ff77b8bd2e..de6623a515b 100644 --- a/packages/swingset/src/stories/use-form.mdx +++ b/packages/swingset/src/stories/use-form.mdx @@ -79,7 +79,7 @@ const mergedRef = useMergeRefs([ref, initialFocusRef]); ; ``` -A model reports a failed save by rejecting `onSubmit` with `FormSubmitError`. `message` lands on `form.error` and each `fields` entry on that field's feedback. Either part may be omitted: fields alone show only under the fields with no banner. A plain `Error` shows only its message, and anything else shows the localized generic message. +A model reports a failed save by rejecting `onSubmit` with `FormSubmitError`. `message` lands on `form.error` and each `fields` entry on that field's feedback. Either part may be omitted: fields alone show only under the fields with no banner. Anything else shows the localized generic message and is logged with `console.error`. ```ts throw new FormSubmitError({ From 76680cafcf9282029aa2abf28dcac851e5bf441d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 12:08:49 -0400 Subject: [PATCH 30/31] fix(mosaic): scope account-section saves to the user they were composed for The section's dialogs hold a draft across renders, so a user switch left Alice's typed name submitting against Bob. Key the view on the user id so the draft is discarded, and re-read the active user at invocation time so a save that races the switch is refused rather than misapplied. Also pass the instance's username requiredness through, so an optional username can be cleared, and stop the profile panel from substituting empty defaults for the attributes the section gates its rows on. --- ...ofile-account-section.integration.test.tsx | 5 ++ ...ser-profile-account-section.model.test.tsx | 47 +++++++++++++++ ...r-profile-edit-username.controller.test.ts | 25 ++++++-- .../user-profile-profile-panel.view.test.tsx | 15 +++++ .../user-profile-account-section.model.ts | 41 ++++++++++--- .../user-profile-account-section.tsx | 9 ++- .../user-profile-account-section.view.tsx | 3 + .../user-profile-edit-username.controller.ts | 4 +- .../user-profile-username-row.view.tsx | 16 ++++- .../user-profile-profile-panel.view.tsx | 58 +------------------ 10 files changed, 149 insertions(+), 74 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index e26e926d863..4b862eed41e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -8,6 +8,7 @@ import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; let user: { + id: string; firstName: string; lastName: string; username: string; @@ -28,6 +29,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: true, user }), useClerk: () => ({ + get user() { + return user; + }, __internal_environment: { userSettings: { attributes: { @@ -52,6 +56,7 @@ function renderSection() { beforeEach(() => { user = { + id: 'user_1', firstName: 'Preston', lastName: 'Booth', username: 'prestonxyz', diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 233ffb158e1..5485c1364b7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -17,6 +17,7 @@ interface FakeAttribute { let isUserLoaded: boolean; let user: { + id: string; firstName: string | null; lastName: string | null; username: string | null; @@ -34,6 +35,7 @@ let user: { setProfileImage: ReturnType; update: ReturnType; } | null; +let activeUser: typeof user; let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>; let usernameSettings: { min_length: number; max_length: number }; let environmentHydrated: boolean; @@ -48,6 +50,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), useClerk: () => ({ + get user() { + return activeUser; + }, __internal_environment: environmentHydrated ? { userSettings: { attributes, usernameSettings } } : null, }), }; @@ -100,6 +105,7 @@ beforeEach(() => { phone_number: attribute(), }; user = { + id: 'user_1', firstName: 'Preston', lastName: 'Booth', username: 'prestonxyz', @@ -116,6 +122,7 @@ beforeEach(() => { setProfileImage: vi.fn(() => Promise.resolve({})), update: vi.fn(() => Promise.resolve(user)), }; + activeUser = user; }); describe('useUserProfileAccountSectionModel', () => { @@ -133,6 +140,39 @@ describe('useUserProfileAccountSectionModel', () => { expect(renderModel()).toEqual({ status: 'hidden' }); }); + describe('the user it was rendered for', () => { + const saves: [string, (model: ReturnType) => Promise | undefined][] = [ + ['the picture upload', model => model.onProfilePictureChange?.(new File(['x'], 'me.png', { type: 'image/png' }))], + ['the picture removal', model => model.onRemoveProfilePicture?.()], + ['the name', model => model.onSubmitName?.({ firstName: 'Pres', lastName: 'B' })], + ['the username', model => model.onSubmitUsername?.('ada')], + ]; + + it('is named, so the surface holding a draft can be scoped to it', () => { + expect(ready().userId).toBe('user_1'); + }); + + it.each(saves)('refuses %s once someone else is active', async (_name, runSave) => { + const model = ready(); + activeUser = user && { ...user, id: 'user_2', update: vi.fn(), setProfileImage: vi.fn() }; + + await expect(rejection(runSave(model))).resolves.toEqual({ global: { code: 'generic' } }); + expect(user?.update).not.toHaveBeenCalled(); + expect(user?.setProfileImage).not.toHaveBeenCalled(); + expect(activeUser?.update).not.toHaveBeenCalled(); + expect(activeUser?.setProfileImage).not.toHaveBeenCalled(); + }); + + it.each(saves)('refuses %s once nobody is signed in', async (_name, runSave) => { + const model = ready(); + activeUser = null; + + await expect(rejection(runSave(model))).resolves.toEqual({ global: { code: 'generic' } }); + expect(user?.update).not.toHaveBeenCalled(); + expect(user?.setProfileImage).not.toHaveBeenCalled(); + }); + }); + it('maps the user to plain row data', () => { const model = ready(); @@ -264,6 +304,13 @@ describe('useUserProfileAccountSectionModel', () => { }); }); + it('passes on whether the instance requires one, so an optional username can be cleared', () => { + expect(ready().usernameRequired).toBe(false); + + attributes.username = attribute({ required: true }); + expect(ready().usernameRequired).toBe(true); + }); + it('is hidden when the instance does not use usernames', () => { attributes.username = attribute({ enabled: false }); expect(ready().username).toBeUndefined(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 018df00b0ae..4f5b7f27fd0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -12,8 +12,10 @@ function deferred() { return { promise, resolve }; } -function renderController(onSubmit: (username: string) => Promise = () => Promise.resolve()) { - const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); +function renderController(onSubmit: (username: string) => Promise = () => Promise.resolve(), required = false) { + const { result } = renderHook(() => + useUserProfileEditUsernameController({ username: 'prestonxyz', required, onSubmit }), + ); act(() => result.current.onOpenChange(true)); return result; } @@ -31,17 +33,30 @@ describe('useUserProfileEditUsernameController', () => { expect(result.current.form.values.username).toBe('prestonxyz'); }); - it('withholds the save until the value moves, and on an empty value', () => { + it('withholds the save until the value moves', () => { const result = renderController(); expect(result.current.form.canSubmit).toBe(false); + act(() => result.current.form.setValue('username', 'ada')); + expect(result.current.form.canSubmit).toBe(true); + }); + + it('lets an optional username be cleared', () => { + const result = renderController(); + act(() => result.current.form.setValue('username', '')); - expect(result.current.form.canSubmit).toBe(false); - act(() => result.current.form.setValue('username', 'ada')); expect(result.current.form.canSubmit).toBe(true); }); + it('withholds the save on an empty value when the instance requires a username', () => { + const result = renderController(() => Promise.resolve(), true); + + act(() => result.current.form.setValue('username', '')); + + expect(result.current.form.canSubmit).toBe(false); + }); + it('saves the value it is holding, stays open while saving, then closes', async () => { const request = deferred(); const onSubmit = vi.fn(() => request.promise); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 930f4dbd15e..b4fd4577913 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -37,6 +37,21 @@ function renderView(overrides: Partial = {}) { } describe('UserProfileProfilePanelView', () => { + it('names the connection managing the name, as the section does on its own', () => { + renderView({ nameManagedBy: { name: 'Okta' }, onSubmitName: undefined }); + + expect(screen.getByText('Managed by Okta')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit name' })).not.toBeInTheDocument(); + }); + + it('drops the rows the instance does not collect', () => { + renderView({ username: undefined, emails: undefined, phones: undefined }); + + expect(screen.queryByText('Username')).not.toBeInTheDocument(); + expect(screen.queryByText('item1@clerk.dev')).not.toBeInTheDocument(); + expect(screen.queryByText('+1 801-888-8181')).not.toBeInTheDocument(); + }); + it('hides connected accounts when only providers without a connect callback are supplied', () => { renderView({ connectedAccounts: [], diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts index 20b9e6cec92..4a4d927ba98 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -1,9 +1,10 @@ import { getFullName } from '@clerk/shared/internal/clerk-js/user'; -import { useUser } from '@clerk/shared/react'; +import { useClerk, useUser } from '@clerk/shared/react'; import type { AttributeData, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; -import { save } from '../../../utils/form-error'; +import type { MessageValues } from '../../../localization'; +import { save, SaveError, UNEXPECTED_ERROR } from '../../../utils/form-error'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; import type { UserProfileEmail, @@ -27,6 +28,7 @@ type UserProfileAccountSectionData = Pick< | 'lastNameAttribute' | 'nameManagedBy' | 'username' + | 'usernameRequired' | 'emails' | 'phones' | 'onProfilePictureChange' @@ -38,7 +40,7 @@ type UserProfileAccountSectionData = Pick< export type UserProfileAccountSectionModel = | { status: 'loading' } | { status: 'hidden' } - | (UserProfileAccountSectionData & { status: 'ready' }); + | (UserProfileAccountSectionData & { status: 'ready'; userId: string }); const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; @@ -79,6 +81,7 @@ function toPhones(user: UserResource): UserProfilePhone[] { export function useUserProfileAccountSectionModel(): UserProfileAccountSectionModel { const { isLoaded, user } = useUser(); + const clerk = useClerk(); const environment = useMosaicEnvironment(); if (!isLoaded || !environment) { @@ -89,6 +92,25 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'hidden' }; } + const userId = user.id; + + const saveAsUser = ( + run: (current: UserResource) => Promise, + fields: readonly TField[] = [], + params?: MessageValues, + ): Promise => + save( + () => { + const current = clerk.user; + if (!current || current.id !== userId) { + throw new SaveError({ global: UNEXPECTED_ERROR }); + } + return run(current); + }, + fields, + params, + ); + const { attributes, usernameSettings } = environment.userSettings; const usernameAttribute = attributes.username; const usernameImmutable = Boolean(usernameAttribute?.immutable); @@ -99,6 +121,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'ready', + userId, allowMultipleAccounts: true, name: getFullName(user), firstName: user.firstName ?? '', @@ -109,17 +132,21 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo imageUrl: user.imageUrl, hasImage: user.hasImage, username: showUsername ? (user.username ?? '') : undefined, + usernameRequired: Boolean(usernameAttribute?.required), emails: showEmails ? toEmails(user) : undefined, phones: showPhones ? toPhones(user) : undefined, - onProfilePictureChange: file => save(() => user.setProfileImage({ file })), - onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, + onProfilePictureChange: file => saveAsUser(current => current.setProfileImage({ file })), + onRemoveProfilePicture: user.hasImage + ? () => saveAsUser(current => current.setProfileImage({ file: null })) + : undefined, onSubmitName: nameManagedBy ? undefined - : value => save(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), + : value => + saveAsUser(current => current.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), onSubmitUsername: showUsername && !usernameImmutable ? username => - save(() => user.update({ username }), USERNAME_FIELDS, { + saveAsUser(current => current.update({ username }), USERNAME_FIELDS, { min_length: usernameSettings.min_length, max_length: usernameSettings.max_length, }) diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx index 4b902832b22..8271ba43f3d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx @@ -8,7 +8,12 @@ export function UserProfileAccountSection() { return null; } - const { status: _status, ...viewProps } = model; + const { status: _status, userId, ...viewProps } = model; - return ; + return ( + + ); } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index 0d170270aaf..ef2920a06e0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -27,6 +27,7 @@ export interface UserProfileAccountSectionViewProps { hasImage?: boolean; name: string; username?: string; + usernameRequired?: boolean; /** Passed alongside `name`, which cannot be split back into its two halves. */ firstName?: string; lastName?: string; @@ -62,6 +63,7 @@ export function UserProfileAccountSectionView({ hasImage = false, name, username, + usernameRequired = false, firstName, lastName, firstNameAttribute, @@ -145,6 +147,7 @@ export function UserProfileAccountSectionView({ {username !== undefined ? ( ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index c272a103d7e..41adea869dc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts @@ -6,6 +6,7 @@ import type { UserProfileEditUsernameValue } from './user-profile-edit-username. export interface UserProfileEditUsernameControllerOptions { username?: string; + required?: boolean; onSubmit: (username: string) => Promise; } @@ -17,12 +18,13 @@ export interface UserProfileEditUsernameController { export function useUserProfileEditUsernameController({ username = '', + required = false, onSubmit, }: UserProfileEditUsernameControllerOptions): UserProfileEditUsernameController { const [isOpen, setIsOpen] = useState(false); const form = useForm({ initialValues: { username }, - canSubmit: values => values.username !== username && values.username !== '', + canSubmit: values => values.username !== username && (!required || values.username !== ''), onSubmit: async values => { await onSubmit(values.username); setIsOpen(false); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx index ef3decc5cab..48d0b78df0a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx @@ -6,10 +6,11 @@ import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dial export interface UserProfileUsernameRowViewProps { username: string; + required?: boolean; onSubmit?: (username: string) => Promise; } -export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { +export function UserProfileUsernameRowView({ username, required = false, onSubmit }: UserProfileUsernameRowViewProps) { const m = useMessages('userProfileAccountSection'); return ( @@ -22,6 +23,7 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs @@ -31,9 +33,17 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs ); } -function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { +function EditUsername({ + username, + required, + onSubmit, +}: { + username: string; + required: boolean; + onSubmit: (username: string) => Promise; +}) { const m = useMessages('userProfileAccountSection'); - const controller = useUserProfileEditUsernameController({ username, onSubmit }); + const controller = useUserProfileEditUsernameController({ username, required, onSubmit }); const isSet = Boolean(username); return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index 6bfceb31d9a..926abce7705 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -37,46 +37,19 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect } export function UserProfileProfilePanelView({ - allowMultipleAccounts, - imageUrl, - hasImage, name = '', - username = '', - firstName, - lastName, - firstNameAttribute, - lastNameAttribute, - emails = [], - phones = [], connectedAccounts = [], availableConnectionProviders = [], onReconnectAccount, web3Wallets = [], availableWeb3Providers = [], - onProfilePictureChange, - onProfilePictureReject, - onRemoveProfilePicture, - onSubmitName, - onSubmitUsername, - onAddEmail, - onSendEmailCode, - onVerifyEmailCode, - onManageEmail, - onVerifyEmail, - onSetPrimaryEmail, - onRemoveEmail, - onSendPhoneCode, - onVerifyPhoneCode, - onManagePhone, - onVerifyPhone, - onSetPrimaryPhone, - onRemovePhone, onConnectAccount, onRemoveConnectedAccount, onConnectWeb3Wallet, onSetPrimaryWeb3Wallet, onRemoveWeb3Wallet, deleteAccountSlot, + ...account }: UserProfileProfilePanelViewProps): ReactElement { const pageTitle = useRef(null); return ( @@ -89,35 +62,8 @@ export function UserProfileProfilePanelView({ pageTitle.current} From 6fe38adb45bdeacf5169c8ab4cf5a8b879c782fd Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 15:10:19 -0400 Subject: [PATCH 31/31] style(mosaic): format the name dialog's field-only banner test --- .../__tests__/user-profile-edit-name.dialog.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx index dfab0026b04..c65ac8c800a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx @@ -146,8 +146,7 @@ describe('UserProfileEditNameDialog', () => { it('leaves the banner empty when only a field is blamed', async () => { const user = userEvent.setup(); renderView({ - onSubmit: () => - Promise.reject(new SaveError({ fields: { firstName: { message: 'First name is required.' } } })), + onSubmit: () => Promise.reject(new SaveError({ fields: { firstName: { message: 'First name is required.' } } })), }); await user.type(lastNameField(), 'x');