Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions apps/mobile/__tests__/components/profile/edit-name-sheet.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): TestNode[]
}

interface TestInstance {
root: TestTreeRoot
}

interface TestRendererApi {
create(element: React.ReactNode): TestInstance
act(callback: () => Promise<void> | void): Promise<void>
}

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(
<QueryClientProvider client={queryClient}>
<EditNameSheet open onClose={onClose} />
</QueryClientProvider>,
)
})
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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/__tests__/screens/profile-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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'),
}
Expand Down
33 changes: 27 additions & 6 deletions apps/mobile/app/(tabs)/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
CreditCard,
Download,
LogOut,
Pencil,
RotateCcw,
TriangleAlert,
User as UserIcon,
Expand Down Expand Up @@ -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<typeof createTokensV2>
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -566,12 +569,21 @@ export default function ProfileScreen() {
{planBadgeLabel}
</Badge>
) : null}
<Text
style={[styles.identityName, { color: tokens.fg1 }]}
numberOfLines={1}
<Pressable
onPress={() => setShowEditName(true)}
accessibilityRole="button"
accessibilityLabel={t('profile.editName.title')}
hitSlop={8}
style={styles.identityNameButton}
>
{profile?.name}
</Text>
<Text
style={[styles.identityName, { color: tokens.fg1 }]}
numberOfLines={1}
>
{profile?.name}
</Text>
<Pencil size={16} strokeWidth={1.8} color={tokens.fg3} />
</Pressable>
<Text
style={[styles.identityLine, { color: tokens.fg2 }]}
numberOfLines={1}
Expand Down Expand Up @@ -877,6 +889,8 @@ export default function ProfileScreen() {
</KeyboardAwareScrollView>
</Modal>

<EditNameSheet open={showEditName} onClose={() => setShowEditName(false)} />

<TourReplayModal
visible={showTourReplay}
onClose={() => setShowTourReplay(false)}
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading