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
38 changes: 23 additions & 15 deletions apps/mobile/src/app/(tabs)/fertilizers.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import { Modal, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'
import { KeyboardAvoidingView, Modal, Platform, ScrollView, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'
import { useLayoutEffect } from 'react'
import { useLocalSearchParams, useNavigation } from 'expo-router'
import { keepPreviousData } from '@tanstack/react-query'
Expand Down Expand Up @@ -545,7 +545,7 @@ export function FertilizersScreen() {
)
}

function AddEditFertilizerModal({
export function AddEditFertilizerModal({
formData,
isVisible,
mode,
Expand Down Expand Up @@ -582,7 +582,11 @@ function AddEditFertilizerModal({
presentationStyle='fullScreen'
onRequestClose={onClose}
>
<View style={{ flex: 1, backgroundColor: palette.background }}>
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: palette.background }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 20, paddingTop: 60, borderBottomWidth: 1, borderBottomColor: palette.border, backgroundColor: palette.surface }}>
<Text style={styles.formTitle}>
{mode === 'add' ? 'Add New Fertilizer' : 'Edit Fertilizer'}
Expand All @@ -591,7 +595,11 @@ function AddEditFertilizerModal({
<Text style={{ fontSize: 24, color: palette.textPrimary }}>✕</Text>
</TouchableOpacity>
</View>
<ScrollView style={[styles.formContainer, { flex: 1 }]}>
<ScrollView
style={[styles.formContainer, { flex: 1 }]}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: 24 }}
>
<Text style={styles.inputLabel}>Name</Text>
<TextInput
style={styles.input}
Expand All @@ -600,6 +608,16 @@ function AddEditFertilizerModal({
onChangeText={(text) => onChange({ ...formData, name: text })}
/>

<Text style={styles.inputLabel}>Notes (optional)</Text>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="Notes (optional)"
value={formData.notes}
onChangeText={(text) => onChange({ ...formData, notes: text })}
multiline
numberOfLines={3}
/>

<Text style={styles.inputLabel}>Type</Text>
<View
style={localStyles.typeContainer}
Expand Down Expand Up @@ -664,16 +682,6 @@ function AddEditFertilizerModal({
keyboardType="numeric"
/>

<Text style={styles.inputLabel}>Notes (optional)</Text>
<TextInput
style={[styles.input, styles.textArea]}
placeholder="Notes (optional)"
value={formData.notes}
onChangeText={(text) => onChange({ ...formData, notes: text })}
multiline
numberOfLines={3}
/>

{mutation.error && (
<Text style={styles.errorText}>
Error: {mutation.error.message}
Expand All @@ -699,7 +707,7 @@ function AddEditFertilizerModal({
</Text>
</TouchableOpacity>
</ScrollView>
</View>
</KeyboardAvoidingView>
</Modal>
)
}
Expand Down
260 changes: 260 additions & 0 deletions apps/mobile/src/app/__tests__/keyboard-visibility.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
import React, { act } from 'react'
import { render, fireEvent, screen } from '@testing-library/react-native'

import { AddEditChoreModal } from '../../components/AddEditChoreModal'

import { AddEditFertilizerModal } from '../(tabs)/fertilizers'
import AddEditPlantScreen from '../plants/add-edit'

// Mock vector icons so Icon components don't trigger async updates (e.g. font load) that cause act(...) warnings.
// Factory must not reference out-of-scope variables; require inside the factory.
jest.mock('@expo/vector-icons', () => {
const React = require('react')
const { View } = require('react-native')

return {
Ionicons: (props: unknown) => React.createElement(View, { ...(props as object), testID: 'icon' }),
}
})

// Mocks for navigation and routing used by the screens
jest.mock('expo-router', () => ({
useLocalSearchParams: jest.fn(() => ({})),
useNavigation: jest.fn(() => ({
getParent: () => ({ setOptions: jest.fn() }),
})),
useRouter: jest.fn(() => ({
back: jest.fn(),
})),
}))

// Mock Alert context to avoid needing a real provider
jest.mock('../../contexts/AlertContext', () => ({
useAlert: () => ({
alert: jest.fn(),
}),
}))

// Avoid needing a real React Query client for focus refresh logic
jest.mock('../../hooks/useRefetchOnFocus', () => ({
useRefreshOnFocus: jest.fn(),
}))

// Mock tRPC hooks used by the screens
jest.mock('../../trpc', () => {
const plantsListUseQuery = jest.fn()
const plantsCreateUseMutation = jest.fn()
const plantsUpdateUseMutation = jest.fn()
const plantsIdentifyUseMutation = jest.fn()
const speciesListUseQuery = jest.fn()

const fertilizersListUseQuery = jest.fn()
const fertilizersCreateUseMutation = jest.fn()
const fertilizersUpdateUseMutation = jest.fn()
const fertilizersDeleteUseMutation = jest.fn()
const fertilizersArchiveUseMutation = jest.fn()
const fertilizersUnarchiveUseMutation = jest.fn()

return {
trpc: {
plants: {
list: { useQuery: plantsListUseQuery },
create: { useMutation: plantsCreateUseMutation },
update: { useMutation: plantsUpdateUseMutation },
identifyByImages: { useMutation: plantsIdentifyUseMutation },
},
species: {
list: { useQuery: speciesListUseQuery },
},
fertilizers: {
list: { useQuery: fertilizersListUseQuery },
create: { useMutation: fertilizersCreateUseMutation },
update: { useMutation: fertilizersUpdateUseMutation },
delete: { useMutation: fertilizersDeleteUseMutation },
archive: { useMutation: fertilizersArchiveUseMutation },
unarchive: { useMutation: fertilizersUnarchiveUseMutation },
},
},
}
})

const getTrpcMock = () => (jest.requireMock('../../trpc').trpc as any)

describe('Keyboard visibility for multiline inputs', () => {
describe('Add/Edit Chore modal', () => {
it('keeps Notes input scrollable when focused so it can move above the keyboard', () => {
const formData = {
description: '',
lifecycles: [],
fertilizers: [],
recurAmount: '',
recurUnit: '',
notes: '',
} as any

const { getByPlaceholderText } = render(
<AddEditChoreModal
fertilizersData={{ fertilizers: [] }}
formData={formData}
isVisible={true}
mode="add"
mutation={{ error: null, isPending: false }}
onChange={jest.fn()}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>
)

const notesInput = getByPlaceholderText('Notes (optional)')

expect(notesInput.props.multiline).toBe(true)
expect(notesInput.props.scrollEnabled).toBe(false)

act(() => {
fireEvent(notesInput, 'focus')
})
const notesFocused = getByPlaceholderText('Notes (optional)')
expect(notesFocused.props.scrollEnabled).toBe(true)

act(() => {
fireEvent(notesFocused, 'blur')
})
const notesBlurred = getByPlaceholderText('Notes (optional)')
expect(notesBlurred.props.scrollEnabled).toBe(false)
})
})

describe('Add/Edit Fertilizer modal', () => {
it('keeps Notes input scrollable when focused so it can move above the keyboard', () => {
const { getByPlaceholderText } = render(
<AddEditFertilizerModal
formData={{
name: '',
type: 'liquid',
isOrganic: false,
notes: '',
nitrogen: '',
phosphorus: '',
potassium: '',
}}
isVisible={true}
mode="add"
mutation={{ error: null, isPending: false }}
onChange={jest.fn()}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>
)

const notesInput = getByPlaceholderText('Notes (optional)')

expect(notesInput.props.multiline).toBe(true)
expect(notesInput.props.scrollEnabled).toBe(false)

act(() => {
fireEvent(notesInput, 'focus')
})
const notesFocused = getByPlaceholderText('Notes (optional)')
expect(notesFocused.props.scrollEnabled).toBe(true)

act(() => {
fireEvent(notesFocused, 'blur')
})
const notesBlurred = getByPlaceholderText('Notes (optional)')
expect(notesBlurred.props.scrollEnabled).toBe(false)
})
})

describe('Add/Edit Plant screen', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('hides Notes on Add until a species is set, then keeps it scrollable when focused', () => {
const trpc = getTrpcMock()

// In Add mode: plants.list is disabled, but species.list may be called
trpc.plants.list.useQuery.mockReturnValue({
data: { plants: [] },
isLoading: false,
isError: false,
error: null,
isRefetching: false,
refetch: jest.fn(),
})

trpc.species.list.useQuery.mockReturnValue({
data: { species: [] },
isLoading: false,
})

trpc.plants.create.useMutation.mockReturnValue({
mutate: jest.fn(),
isPending: false,
error: null,
})

trpc.plants.update.useMutation.mockReturnValue({
mutate: jest.fn(),
isPending: false,
error: null,
})

trpc.plants.identifyByImages.useMutation.mockReturnValue({
mutate: jest.fn(),
isPending: false,
error: null,
})

// Add mode: no id in params
const expoRouter = jest.requireMock('expo-router')
expoRouter.useLocalSearchParams.mockReturnValue({})

const { rerender } = render(<AddEditPlantScreen />)

// Simulate that a species has been chosen by setting speciesId in form data via rerender.
// We can't access internal state directly, but we can re-render the screen in "edit" mode
// with a plant that has speciesId set, which uses the same Notes rendering logic.
expoRouter.useLocalSearchParams.mockReturnValue({ id: 'plant-1' })

trpc.plants.list.useQuery.mockReturnValue({
data: {
plants: [
{
_id: 'plant-1',
name: 'Tomato',
plantedAt: null,
lifecycle: 'start',
notes: '',
species: { _id: 'species-1' },
},
],
},
isLoading: false,
isError: false,
error: null,
isRefetching: false,
refetch: jest.fn(),
})

rerender(<AddEditPlantScreen />)

const notesInput = screen.getByPlaceholderText('Notes (optional)')
expect(notesInput.props.multiline).toBe(true)
expect(notesInput.props.scrollEnabled).toBe(false)

act(() => {
fireEvent(notesInput, 'focus')
})
const notesFocused = screen.getByPlaceholderText('Notes (optional)')
expect(notesFocused.props.scrollEnabled).toBe(true)

act(() => {
fireEvent(notesFocused, 'blur')
})
const notesBlurred = screen.getByPlaceholderText('Notes (optional)')
expect(notesBlurred.props.scrollEnabled).toBe(false)
})
})
})

Loading