diff --git a/apps/mobile/__tests__/components/profile/edit-name-sheet.test.tsx b/apps/mobile/__tests__/components/profile/edit-name-sheet.test.tsx new file mode 100644 index 000000000..83a0ccec9 --- /dev/null +++ b/apps/mobile/__tests__/components/profile/edit-name-sheet.test.tsx @@ -0,0 +1,172 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +interface TestNode { + type: unknown + props: { + children?: unknown + onPress?: (...args: unknown[]) => unknown + accessibilityLabel?: string + testID?: string + value?: unknown + onChangeText?: (...args: unknown[]) => unknown + [key: string]: unknown + } +} + +interface TestTreeRoot extends TestNode { + findAllByProps(props: Record): TestNode[] +} + +interface TestInstance { + root: TestTreeRoot +} + +interface TestRendererApi { + create(element: React.ReactNode): TestInstance + act(callback: () => Promise | void): Promise +} + +const TestRenderer: TestRendererApi = require('react-test-renderer') + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en' }, + }), +})) + +const mockPatchProfile = vi.fn() +let mockProfileName = 'Thomas' + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ + profile: { name: mockProfileName }, + patchProfile: mockPatchProfile, + }), +})) + +const mockPerformQueuedApiMutation = vi.fn() + +vi.mock('@/lib/queued-api-mutation', () => ({ + performQueuedApiMutation: (...args: unknown[]) => + mockPerformQueuedApiMutation(...args), +})) + +vi.mock('@/components/bottom-sheet-modal', () => ({ + BottomSheetModal: ({ + open, + children, + }: { + open: boolean + children: React.ReactNode + }) => (open ? <>{children} : null), +})) + +import { EditNameSheet } from '@/app/(tabs)/profile/_components/edit-name-sheet' + +function findByTestId(tree: TestInstance, testID: string): TestNode { + const node = tree.root.findAllByProps({ testID }).at(0) + if (!node) throw new Error(`No node with testID "${testID}"`) + return node +} + +function findByLabel(tree: TestInstance, accessibilityLabel: string): TestNode { + const node = tree.root + .findAllByProps({ accessibilityLabel }) + .find((candidate) => typeof candidate.props.onPress === 'function') + if (!node) throw new Error(`No pressable with label "${accessibilityLabel}"`) + return node +} + +async function renderSheet(onClose = vi.fn()) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + let tree!: TestInstance + await TestRenderer.act(async () => { + tree = TestRenderer.create( + + + , + ) + }) + return { tree, onClose } +} + +async function typeAndSave(tree: TestInstance, value: string) { + await TestRenderer.act(async () => { + findByTestId(tree, 'edit-name-input').props.onChangeText?.(value) + }) + await TestRenderer.act(async () => { + findByLabel(tree, 'common.save').props.onPress?.() + }) +} + +describe('EditNameSheet', () => { + beforeEach(() => { + mockPatchProfile.mockReset() + mockPerformQueuedApiMutation.mockReset() + mockProfileName = 'Thomas' + }) + + it('seeds the field with the current profile name', async () => { + const { tree } = await renderSheet() + + expect(findByTestId(tree, 'edit-name-input').props.value).toBe('Thomas') + }) + + it('shows the required error and skips the mutation for a whitespace-only name', async () => { + const { tree } = await renderSheet() + + await typeAndSave(tree, ' ') + + expect(findByTestId(tree, 'edit-name-error').props.children).toBe( + 'profile.editName.required', + ) + expect(mockPerformQueuedApiMutation).not.toHaveBeenCalled() + }) + + it('shows the tooLong error and skips the mutation for a 51-character name', async () => { + const { tree } = await renderSheet() + + await typeAndSave(tree, 'a'.repeat(51)) + + expect(findByTestId(tree, 'edit-name-error').props.children).toBe( + 'profile.editName.tooLong', + ) + expect(mockPerformQueuedApiMutation).not.toHaveBeenCalled() + }) + + it('queues the trimmed name, patches optimistically, and closes on success', async () => { + mockPerformQueuedApiMutation.mockResolvedValue(undefined) + const { tree, onClose } = await renderSheet() + + await typeAndSave(tree, ' Ana Clara ') + + expect(mockPerformQueuedApiMutation).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'setName', + scope: 'profile', + method: 'PUT', + payload: { name: 'Ana Clara' }, + dedupeKey: 'profile-name', + }), + ) + expect(mockPatchProfile).toHaveBeenCalledWith({ name: 'Ana Clara' }) + expect(onClose).toHaveBeenCalled() + }) + + it('restores the previous name and shows an error when the mutation fails', async () => { + mockPerformQueuedApiMutation.mockRejectedValue(new Error('boom')) + const { tree, onClose } = await renderSheet() + + await typeAndSave(tree, 'Ana Clara') + + expect(mockPatchProfile).toHaveBeenCalledWith({ name: 'Ana Clara' }) + expect(mockPatchProfile).toHaveBeenCalledWith({ name: 'Thomas' }) + expect(findByTestId(tree, 'edit-name-error')).toBeDefined() + expect(onClose).not.toHaveBeenCalled() + }) +}) diff --git a/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx b/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx index 8bd090964..f1cc14d4a 100644 --- a/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx +++ b/apps/mobile/__tests__/screens/intentional-offline-ux.test.tsx @@ -319,6 +319,7 @@ vi.mock('lucide-react-native', () => { MessageCircle: createIcon('MessageCircle'), MessageSquare: createIcon('MessageSquare'), Palette: createIcon('Palette'), + Pencil: createIcon('Pencil'), RotateCcw: createIcon('RotateCcw'), Settings: createIcon('Settings'), ShieldCheck: createIcon('ShieldCheck'), diff --git a/apps/mobile/__tests__/screens/profile-screen.test.tsx b/apps/mobile/__tests__/screens/profile-screen.test.tsx index d54f0fb24..36deccdec 100644 --- a/apps/mobile/__tests__/screens/profile-screen.test.tsx +++ b/apps/mobile/__tests__/screens/profile-screen.test.tsx @@ -30,6 +30,10 @@ vi.mock('@tanstack/react-query', () => ({ invalidateQueries: vi.fn(), clear: vi.fn(), }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + }), })) vi.mock('@/hooks/use-profile', () => ({ @@ -211,6 +215,7 @@ vi.mock('lucide-react-native', () => { ChevronLeft: createIcon('ChevronLeft'), Flame: createIcon('Flame'), Download: createIcon('Download'), + Pencil: createIcon('Pencil'), UserX: createIcon('UserX'), TriangleAlert: createIcon('TriangleAlert'), } diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 5c698d47c..cc75cda80 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -37,6 +37,7 @@ import { CreditCard, Download, LogOut, + Pencil, RotateCcw, TriangleAlert, User as UserIcon, @@ -82,6 +83,7 @@ import { FreshStartAnimation } from '@/components/ui/fresh-start-animation' import { plural } from '@/lib/plural' import { ProfileNavIcon } from './profile/_components/profile-nav-icon' import { ProfileActionButton } from './profile/_components/profile-action-button' +import { EditNameSheet } from './profile/_components/edit-name-sheet' import { TourReplayModal } from '@/components/tour/tour-replay-modal' type Tokens = ReturnType @@ -251,6 +253,7 @@ export default function ProfileScreen() { const [showFreshStartAnim, setShowFreshStartAnim] = useState(false) const [showResetModal, setShowResetModal] = useState(false) + const [showEditName, setShowEditName] = useState(false) const [resetStep, setResetStep] = useState<'info' | 'confirm'>('info') const [resetConfirmText, setResetConfirmText] = useState('') const [resetLoading, setResetLoading] = useState(false) @@ -566,12 +569,21 @@ export default function ProfileScreen() { {planBadgeLabel} ) : null} - setShowEditName(true)} + accessibilityRole="button" + accessibilityLabel={t('profile.editName.title')} + hitSlop={8} + style={styles.identityNameButton} > - {profile?.name} - + + {profile?.name} + + + + setShowEditName(false)} /> + setShowTourReplay(false)} @@ -1097,12 +1111,19 @@ function createStyles(_tokens: Tokens) { planBadge: { alignSelf: 'center', }, + identityNameButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + maxWidth: '100%', + minHeight: 44, + }, identityName: { fontFamily: 'Rubik_500Medium', fontSize: 32, letterSpacing: -0.32, lineHeight: 38, - maxWidth: '100%', + flexShrink: 1, }, identityLine: { fontFamily: 'Rubik_400Regular', diff --git a/apps/mobile/app/(tabs)/profile/_components/edit-name-sheet.tsx b/apps/mobile/app/(tabs)/profile/_components/edit-name-sheet.tsx new file mode 100644 index 000000000..6f08adeec --- /dev/null +++ b/apps/mobile/app/(tabs)/profile/_components/edit-name-sheet.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react' +import { StyleSheet, Text, View } from 'react-native' +import { useMutation } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { API } from '@orbit/shared/api' +import { setNameRequestSchema } from '@orbit/shared/types/profile' +import { getErrorMessage } from '@orbit/shared/utils' +import { BottomSheetModal } from '@/components/bottom-sheet-modal' +import { AppTextInput } from '@/components/ui/app-text-input' +import { PillButton } from '@/components/ui/pill-button' +import { useProfile } from '@/hooks/use-profile' +import { performQueuedApiMutation } from '@/lib/queued-api-mutation' +import { useAppTheme } from '@/lib/use-app-theme' +import { createTokensV2 } from '@/lib/theme' + +interface EditNameSheetProps { + open: boolean + onClose: () => void +} + +export function EditNameSheet({ open, onClose }: Readonly) { + const { t } = useTranslation() + const { profile, patchProfile } = useProfile() + const { currentScheme, currentTheme } = useAppTheme() + const tokens = createTokensV2(currentScheme, currentTheme) + + const [name, setName] = useState(() => profile?.name ?? '') + const [error, setError] = useState('') + const [prevOpen, setPrevOpen] = useState(open) + if (open !== prevOpen) { + setPrevOpen(open) + if (open) { + setName(profile?.name ?? '') + setError('') + } + } + + const mutation = useMutation({ + mutationFn: (nextName) => + performQueuedApiMutation({ + type: 'setName', + scope: 'profile', + endpoint: API.profile.name, + method: 'PUT', + payload: { name: nextName }, + dedupeKey: 'profile-name', + }), + onMutate: (nextName) => { + const previous = profile?.name + patchProfile({ name: nextName }) + return { previous } + }, + onSuccess: () => { + onClose() + }, + onError: (err, _nextName, context) => { + if (context?.previous !== undefined) { + patchProfile({ name: context.previous }) + } + setError(getErrorMessage(err, t('profile.editName.errorGeneric'))) + }, + }) + + function handleNameChange(value: string) { + setName(value) + if (error) setError('') + } + + function handleSave() { + const parsed = setNameRequestSchema.safeParse({ name }) + if (!parsed.success) { + setError( + name.trim().length === 0 + ? t('profile.editName.required') + : t('profile.editName.tooLong'), + ) + return + } + mutation.mutate(parsed.data.name) + } + + return ( + + + + {t('profile.editName.label')} + + + {error ? ( + + {error} + + ) : null} + + + {t('common.save')} + + + {t('common.cancel')} + + + + + ) +} + +const styles = StyleSheet.create({ + body: { + paddingHorizontal: 22, + paddingTop: 8, + gap: 16, + }, + label: { + fontFamily: 'Rubik_500Medium', + fontSize: 14, + }, + error: { + fontFamily: 'Rubik_400Regular', + fontSize: 13, + lineHeight: 18, + }, + actions: { + gap: 12, + paddingTop: 8, + }, +}) diff --git a/apps/web/__tests__/app/profile-page.test.tsx b/apps/web/__tests__/app/profile-page.test.tsx index 3e2a5737e..00ab66d11 100644 --- a/apps/web/__tests__/app/profile-page.test.tsx +++ b/apps/web/__tests__/app/profile-page.test.tsx @@ -25,6 +25,10 @@ vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn(), }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + }), })) vi.mock('@/hooks/use-profile', () => ({ diff --git a/apps/web/__tests__/components/profile/edit-name-sheet.test.tsx b/apps/web/__tests__/components/profile/edit-name-sheet.test.tsx new file mode 100644 index 000000000..f13787492 --- /dev/null +++ b/apps/web/__tests__/components/profile/edit-name-sheet.test.tsx @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', +})) + +const mockUpdateName = vi.fn() + +vi.mock('@/app/actions/profile', () => ({ + updateName: (...args: unknown[]) => mockUpdateName(...args), +})) + +const mockPatchProfile = vi.fn() +const mockInvalidate = vi.fn() +let mockProfileName = 'Thomas' + +vi.mock('@/hooks/use-profile', () => ({ + useProfile: () => ({ + profile: { name: mockProfileName }, + patchProfile: mockPatchProfile, + invalidate: mockInvalidate, + }), +})) + +vi.mock('@/components/ui/app-overlay', () => ({ + AppOverlay: ({ + open, + title, + children, + }: { + open: boolean + title?: string + children: React.ReactNode + }) => + open ? ( +
+ {title &&

{title}

} + {children} +
+ ) : null, +})) + +import { EditNameSheet } from '@/app/(app)/profile/_components/edit-name-sheet' + +function renderSheet(onOpenChange = vi.fn()) { + const queryClient = new QueryClient({ + defaultOptions: { mutations: { retry: false } }, + }) + render( + + + , + ) + return onOpenChange +} + +describe('EditNameSheet', () => { + beforeEach(() => { + mockUpdateName.mockReset() + mockPatchProfile.mockReset() + mockInvalidate.mockReset() + mockProfileName = 'Thomas' + }) + + it('seeds the field with the current profile name', () => { + renderSheet() + + expect(screen.getByDisplayValue('Thomas')).toBeInTheDocument() + }) + + it('shows the required error and skips the action for a whitespace-only name', () => { + renderSheet() + + fireEvent.change(screen.getByDisplayValue('Thomas'), { target: { value: ' ' } }) + fireEvent.click(screen.getByText('common.save')) + + expect(screen.getByRole('alert')).toHaveTextContent('profile.editName.required') + expect(mockUpdateName).not.toHaveBeenCalled() + }) + + it('shows the tooLong error and skips the action for a 51-character name', () => { + renderSheet() + + fireEvent.change(screen.getByDisplayValue('Thomas'), { + target: { value: 'a'.repeat(51) }, + }) + fireEvent.click(screen.getByText('common.save')) + + expect(screen.getByRole('alert')).toHaveTextContent('profile.editName.tooLong') + expect(mockUpdateName).not.toHaveBeenCalled() + }) + + it('saves a valid name trimmed, patches optimistically, and closes', async () => { + mockUpdateName.mockResolvedValue(undefined) + const onOpenChange = renderSheet() + + fireEvent.change(screen.getByDisplayValue('Thomas'), { + target: { value: ' Ana Clara ' }, + }) + fireEvent.click(screen.getByText('common.save')) + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)) + expect(mockUpdateName).toHaveBeenCalledWith({ name: 'Ana Clara' }) + expect(mockPatchProfile).toHaveBeenCalledWith({ name: 'Ana Clara' }) + expect(mockInvalidate).toHaveBeenCalled() + }) + + it('restores the previous name and shows an error when the server rejects', async () => { + mockUpdateName.mockRejectedValue(new Error('boom')) + const onOpenChange = renderSheet() + + fireEvent.change(screen.getByDisplayValue('Thomas'), { + target: { value: 'Ana Clara' }, + }) + fireEvent.click(screen.getByText('common.save')) + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()) + expect(mockPatchProfile).toHaveBeenCalledWith({ name: 'Ana Clara' }) + expect(mockPatchProfile).toHaveBeenCalledWith({ name: 'Thomas' }) + expect(onOpenChange).not.toHaveBeenCalledWith(false) + }) +}) diff --git a/apps/web/app/(app)/profile/_components/edit-name-sheet.tsx b/apps/web/app/(app)/profile/_components/edit-name-sheet.tsx new file mode 100644 index 000000000..bb6fde316 --- /dev/null +++ b/apps/web/app/(app)/profile/_components/edit-name-sheet.tsx @@ -0,0 +1,126 @@ +'use client' + +import { useState } from 'react' +import { useMutation } from '@tanstack/react-query' +import { useTranslations } from 'next-intl' +import { setNameRequestSchema } from '@orbit/shared/types/profile' +import { getErrorMessage } from '@orbit/shared/utils' +import { AppOverlay } from '@/components/ui/app-overlay' +import { FieldInput } from '@/components/ui/field-input' +import { PillButton } from '@/components/ui/pill-button' +import { useProfile } from '@/hooks/use-profile' +import { updateName } from '@/app/actions/profile' + +interface EditNameSheetProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function EditNameSheet({ open, onOpenChange }: Readonly) { + const t = useTranslations() + const { profile, patchProfile, invalidate } = useProfile() + + const [name, setName] = useState(() => profile?.name ?? '') + const [error, setError] = useState('') + const [prevOpen, setPrevOpen] = useState(open) + if (open !== prevOpen) { + setPrevOpen(open) + if (open) { + setName(profile?.name ?? '') + setError('') + } + } + + const mutation = useMutation({ + mutationFn: (nextName) => updateName({ name: nextName }), + onMutate: (nextName) => { + const previous = profile?.name + patchProfile({ name: nextName }) + return { previous } + }, + onSuccess: () => { + onOpenChange(false) + }, + onError: (err, _nextName, context) => { + if (context?.previous !== undefined) { + patchProfile({ name: context.previous }) + } + setError(getErrorMessage(err, t('profile.editName.errorGeneric'))) + }, + onSettled: () => { + invalidate() + }, + }) + + function handleNameChange(value: string) { + setName(value) + if (error) setError('') + } + + function handleSave() { + const parsed = setNameRequestSchema.safeParse({ name }) + if (!parsed.success) { + setError( + name.trim().length === 0 + ? t('profile.editName.required') + : t('profile.editName.tooLong'), + ) + return + } + mutation.mutate(parsed.data.name) + } + + return ( + +
+ { + if (event.key === 'Enter') handleSave() + }} + /> + {error && ( +

+ {error} +

+ )} +
+ + {t('common.save')} + + onOpenChange(false)} + > + {t('common.cancel')} + +
+
+
+ ) +} diff --git a/apps/web/app/(app)/profile/page.tsx b/apps/web/app/(app)/profile/page.tsx index 8930fca0a..3ed367f1d 100644 --- a/apps/web/app/(app)/profile/page.tsx +++ b/apps/web/app/(app)/profile/page.tsx @@ -10,7 +10,7 @@ import { type ProfileNavItem, } from '@orbit/shared/utils/profile-navigation' import { useTranslations } from 'next-intl' -import { User, Download, LogOut, RotateCcw, UserX } from 'lucide-react' +import { User, Download, LogOut, RotateCcw, UserX, Pencil } from 'lucide-react' import { useProfile, useTrialDaysLeft, @@ -31,6 +31,7 @@ import { plural } from '@/lib/plural' import { SubscriptionCard } from './_components/subscription-card' import { FreshStartModal } from './_components/fresh-start-modal' import { DeleteAccountModal } from './_components/delete-account-modal' +import { EditNameSheet } from './_components/edit-name-sheet' import { ProfileNavIcon } from './_components/profile-nav-icon' import { ProfileActionButton } from './_components/profile-action-button' import { TourReplayModal } from './_components/tour-replay-modal' @@ -110,6 +111,7 @@ export default function ProfilePage() { const [showResetModal, setShowResetModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) const [showTourReplay, setShowTourReplay] = useState(false) + const [showEditName, setShowEditName] = useState(false) const [isExporting, setIsExporting] = useState(false) const [exportError, setExportError] = useState(null) @@ -220,19 +222,34 @@ export default function ProfilePage() { ) : ( <> {showPlanBadge && {planBadgeLabel}} - setShowEditName(true)} + className="flex max-w-full cursor-pointer appearance-none items-center border-0 bg-transparent p-0" + style={{ gap: 8, minHeight: 44 }} > - {profile?.name} - + + {profile?.name} + +