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 @@ +--- +--- diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 6cca449f303..c90577f3d88 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 { + 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 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 aa0b43f412d..f7b9ce8be8c 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import { deferred, tick } from '../../__tests__/async'; +import { SaveError } from '../../utils/form-error'; import type { FieldFeedback } from './form-submit-error'; import { FormSubmitError } from './form-submit-error'; import { useForm } from './use-form'; @@ -93,6 +94,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 tick(); + }); + 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'); @@ -108,36 +128,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 tick(); - }); - 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 tick(); - }); - 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 tick(); + }); + 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(); @@ -173,7 +189,7 @@ describe('useForm', () => { useForm({ initialValues: { username: '' }, onSubmit: () => { - throw new Error('Nope'); + throw new FormSubmitError({ message: 'Nope' }); }, }), ); @@ -201,7 +217,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/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index 0aeb59366f0..3c2867fa0d2 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/use-machine'; 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) { diff --git a/packages/mosaic/src/components/section/index.ts b/packages/mosaic/src/components/section/index.ts index f1c1cf21ee3..540434b0ca1 100644 --- a/packages/mosaic/src/components/section/index.ts +++ b/packages/mosaic/src/components/section/index.ts @@ -12,6 +12,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 3cfef0569b5..57fc066b4ca 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -157,6 +157,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'], + }, actionsWrap: { justifyContent: { [compact]: 'flex-start', default: 'flex-end' }, width: { [compact]: '100%', default: null }, diff --git a/packages/mosaic/src/components/section/section.test.tsx b/packages/mosaic/src/components/section/section.test.tsx index 3eea27e3a23..26f094bd128 100644 --- a/packages/mosaic/src/components/section/section.test.tsx +++ b/packages/mosaic/src/components/section/section.test.tsx @@ -340,6 +340,46 @@ describe('Section', () => { 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(); + }); + it('marks a wrapping item for its theme hook', () => { render( diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index df277055a11..f716badc9a0 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -29,6 +29,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 = { @@ -271,6 +272,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 @@ -346,5 +374,6 @@ export const Section = { Label, Description, Actions, + Note, Error: SectionError, }; 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..6443dcf464c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -0,0 +1,138 @@ +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 '../../../mosaic-provider'; +import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; + +let user: { + id: string; + 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 }), + useClerk: () => ({ + get user() { + return user; + }, + __internal_environment: { + userSettings: { + attributes: { + first_name: { enabled: true, required: false }, + last_name: { enabled: true, required: false }, + username: { enabled: true, required: false }, + }, + usernameSettings: { min_length: 4, max_length: 64 }, + }, + }, + }), + }; +}); + +function renderSection() { + return render( + + + , + ); +} + +beforeEach(() => { + user = { + id: 'user_1', + 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 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' }); + + 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( + '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 new file mode 100644 index 00000000000..5485c1364b7 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -0,0 +1,339 @@ +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 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 { + enabled: boolean; + required: boolean; + immutable?: boolean; + used_for_first_factor: boolean; + used_for_second_factor: boolean; +} + +let isUserLoaded: boolean; +let user: { + id: string; + firstName: string | null; + lastName: string | null; + username: string | null; + imageUrl: string; + hasImage: 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 } }[]; + phoneNumbers: { id: string; phoneNumber: string; verification: { status: string | null } }[]; + 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; + +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 }), + useClerk: () => ({ + get user() { + return activeUser; + }, + __internal_environment: environmentHydrated ? { userSettings: { attributes, usernameSettings } } : null, + }), + }; +}); + +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') { + 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; + usernameSettings = { min_length: 4, max_length: 64 }; + attributes = { + first_name: attribute(), + last_name: attribute(), + username: attribute(), + email_address: attribute(), + phone_number: attribute(), + }; + user = { + id: 'user_1', + 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)), + }; + activeUser = 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' }); + }); + + 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(); + + 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 }], + }); + }); + + 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' }); + await ready().onProfilePictureChange?.(file); + 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(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 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(rejection(ready().onSubmitName?.({ firstName: '', lastName: 'B' }))).resolves.toEqual({ + fields: { + firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, + }, + }); + }); + + 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' }, + 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' }); + }); + + 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 a Clerk runtime failure by its code, without the developer message', async () => { + user?.update.mockRejectedValue(new ClerkRuntimeError('Network down.', { code: 'network_error' })); + await expect(rejection(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' }))).resolves.toEqual({ + 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', async () => { + await ready().onSubmitUsername?.('preston'); + expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); + }); + + 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.', + params: { min_length: 4, max_length: 64 }, + }, + }, + }); + }); + + 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(); + }); + + 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 2b3cf273fed..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,130 +1,93 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { createActor } from '../../../machine/create-actor'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import { - userProfileEditNameMachine, - useUserProfileEditNameController, -} from '../user-profile-account-section/user-profile-edit-name.controller'; - -function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { - const actor = createActor(userProfileEditNameMachine, { context: { saveName, ...saved } }).start(); - actor.send({ type: 'OPEN' }); - return actor; +import { SaveError } from '../../../utils/form-error'; +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'; + +function deferred() { + let resolve: () => void = () => {}; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; } -describe('userProfileEditNameMachine', () => { - it('seeds both fields from the saved name on open', () => { - const actor = start(() => Promise.resolve()); - - expect(actor.getSnapshot().value).toBe('editing'); - expect(actor.getSnapshot().context.firstName).toBe('Preston'); - expect(actor.getSnapshot().context.lastName).toBe('Booth'); - }); +function renderController(onSubmit: (value: UserProfileEditNameValue) => Promise = () => Promise.resolve()) { + const { result } = renderHook(() => + useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }), + ); + act(() => result.current.onOpenChange(true)); + return result; +} - it('returns to idle when the save lands, and can be opened again', async () => { - const actor = start(() => Promise.resolve()); - actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); - actor.send({ type: 'SAVE' }); - expect(actor.getSnapshot().value).toBe('saving'); +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); - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); - expect(actor.getSnapshot().status).toBe('active'); + act(() => result.current.onOpenChange(true)); - actor.send({ type: 'OPEN' }); - expect(actor.getSnapshot().value).toBe('editing'); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values).toEqual({ firstName: 'Preston', lastName: 'Booth' }); }); - it('re-seeds from the saved name on the next open, dropping what was typed', () => { - const actor = start(() => Promise.resolve()); - actor.send({ type: 'TYPE', field: 'firstName', value: 'Ada' }); - actor.send({ type: 'CANCEL' }); - - actor.send({ type: 'OPEN' }); + it('withholds the save while the name is unchanged', () => { + const result = renderController(); + expect(result.current.form.canSubmit).toBe(false); - expect(actor.getSnapshot().context.firstName).toBe('Preston'); - }); + act(() => result.current.form.setValue('lastName', 'Barton')); - 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.'))); - 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.', - fields: undefined, - }); + expect(result.current.form.canSubmit).toBe(true); }); - 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)); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ firstName: 'First name is required.' }), - ); - }); + 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')); - it('falls back to generic copy when the rejection is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const actor = start(() => Promise.reject('nope')); - actor.send({ type: 'SAVE' }); + act(() => result.current.form.submit()); + act(() => result.current.onOpenChange(false)); - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.message).toBe('Something went wrong. Please try again.'), - ); - }); - - it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => Promise.reject(new Error('nope'))); - actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); - - actor.send({ type: 'CANCEL' }); - - expect(actor.getSnapshot().value).toBe('idle'); - expect(actor.getSnapshot().context.error).toBeUndefined(); + expect(onSubmit).toHaveBeenCalledWith({ firstName: 'Ada', lastName: 'Booth' }); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.isSubmitting).toBe(true); + await act(async () => request.resolve()); + await waitFor(() => expect(result.current.isOpen).toBe(false)); }); -}); -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() }), + 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.' } }, + }), + ), ); - 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); + act(() => result.current.form.setValue('lastName', 'Barton')); - act(() => result.current.onLastNameChange('Barton')); - expect(result.current.lastName).toBe('Barton'); + act(() => result.current.form.submit()); - act(() => result.current.onSubmit()); + await waitFor(() => expect(result.current.form.error).toBe('Your name could not be updated.')); expect(result.current.isOpen).toBe(true); - expect(result.current.isSaving).toBe(true); - - await waitFor(() => expect(result.current.isOpen).toBe(false)); + 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('saves the values it is currently holding', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => - useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }), - ); + 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)); - act(() => result.current.onFirstNameChange('Ada')); - act(() => result.current.onSubmit()); - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ firstName: 'Ada', lastName: 'Booth' })); + 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 f4dedaf7343..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 @@ -4,28 +4,41 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../../components/button'; import { MosaicProvider } from '../../../mosaic-provider'; -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()); + expect(props.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(); + it('withholds the save until the name changes, then submits what was typed', async () => { 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'); - }); - - it('submits from the action, without validating', async () => { - const onSubmit = vi.fn(); - 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,61 @@ 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: { - message: 'Your name could not be updated.', - fields: { lastName: '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.' } }, + }), + ), }); + await user.type(lastNameField(), 'x'); + await user.click(saveButton()); + const banner = screen.getByRole('alert'); + await waitFor(() => expect(banner).toHaveTextContent('Your name could not be updated.')); expect(banner.querySelector('.cl-banner-root')).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: 'First name is required.' } } }); + 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.' } } })), + }); + + await user.type(lastNameField(), 'x'); + await user.click(saveButton()); + await waitFor(() => expect(firstNameField()).toHaveAttribute('aria-invalid', 'true')); + expect(firstNameField()).toHaveAccessibleDescription('First name is required.'); expect(screen.getByRole('alert').textContent).toBe(''); - 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/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 155b473bfbe..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 @@ -1,172 +1,105 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { createActor } from '../../../machine/create-actor'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; -import { - userProfileEditUsernameMachine, - useUserProfileEditUsernameController, -} from '../user-profile-account-section/user-profile-edit-username.controller'; - -function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { - const actor = createActor(userProfileEditUsernameMachine, { context: { saveUsername, savedUsername } }).start(); - actor.send({ type: 'OPEN' }); - return actor; -} - -describe('userProfileEditUsernameMachine', () => { - it('seeds the field from the saved username on open', () => { - const actor = start(() => Promise.resolve()); - - expect(actor.getSnapshot().value).toBe('editing'); - expect(actor.getSnapshot().context.username).toBe('prestonxyz'); - }); - - it('returns to idle when the save lands, and can be opened again', async () => { - const actor = start(() => Promise.resolve()); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - expect(actor.getSnapshot().value).toBe('saving'); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); - expect(actor.getSnapshot().status).toBe('active'); +import { SaveError } from '../../../utils/form-error'; +import { useUserProfileEditUsernameController } from '../user-profile-account-section/user-profile-edit-username.controller'; - actor.send({ type: 'OPEN' }); - expect(actor.getSnapshot().value).toBe('editing'); - }); - - it('re-seeds from the saved username on the next open, dropping what was typed', () => { - const actor = start(() => Promise.resolve()); - actor.send({ type: 'TYPE', value: 'ada' }); - actor.send({ type: 'CANCEL' }); - - actor.send({ type: 'OPEN' }); - - expect(actor.getSnapshot().context.username).toBe('prestonxyz'); - }); - - it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => Promise.reject(new Error('That username is taken.'))); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); - expect(actor.getSnapshot().context.username).toBe('preston'); - expect(actor.getSnapshot().context.error).toEqual({ message: 'That username is taken.', fields: undefined }); +function deferred() { + let resolve: () => void = () => {}; + const promise = new Promise(r => { + resolve = r; }); + return { promise, resolve }; +} - it('carries field copy through when the rejection names the control', async () => { - const failure = new UserProfileSaveError('Your username could not be updated.', { - username: 'That username is taken.', - }); - const actor = start(() => Promise.reject(failure)); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); +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; +} - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ username: 'That username is taken.' }), +describe('useUserProfileEditUsernameController', () => { + it('opens on the saved username', () => { + const { result } = renderHook(() => + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: vi.fn() }), ); - }); + expect(result.current.isOpen).toBe(false); - it('falls back to generic copy when the rejection is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const actor = start(() => Promise.reject('nope')); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); + act(() => result.current.onOpenChange(true)); - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.message).toBe('Something went wrong. Please try again.'), - ); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values.username).toBe('prestonxyz'); }); - it('refuses to save a value that has not moved', () => { - const saveUsername = vi.fn(() => Promise.resolve()); - const actor = start(saveUsername); - - actor.send({ type: 'SAVE' }); + it('withholds the save until the value moves', () => { + const result = renderController(); + expect(result.current.form.canSubmit).toBe(false); - // 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(); + act(() => result.current.form.setValue('username', 'ada')); + expect(result.current.form.canSubmit).toBe(true); }); - it('refuses to save an empty value, since clearing a username is not on offer', () => { - const saveUsername = vi.fn(() => Promise.resolve()); - const actor = start(saveUsername); - actor.send({ type: 'TYPE', value: '' }); + it('lets an optional username be cleared', () => { + const result = renderController(); - actor.send({ type: 'SAVE' }); + act(() => result.current.form.setValue('username', '')); - expect(actor.getSnapshot().value).toBe('editing'); - expect(saveUsername).not.toHaveBeenCalled(); + expect(result.current.form.canSubmit).toBe(true); }); - it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => Promise.reject(new Error('nope'))); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); + it('withholds the save on an empty value when the instance requires a username', () => { + const result = renderController(() => Promise.resolve(), true); - actor.send({ type: 'CANCEL' }); + act(() => result.current.form.setValue('username', '')); - expect(actor.getSnapshot().value).toBe('idle'); - expect(actor.getSnapshot().context.error).toBeUndefined(); + expect(result.current.form.canSubmit).toBe(false); }); -}); -describe('useUserProfileEditUsernameController', () => { - it('holds the dialog open across editing and saving, then closes on success', async () => { - const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), - ); - expect(result.current.isOpen).toBe(false); + 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)); - expect(result.current.isOpen).toBe(true); - expect(result.current.username).toBe('prestonxyz'); - expect(result.current.isSaving).toBe(false); - - act(() => result.current.onUsernameChange('preston')); - expect(result.current.username).toBe('preston'); + act(() => result.current.form.submit()); + act(() => result.current.onOpenChange(false)); - act(() => result.current.onSubmit()); + expect(onSubmit).toHaveBeenCalledWith('ada'); expect(result.current.isOpen).toBe(true); - expect(result.current.isSaving).toBe(true); - + await act(async () => request.resolve()); await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - it('saves the value it is currently holding', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); - - act(() => result.current.onOpenChange(true)); - act(() => result.current.onUsernameChange('ada')); - act(() => result.current.onSubmit()); - - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ada')); - }); - - it('withholds the save until the value moves', () => { - const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), + 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: () => Promise.resolve() }), - ); + 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 f1d669ca6a5..8cee1bbda25 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,27 +3,40 @@ 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 '../../../mosaic-provider'; +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 = {}) { - 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'); @@ -36,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(); @@ -46,97 +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 waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('no')); 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: { 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.' } })), + }); + + await user.type(usernameField(), 'x'); + await user.click(saveButton()); const banner = screen.getByRole('alert'); + await waitFor(() => expect(banner).toHaveTextContent('Your username could not be updated.')); expect(banner.querySelector('.cl-banner-root')).toHaveAttribute('data-color', 'negative'); - expect(banner).toHaveTextContent('Your username could not be updated.'); expect(usernameField()).not.toHaveAttribute('aria-invalid', 'true'); }); - it('renders a field-scoped failure with no banner', () => { - renderView({ error: { fields: { username: 'That username is taken.' } } }); - - expect(screen.getByRole('alert').textContent).toBe(''); - expect(screen.getByText('That username is taken.')).toBeInTheDocument(); - expect(usernameField()).toHaveAttribute('aria-invalid', 'true'); - }); - - it('withholds the save while the caller says the value is unacceptable', async () => { - const onSubmit = vi.fn(); + it('shows the translation for the error code on the field, with no banner', async () => { const user = userEvent.setup(); - renderView({ canSave: false, onSubmit }); + renderView( + { + 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.' } }, + ); - // 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.getByRole('alert').textContent).toBe(''); }); 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/__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..e932abb968d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../mosaic-provider'; +import { UserProfileNameRowView } from '../user-profile-account-section/user-profile-name-row.view'; + +describe('UserProfileNameRowView', () => { + it('offers to add a name the user does not have yet', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + 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(); + }); + 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/__tests__/user-profile-picture-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx index 3bd3055c685..2cec20b7a28 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,8 +1,9 @@ -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'; import { MosaicProvider } from '../../../mosaic-provider'; +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'; @@ -18,18 +19,41 @@ 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.' }); + it('shows why removing the picture failed', async () => { + const user = userEvent.setup(); + renderView({ + hasImage: true, + onRemove: vi.fn().mockRejectedValue(new SaveError({ global: { code: 'action_blocked' } })), + }); + + await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); + + 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().mockRejectedValue(new SaveError({ global: { code: 'action_blocked' } })), + }); + const input = container.querySelector('input[type="file"]'); + if (!(input instanceof HTMLInputElement)) { + throw new Error('expected a file input'); + } - expect(screen.getByRole('alert')).toHaveTextContent('File size exceeds the maximum limit of 10MB.'); + 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; - 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(); + 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', () => { 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..c5abf01570d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -0,0 +1,184 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, renderHook } from '@testing-library/react'; +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'; +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', () => { + // 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(); + expect(result.current.onRemove).toBeUndefined(); + }); + + it('is pending while the upload runs', 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.isPending).toBe(true); + expect(onChange).toHaveBeenCalledWith(file); + + await act(async () => { + upload.resolve(); + await upload.promise; + }); + expect(result.current.isPending).toBe(false); + expect(result.current.error).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() + .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)); + 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.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'); + const onChange = vi.fn().mockRejectedValue(failure); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.error).toEqual({ code: 'generic' }); + expect(log).toHaveBeenCalledWith(failure); + log.mockRestore(); + }); + + it('reports a failed removal the same way', async () => { + const onRemove = vi.fn().mockRejectedValue(new SaveError({ global: { message: 'Nope.' } })); + const { result } = renderController({ onRemove }); + + 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/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index fd643716d92..8c4205b04d7 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 @@ -39,6 +39,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('keeps the final wallet confirmation mounted until removal settles', async () => { const user = userEvent.setup(); const titleRef = createRef(); @@ -99,7 +114,7 @@ describe('UserProfileProfilePanelView', () => { it('composes the profile content without profile navigation', () => { renderView({ - onProfilePictureChange: vi.fn(), + onProfilePictureChange: vi.fn(() => Promise.resolve()), onSubmitName: () => Promise.resolve(), onSubmitUsername: () => Promise.resolve(), }); @@ -133,9 +148,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()); const user = userEvent.setup(); - const { container } = renderView({ onProfilePictureChange, onRemoveProfilePicture: vi.fn() }); + const { container } = renderView({ + onProfilePictureChange, + onRemoveProfilePicture: vi.fn(() => Promise.resolve()), + }); expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); @@ -150,7 +168,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()); const onProfilePictureReject = vi.fn(); const user = userEvent.setup(); const { container } = renderView({ onProfilePictureChange, onProfilePictureReject }); @@ -170,7 +188,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()) }); const input = container.querySelector('input[type="file"]'); if (!input) { throw new Error('File picker not found'); 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..80eb4b6969d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../mosaic-provider'; +import { UserProfileUsernameRowView } from '../user-profile-account-section/user-profile-username-row.view'; + +describe('UserProfileUsernameRowView', () => { + it('offers to add a username the user does not have yet', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + 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 985d6683d9a..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 @@ -12,18 +12,16 @@ 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', + empty: 'No name added', edit: 'Edit name', + add: 'Add name', + managedBy: 'Managed by {name}', dialogTitle: 'Edit name', + addDialogTitle: 'Add name', firstNameLabel: 'First name', lastNameLabel: 'Last name', cancel: 'Cancel', @@ -31,9 +29,12 @@ export const userProfileAccountSectionMessages = { }, username: { label: 'Username', + empty: 'No username added', edit: 'Edit username', + add: 'Add username', dialogTitle: 'Edit 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-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..9dd21959440 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -0,0 +1,155 @@ +import { getFullName } from '@clerk/shared/internal/clerk-js/user'; +import { useClerk, useUser } from '@clerk/shared/react'; +import type { AttributeData, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; + +import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; +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, + UserProfileNameAttribute, + UserProfilePhone, +} from './user-profile-account-section.types'; +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'; + +type UserProfileAccountSectionData = Pick< + UserProfileAccountSectionViewProps, + | 'allowMultipleAccounts' + | 'name' + | 'imageUrl' + | 'hasImage' + | 'firstName' + | 'lastName' + | 'firstNameAttribute' + | 'lastNameAttribute' + | 'nameManagedBy' + | 'username' + | 'usernameRequired' + | 'emails' + | 'phones' + | 'onProfilePictureChange' + | 'onRemoveProfilePicture' + | 'onSubmitName' + | 'onSubmitUsername' +>; + +export type UserProfileAccountSectionModel = + | { status: 'loading' } + | { status: 'hidden' } + | (UserProfileAccountSectionData & { status: 'ready'; userId: string }); + +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_)/, '') }; +} + +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 clerk = useClerk(); + const environment = useMosaicEnvironment(); + + if (!isLoaded || !environment) { + return { status: 'loading' }; + } + + if (!user) { + 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); + const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); + const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); + const showEmails = isAttributeAvailable(attributes.email_address); + const showPhones = isAttributeAvailable(attributes.phone_number); + + return { + status: 'ready', + userId, + allowMultipleAccounts: true, + name: getFullName(user), + firstName: user.firstName ?? '', + 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, + usernameRequired: Boolean(usernameAttribute?.required), + emails: showEmails ? toEmails(user) : undefined, + phones: showPhones ? toPhones(user) : undefined, + onProfilePictureChange: file => saveAsUser(current => current.setProfileImage({ file })), + onRemoveProfilePicture: user.hasImage + ? () => saveAsUser(current => current.setProfileImage({ file: null })) + : undefined, + onSubmitName: nameManagedBy + ? undefined + : value => + saveAsUser(current => current.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), + onSubmitUsername: + showUsername && !usernameImmutable + ? username => + saveAsUser(current => current.update({ username }), USERNAME_FIELDS, { + min_length: usernameSettings.min_length, + max_length: usernameSettings.max_length, + }) + : 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..8271ba43f3d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx @@ -0,0 +1,19 @@ +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, userId, ...viewProps } = model; + + return ( + + ); +} 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..fa2aabc8e4f --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts @@ -0,0 +1,5 @@ +import type { AttributeData } from '@clerk/shared/types'; + +export function isAttributeAvailable(attribute: AttributeData | undefined): boolean { + return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor); +} 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 70683936c20..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 @@ -1,6 +1,7 @@ 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 type { UserProfileEmail, UserProfileNameAttribute, @@ -25,17 +26,20 @@ export interface UserProfileAccountSectionViewProps { */ hasImage?: boolean; name: string; - username: string; + username?: string; + usernameRequired?: boolean; /** Passed alongside `name`, which cannot be split back into its two halves. */ firstName?: string; lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - emails: UserProfileEmail[]; - phones: UserProfilePhone[]; - onProfilePictureChange?: (file: File) => void; + nameManagedBy?: UserProfileManagedBy; + /** 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?: () => void; + onRemoveProfilePicture?: () => Promise; onSubmitName?: (value: UserProfileEditNameValue) => Promise; onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; @@ -59,10 +63,12 @@ export function UserProfileAccountSectionView({ hasImage = false, name, username, + usernameRequired = false, firstName, lastName, firstNameAttribute, lastNameAttribute, + nameManagedBy, emails, phones, onProfilePictureChange, @@ -85,7 +91,8 @@ export function UserProfileAccountSectionView({ onRemovePhone, }: UserProfileAccountSectionViewProps) { const m = useMessages('userProfileAccountSection'); - const phoneRow = ( + const showName = firstNameAttribute?.enabled !== false || lastNameAttribute?.enabled !== false; + const phoneRow = phones ? ( - ); - const emailRow = ( + ) : null; + const emailRow = emails ? ( - ); + ) : null; return ( @@ -126,18 +133,24 @@ export function UserProfileAccountSectionView({ onReject={onProfilePictureReject} onRemove={onRemoveProfilePicture} /> - - + {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 97fe9ce813d..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,101 +1,20 @@ -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/use-machine'; -import type { UserProfileFormError } from './user-profile-account-section.types'; -import { UserProfileSaveError } from './user-profile-account-section.types'; -import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.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: UserProfileFormError | 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 toFormError(cause: unknown): UserProfileFormError { - if (cause instanceof UserProfileSaveError) { - return { message: cause.message, fields: cause.fields }; - } - if (cause instanceof Error) { - return { message: cause.message }; - } - return { message: 'Something went wrong. Please try again.' }; -} - -export const 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: 'saving', - 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; lastName?: string; - /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ + /** Resolve to close the dialog, or reject with a `SaveError` to keep it open showing why. */ onSubmit: (value: UserProfileEditNameValue) => Promise; } export interface UserProfileEditNameController { isOpen: boolean; onOpenChange: (open: boolean) => void; - firstName: string; - lastName: string; - onFirstNameChange: (value: string) => void; - onLastNameChange: (value: string) => void; - onSubmit: () => void; - isSaving: boolean; - error: UserProfileFormError | undefined; + form: UseFormResult; } export function useUserProfileEditNameController({ @@ -103,19 +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' }), - 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 1f08bb6b725..0ebdca31a78 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,14 +1,16 @@ -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 { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; +import type { UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; import { useMessages } from '../../../localization'; -import type { UserProfileFormError, UserProfileNameAttribute } from './user-profile-account-section.types'; +import type { UserProfileNameAttribute } from './user-profile-account-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; @@ -22,51 +24,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; - isSaving?: boolean; - error?: UserProfileFormError; - onSubmit: () => void; + title?: string; + 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, - isSaving = false, - error, - onSubmit, + title, + form, }: UserProfileEditNameDialogProps) { const m = useMessages('userProfileAccountSection'); - const formId = useId(); const initialFocusRef = useRef(null); 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. - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - if (!isSaving) { - onSubmit(); - } - }; - return ( - {m.name.dialogTitle} + {title ?? m.name.dialogTitle} - {error?.message} + {form.error} } > {showFirstName ? ( - - {m.name.firstNameLabel} - onFirstNameChange(event.target.value)} - /> - - {error?.fields?.firstName} - - + /> ) : null} {showLastName ? ( - - {m.name.lastNameLabel} - onLastNameChange(event.target.value)} - /> - - {error?.fields?.lastName} - - + /> ) : null} @@ -148,9 +115,11 @@ export function UserProfileEditNameDialog({ } /> {m.name.save} @@ -160,3 +129,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-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts index ebb412f2203..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 @@ -1,111 +1,43 @@ -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/use-machine'; -import type { UserProfileFormError } from './user-profile-account-section.types'; -import { UserProfileSaveError } from './user-profile-account-section.types'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; +import { useState } from 'react'; -export interface UserProfileEditUsernameContext { - saveUsername: (username: string) => Promise; - savedUsername: string; - username: string; - error: UserProfileFormError | undefined; -} - -export type UserProfileEditUsernameEvent = - | { type: 'OPEN' } - | { type: 'TYPE'; value: string } - | { type: 'SAVE' } - | { type: 'CANCEL' }; - -const { createMachine, assign, fromPromise } = setup(); - -function notSeated(): Promise { - return Promise.reject(new Error('edit-username deps are not seated')); -} - -function isSaveable(context: UserProfileEditUsernameContext): boolean { - return context.username !== context.savedUsername && context.username !== ''; -} - -function toFormError(cause: unknown): UserProfileFormError { - if (cause instanceof UserProfileSaveError) { - return { message: cause.message, fields: cause.fields }; - } - if (cause instanceof Error) { - return { message: cause.message }; - } - return { message: 'Something went wrong. Please try again.' }; -} - -export const userProfileEditUsernameMachine = createMachine({ - id: 'editUsername', - initial: 'idle', - context: { - saveUsername: notSeated, - savedUsername: '', - username: '', - error: undefined, - }, - states: { - idle: { - on: { - OPEN: { - target: 'editing', - actions: assign(context => ({ username: context.savedUsername, error: undefined })), - }, - }, - }, - editing: { - on: { - TYPE: { actions: assign((_, event) => ({ username: event.value })) }, - SAVE: { target: 'saving', guard: isSaveable }, - CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, - }, - }, - saving: { - invoke: fromPromise(context => context.saveUsername(context.username), { - onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, - onError: { - target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), - }, - }), - }, - }, -}); +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; + required?: boolean; onSubmit: (username: string) => Promise; } export interface UserProfileEditUsernameController { isOpen: boolean; onOpenChange: (open: boolean) => void; - username: string; - onUsernameChange: (value: string) => void; - onSubmit: () => void; - canSave: boolean; - isSaving: boolean; - error: UserProfileFormError | undefined; + form: UseFormResult; } export function useUserProfileEditUsernameController({ username = '', + required = false, 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 && (!required || 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 7e5981d1352..4dff57aee3f 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,50 +1,42 @@ -import type { FormEvent } from 'react'; -import { useId, useRef } from 'react'; +import { useMergeRefs } from '@floating-ui/react'; +import { useRef } from 'react'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; +import type { UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; import { useMessages } from '../../../localization'; -import type { UserProfileFormError } from './user-profile-account-section.types'; 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; - canSave?: boolean; - isSaving?: boolean; - error?: UserProfileFormError; - onSubmit: () => void; + title?: string; + form: UseFormResult; } export function UserProfileEditUsernameDialog({ open, onOpenChange, trigger, - username, - onUsernameChange, - canSave = true, - isSaving = false, - error, - onSubmit, + title, + form, }: UserProfileEditUsernameDialogProps) { const m = useMessages('userProfileAccountSection'); - 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 ( - {m.username.dialogTitle} + {title ?? m.username.dialogTitle} - {error?.message} + {form.error} } > - + {m.username.fieldLabel} onUsernameChange(event.target.value)} + {...control} /> - - {error?.fields?.username} - + {error ? {error} : null} @@ -105,10 +96,10 @@ export function UserProfileEditUsernameDialog({ } /> {m.username.save} 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..f4c9cf82c13 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,19 +24,22 @@ export function UserProfileNameRowView({ lastName, firstNameAttribute, lastNameAttribute, + managedBy, onSubmit, }: UserProfileNameRowViewProps) { const m = useMessages('userProfileAccountSection'); + return ( {m.name.label} - {name} + {name || m.name.empty} {onSubmit ? ( + ) : managedBy ? ( + ) : null} @@ -48,12 +59,14 @@ export function UserProfileNameRowView({ } function EditName({ + isSet, firstName, lastName, firstNameAttribute, lastNameAttribute, onSubmit, }: { + isSet: boolean; firstName?: string; lastName?: string; firstNameAttribute?: UserProfileNameAttribute; @@ -65,17 +78,19 @@ function EditName({ return ( - {m.name.edit} + {isSet ? m.name.edit : m.name.add} } /> 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..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 @@ -5,36 +5,52 @@ 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 type { FileRejection } from '../../../primitives/file-upload'; +import type { LocalizableError } from '../../../localization'; +import { useErrorText, useMessages } from '../../../localization'; +import type { FileRejection, FileRejectionReason } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; +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. */ 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; hasImage?: boolean; - errorMessage?: string; - onChange?: (file: File) => void; + onChange?: (file: File) => Promise; onReject?: (rejections: FileRejection[]) => void; - onRemove?: () => void; + onRemove?: () => Promise; } export function UserProfilePictureRowView({ name, imageUrl, hasImage = false, - errorMessage, onChange, onReject, onRemove, }: UserProfilePictureRowViewProps) { const m = useMessages('userProfileAccountSection'); - const [rejectionError, setRejectionError] = useState(); - const displayedError = errorMessage ?? rejectionError; + const errorText = useErrorText(); + const controller = useUserProfilePictureController({ onChange, onRemove }); + const [rejection, setRejection] = useState(); + const error = rejection ?? controller.error; + const remove = controller.onRemove; + const handleRemove = remove + ? () => { + setRejection(undefined); + return remove(); + } + : undefined; const initials = name .split(/\s+/) .map(part => part[0]) @@ -46,17 +62,19 @@ export function UserProfilePictureRowView({ } 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); - onChange?.(file); + setRejection(undefined); + void controller.onChange?.(file); } }} > @@ -65,7 +83,7 @@ export function UserProfilePictureRowView({ {initials} @@ -75,12 +93,12 @@ export function UserProfilePictureRowView({ {m.picture.description} - {displayedError} + {error ? errorText(error) : undefined} ); } @@ -92,7 +110,7 @@ function ProfilePictureActions({ }: { hasImage: boolean; canChange: boolean; - onRemove?: () => void; + onRemove?: () => Promise; }) { const m = useMessages('userProfileAccountSection'); const { openFilePicker } = FileUpload.useFileUpload(); @@ -103,7 +121,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..765c08e981d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts @@ -0,0 +1,70 @@ +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 { + onChange?: (file: File) => Promise; + onRemove?: () => Promise; +} + +export interface UserProfilePictureController { + onChange?: (file: File) => Promise; + onRemove?: () => Promise; + isPending: boolean; + previewUrl: string | undefined; + error: LocalizableError | undefined; +} + +export function useUserProfilePictureController({ + onChange, + onRemove, +}: 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, revert?: () => void) => { + if (inFlight.current) { + return; + } + inFlight.current = true; + setIsPending(true); + setError(undefined); + try { + await action(); + } catch (cause) { + revert?.(); + setError(toFormError(cause).global); + } finally { + inFlight.current = false; + setIsPending(false); + } + }; + + return { + 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/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..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,22 +6,24 @@ 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 ( {m.username.label} - {username} + {username || m.username.empty} {onSubmit ? ( @@ -31,21 +33,32 @@ 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 ( - {m.username.edit} + {isSet ? m.username.edit : m.username.add} } /> diff --git a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx index 546051f4708..55741ba40b8 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-api-keys-panel.tsx @@ -4,6 +4,7 @@ import type { APIKeyResource } from '@clerk/shared/types'; import type { ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react'; +import { FormSubmitError } from '../../components/form'; import { useLocale, useMessages } from '../../localization'; import { formatDate, formatRelativeTime } from './user-profile-api-keys.format'; import type { UserProfileAPIKey } from './user-profile-api-keys-panel.types'; @@ -46,7 +47,7 @@ function useUserProfileApiKeysModel(query: string) { void apiKeys.revalidate(); return created.secret ?? ''; } catch (error) { - throw new Error(createErrorMessage(error, m)); + throw new FormSubmitError({ message: createErrorMessage(error, m) }); } }, revoke: async (id: string) => { 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..86e642ee459 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx @@ -0,0 +1,26 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Icon } from '../../components/icon'; +import { Section, sectionCompactStyles } 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; +} + +export function UserProfileManagedByLabel({ managedBy, label }: { managedBy: UserProfileManagedBy; label: string }) { + return ( + + } + > + {label} + {managedBy.name} + + ); +} 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 0fb89004457..48dd013ffd8 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,14 +1,7 @@ -import * as stylex from '@stylexjs/stylex'; - -import { Icon } from '../../../components/icon'; -import { Section, sectionCompactStyles } from '../../../components/section'; -import { Text } from '../../../components/text'; +import { Section } from '../../../components/section'; import { fill, useMessages } from '../../../localization'; -import { styles } from './user-profile-password-section.styles'; -import type { - UserProfilePasswordManagedBy, - UserProfilePasswordSectionViewProps, -} from './user-profile-password-section.types'; +import { UserProfileManagedByLabel } from '../user-profile-managed-by'; +import type { UserProfilePasswordSectionViewProps } from './user-profile-password-section.types'; export function UserProfilePasswordRowView({ action, @@ -25,32 +18,12 @@ export function UserProfilePasswordRowView({ {action ? ( {action} ) : managedBy ? ( - - - + ) : null} ); } - -function ManagedByLabel({ name }: UserProfilePasswordManagedBy) { - const m = useMessages('userProfilePasswordSection'); - return ( -
- - } - size='sm' - xstyle={styles.managedByText} - > - {fill(m.managedBy, { name })} - {name} - -
- ); -} 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 deleted file mode 100644 index de0c2d9ea20..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { colorVars, space } from '../../../tokens.stylex'; - -export const styles = stylex.create({ - managedBy: { - gap: space['1.5'], - alignItems: 'center', - display: 'flex', - }, - 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 c286af81533..9ed3bf2e5a1 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,7 @@ import type { ReactNode } from 'react'; +import type { UserProfileManagedBy } from '../user-profile-managed-by'; + export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; export interface UserProfileEditPasswordValues { @@ -15,15 +17,11 @@ export interface UserProfileEditPasswordValue { signOutOfOtherSessions: boolean; } -export interface UserProfilePasswordManagedBy { - name: string; -} - export interface UserProfilePasswordSectionViewProps { action?: ReactNode; hasPassword?: boolean; /** Replaces the edit action with the enterprise provider’s name. */ - managedBy?: UserProfilePasswordManagedBy; + managedBy?: UserProfileManagedBy; } export interface UserProfilePasswordSlot { 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 96024384f37..e41f83c92bc 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-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index a5f256402e7..82157ac8452 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 @@ -12,10 +12,7 @@ import type { UserProfileConnectedAccount } from './user-profile-connected-accou import type { UserProfileWeb3Wallet } 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 { @@ -26,39 +23,12 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect } export function UserProfileProfilePanelView({ - allowMultipleAccounts, - imageUrl, - hasImage, name = '', - username = '', - firstName, - lastName, - firstNameAttribute, - lastNameAttribute, - emails = [], - phones = [], titleRef, - onProfilePictureChange, - onProfilePictureReject, - onRemoveProfilePicture, - onSubmitName, - onSubmitUsername, - onAddEmail, - onSendEmailCode, - onVerifyEmailCode, - onManageEmail, - onVerifyEmail, - onSetPrimaryEmail, - onRemoveEmail, - onSendPhoneCode, - onVerifyPhoneCode, - onManagePhone, - onVerifyPhone, - onSetPrimaryPhone, - onRemovePhone, connectedAccountsSlot, web3WalletsSlot, deleteAccountSlot, + ...account }: UserProfileProfilePanelViewProps): ReactElement { return ( }> @@ -70,35 +40,8 @@ export function UserProfileProfilePanelView({ {connectedAccountsSlot} {web3WalletsSlot} diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 09df4031b4b..3f9acc7e9cf 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -1,12 +1,20 @@ 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.', form_password_incorrect: 'Your current password is incorrect.', + 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_password_size_in_bytes_exceeded: 'Your password has exceeded the maximum number of bytes allowed, please shorten it or remove some special characters.', form_password_validation_failed: 'Incorrect Password', + form_username_invalid_length: 'Your username must be between {min_length} and {max_length} characters long.', + form_username_needs_non_number_char: 'Your username must contain at least one non-numeric character.', }; diff --git a/packages/mosaic/src/localization/errors.ts b/packages/mosaic/src/localization/errors.ts index 172628b9e7c..d11da404a36 100644 --- a/packages/mosaic/src/localization/errors.ts +++ b/packages/mosaic/src/localization/errors.ts @@ -6,6 +6,7 @@ export interface LocalizableError { code?: string; paramName?: string; message?: string; + /** Values for the `{placeholder}`s in whichever message the code resolves to. */ params?: MessageValues; } 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/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; +} diff --git a/packages/mosaic/src/utils/form-error.ts b/packages/mosaic/src/utils/form-error.ts new file mode 100644 index 00000000000..45dd494ae4d --- /dev/null +++ b/packages/mosaic/src/utils/form-error.ts @@ -0,0 +1,89 @@ +import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; +import { snakeToCamel } from '@clerk/shared/underscore'; + +import type { LocalizableError, MessageValues } from '../localization'; + +export interface FormError { + global?: LocalizableError; + fields?: Partial>; +} + +export const UNEXPECTED_ERROR: LocalizableError = { code: 'generic' }; + +/** 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[], + params: MessageValues | undefined, +): FormError | undefined { + if (isClerkRuntimeError(cause)) { + return { + global: { + code: cause.code, + ...(cause.longMessage ? { message: cause.longMessage } : {}), + ...(params ? { params } : {}), + }, + }; + } + if (!isClerkAPIResponseError(cause)) { + return undefined; + } + const error: FormError = {}; + for (const apiError of cause.errors) { + const paramName = apiError.meta?.paramName; + const localizable: LocalizableError = { + code: apiError.code, + ...(paramName ? { paramName } : {}), + message: apiError.longMessage || apiError.message, + ...(params ? { params } : {}), + }; + const field = fields.find(f => paramName && snakeToCamel(paramName) === f); + if (field) { + error.fields = { ...error.fields, [field]: error.fields?.[field] ?? localizable }; + } else { + error.global ??= localizable; + } + } + return error; +} + +/** + * 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`. `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, params); + if (!formError) { + throw cause; + } + 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 }; +} diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index db7f56f8b06..deee160f546 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -19,6 +19,7 @@ import { } from '@/components/ui/sidebar'; const flows = [ + { title: 'Account section', href: '/live/account-section' }, { title: 'API keys', href: '/live/api-keys' }, { title: 'Password', href: '/live/password' }, { title: 'Connected accounts', href: '/live/connected-accounts' }, 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..ed07fdb14cd --- /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/mosaic-provider'; +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/lib/registry.ts b/packages/swingset/src/lib/registry.ts index adcff0804a7..4814ea9f80e 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -314,6 +314,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'; @@ -745,6 +746,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/fixtures/use-preview-image.ts b/packages/swingset/src/stories/fixtures/use-preview-image.ts index 10b34995775..7a3f7c5495f 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(); }, [release], ); @@ -31,6 +32,7 @@ export function usePreviewImage(initialUrl?: string) { const clearImage = useCallback(() => { release(); setImageUrl(undefined); + 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 52084a183a6..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,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 { UserProfileEditNameValue } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog'; +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'; @@ -10,8 +10,8 @@ export interface UserProfileEditNameFixtureOptions { firstName?: string; lastName?: string; latency?: number; - /** Rejects every save instead of committing it. */ - failWith?: UserProfileFormError; + /** Fails every save instead of committing it. */ + failWith?: FormError; } /** Stands in for the model. Everything else the dialog needs belongs to the controller. */ @@ -33,7 +33,7 @@ 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); + throw new SaveError(failWith); } setName(value); }, 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..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,5 +1,5 @@ -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 { FormError } from '@clerk/mosaic/utils/form-error'; +import { SaveError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -8,7 +8,7 @@ import { chaosName } from '@/lib/chaos'; export interface UserProfileEditUsernameFixtureOptions { username?: string; latency?: number; - failWith?: UserProfileFormError; + failWith?: FormError; } export function useUserProfileEditUsernameFixture({ @@ -24,7 +24,7 @@ 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); + throw new SaveError(failWith); } setUsername(value); }, diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index 7559e7e7399..0f20c183732 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -41,6 +41,10 @@ list of values, one `Section.Item` per entry, and each item becomes a list item. 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. + `Section.Group` is a query container named `cl-section`, so rows can adapt to the card's width. `Section.Item` accepts `wrap`, which moves the actions under the content when the card is narrower than `26rem`. `sectionCompactStyles.hidden` and `sectionCompactStyles.only` toggle inline content at 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({ 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 3b17983b1c4..54a81a99f38 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/form-error'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -40,14 +40,16 @@ function AccountSection({ failEmailVerification = false, emailRemovalState, phoneRemovalState, + nameManagedBy, }: { allowMultipleAccounts: boolean; failAt?: UserProfileAddPhoneDialogProps['step']; - failWith?: UserProfileFormError; - usernameFailWith?: UserProfileFormError; + failWith?: FormError; + usernameFailWith?: FormError; failEmailVerification?: boolean; emailRemovalState?: 'pending' | 'error'; phoneRemovalState?: 'pending' | 'error'; + nameManagedBy?: { name: 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. + */ +export function NameManagedByConnection() { + return ( + + ); +} + export function MultipleAccounts() { return ; } @@ -207,8 +224,8 @@ export function EditNameFails() { ); @@ -219,8 +236,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 1ac0dfe0cfc..3279ca0545a 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 type { UserProfileFormError } 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 { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import type { StoryMeta } from '@/lib/types';