fix: bug
This commit is contained in:
@@ -5,6 +5,13 @@ import { Stack, usePathname, useRouter } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import { useEffect } from 'react'
|
||||
import 'react-native-reanimated'
|
||||
import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-reanimated'
|
||||
|
||||
// 禁用 Reanimated strict 模式警告(第三方库导致的警告)
|
||||
configureReanimatedLogger({
|
||||
level: ReanimatedLogLevel.warn,
|
||||
strict: false,
|
||||
})
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
import { View, ActivityIndicator, AppState, LogBox } from 'react-native'
|
||||
import * as Updates from 'expo-updates'
|
||||
|
||||
136
app/membership.test.tsx
Normal file
136
app/membership.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react-native'
|
||||
import { View } from 'react-native'
|
||||
import MembershipScreen from './membership'
|
||||
import { useMembership } from '@/hooks/use-membership'
|
||||
|
||||
jest.mock('expo-router', () => ({
|
||||
useRouter: jest.fn(() => ({
|
||||
back: jest.fn(),
|
||||
push: jest.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: jest.fn(() => ({
|
||||
t: (key: string) => key,
|
||||
})),
|
||||
}))
|
||||
|
||||
jest.mock('expo-status-bar', () => ({
|
||||
StatusBar: 'StatusBar',
|
||||
}))
|
||||
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
SafeAreaView: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||
}))
|
||||
|
||||
jest.mock('expo-linear-gradient', () => ({
|
||||
LinearGradient: 'LinearGradient',
|
||||
}))
|
||||
|
||||
jest.mock('expo-image', () => ({
|
||||
Image: 'Image',
|
||||
}))
|
||||
|
||||
jest.mock('react-native-reanimated-carousel', () => 'Carousel')
|
||||
|
||||
jest.mock('react-native-svg', () => ({
|
||||
__esModule: true,
|
||||
default: 'Svg',
|
||||
Svg: 'Svg',
|
||||
Path: 'Path',
|
||||
Defs: 'Defs',
|
||||
LinearGradient: 'LinearGradient',
|
||||
Stop: 'Stop',
|
||||
}))
|
||||
|
||||
jest.mock('@/components/icon', () => ({
|
||||
LeftArrowIcon: 'LeftArrowIcon',
|
||||
OmitIcon: 'OmitIcon',
|
||||
CheckIcon: 'CheckIcon',
|
||||
UncheckedIcon: 'UncheckedIcon',
|
||||
TermsIcon: 'TermsIcon',
|
||||
PrivacyIcon: 'PrivacyIcon',
|
||||
}))
|
||||
|
||||
jest.mock('@/components/icon/checkMark', () => ({
|
||||
CheckMarkIcon: 'CheckMarkIcon',
|
||||
}))
|
||||
|
||||
jest.mock('@/components/drawer/PointsDrawer', () => 'PointsDrawer')
|
||||
jest.mock('@/components/ui/dropdown', () => 'Dropdown')
|
||||
jest.mock('@/components/GradientText', () => 'GradientText')
|
||||
|
||||
jest.mock('@/hooks/use-membership')
|
||||
|
||||
describe('MembershipScreen - 订阅按钮Loading状态', () => {
|
||||
const mockUseMembership = useMembership as jest.MockedFunction<typeof useMembership>
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockUseMembership.mockReturnValue({
|
||||
creditPlans: [
|
||||
{ amountInCents: 1000, credits: 750, popular: false },
|
||||
{ amountInCents: 2000, credits: 1500, popular: true },
|
||||
],
|
||||
creditBalance: 100,
|
||||
selectedPlanIndex: 0,
|
||||
setSelectedPlanIndex: jest.fn(),
|
||||
isLoadingSubscriptions: false,
|
||||
isStripePricingLoading: false,
|
||||
createSubscription: jest.fn(),
|
||||
upgradeSubscription: jest.fn(),
|
||||
restoreSubscription: jest.fn(),
|
||||
rechargeToken: jest.fn(),
|
||||
activeAuthSubscription: null,
|
||||
hasActiveSubscription: false,
|
||||
stripePricingData: {
|
||||
pricing_table_items: [
|
||||
{ price_id: 'price-1', product_id: 'prod-1' },
|
||||
{ price_id: 'price-2', product_id: 'prod-2' },
|
||||
],
|
||||
},
|
||||
} as any)
|
||||
})
|
||||
|
||||
it('应该在点击订阅按钮后显示loading状态', async () => {
|
||||
const createSubscription = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||
mockUseMembership.mockReturnValue({
|
||||
...mockUseMembership(),
|
||||
createSubscription,
|
||||
} as any)
|
||||
|
||||
const { getByText, getByTestId } = render(<MembershipScreen />)
|
||||
|
||||
const checkbox = getByText('membership.agreementText').parent?.parent
|
||||
if (checkbox) fireEvent.press(checkbox)
|
||||
|
||||
const subscribeButton = getByText('membership.subscribeNow')
|
||||
fireEvent.press(subscribeButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSubscription).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('应该在订阅过程中禁用按钮', async () => {
|
||||
mockUseMembership.mockReturnValue({
|
||||
...mockUseMembership(),
|
||||
isLoadingSubscriptions: true,
|
||||
} as any)
|
||||
|
||||
const { getByText } = render(<MembershipScreen />)
|
||||
|
||||
const subscribeButton = getByText('membership.subscribeNow').parent
|
||||
expect(subscribeButton?.props.accessibilityState?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('应该在未同意协议时禁用按钮', () => {
|
||||
const { getByText } = render(<MembershipScreen />)
|
||||
|
||||
const subscribeButton = getByText('membership.subscribeNow').parent
|
||||
expect(subscribeButton?.props.accessibilityState?.disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -68,6 +68,7 @@ export default function MembershipScreen() {
|
||||
const { width: screenWidth } = useWindowDimensions()
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
||||
const [isSubscribing, setIsSubscribing] = useState(false)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -138,24 +139,29 @@ export default function MembershipScreen() {
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||
|
||||
// 处理订阅按钮点击
|
||||
const handleSubscribe = () => {
|
||||
const handleSubscribe = async () => {
|
||||
if (!agreed || !selectedPlan) return
|
||||
|
||||
const planData = creditPlans[selectedPlanIndex]
|
||||
if (!planData) return
|
||||
|
||||
if (hasActiveSubscription) {
|
||||
upgradeSubscription({ credits: planData.credits })
|
||||
} else if (activeAuthSubscription?.cancelAtPeriodEnd) {
|
||||
restoreSubscription()
|
||||
} else {
|
||||
const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex]
|
||||
if (pricingItem) {
|
||||
createSubscription({
|
||||
priceId: pricingItem.price_id,
|
||||
productId: pricingItem.product_id,
|
||||
})
|
||||
setIsSubscribing(true)
|
||||
try {
|
||||
if (hasActiveSubscription) {
|
||||
await upgradeSubscription({ credits: planData.credits })
|
||||
} else if (activeAuthSubscription?.cancelAtPeriodEnd) {
|
||||
await restoreSubscription()
|
||||
} else {
|
||||
const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex]
|
||||
if (pricingItem) {
|
||||
await createSubscription({
|
||||
priceId: pricingItem.price_id,
|
||||
productId: pricingItem.product_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsSubscribing(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,7 +399,7 @@ export default function MembershipScreen() {
|
||||
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
||||
{/* 立即开通按钮 */}
|
||||
<Pressable
|
||||
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading}
|
||||
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
|
||||
style={styles.subscribeButtonPressable}
|
||||
onPress={handleSubscribe}
|
||||
>
|
||||
@@ -410,10 +416,10 @@ export default function MembershipScreen() {
|
||||
end={{ x: 0, y: 0 }}
|
||||
style={[
|
||||
styles.subscribeButton,
|
||||
(!agreed || isLoadingSubscriptions || isStripePricingLoading) && styles.subscribeButtonDisabled,
|
||||
(!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled,
|
||||
]}
|
||||
>
|
||||
{isLoadingSubscriptions || isStripePricingLoading ? (
|
||||
{isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? (
|
||||
<ActivityIndicator color="#F5F5F5" />
|
||||
) : (
|
||||
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import * as ImagePicker from 'expo-image-picker'
|
||||
|
||||
import { Button } from './ui/button'
|
||||
import Text from './ui/Text'
|
||||
@@ -84,410 +85,419 @@ interface DynamicFormProps {
|
||||
|
||||
export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
||||
function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer, points }, ref) {
|
||||
const { t } = useTranslation()
|
||||
const startNodes = formSchema.startNodes || []
|
||||
const { t } = useTranslation()
|
||||
const startNodes = formSchema.startNodes || []
|
||||
|
||||
// Initialize form state for each node
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initialData: Record<string, string> = {}
|
||||
startNodes.forEach((node) => {
|
||||
if (node.type === 'text') {
|
||||
// Get default value from data.text or output.texts[0]
|
||||
const texts = node.data?.output?.texts
|
||||
initialData[node.id] = texts?.[0] || node.data?.text || ''
|
||||
} else if (node.type === 'select') {
|
||||
// Get default value from output.selections
|
||||
initialData[node.id] = node.data?.output?.selections || ''
|
||||
} else if (node.type === 'image') {
|
||||
// Get default value from output.images
|
||||
const images = node.data?.output?.images
|
||||
initialData[node.id] = images?.[0]?.url || ''
|
||||
} else if (node.type === 'video') {
|
||||
// Get default value from output.videos
|
||||
const videos = node.data?.output?.videos
|
||||
initialData[node.id] = videos?.[0]?.url || ''
|
||||
} else {
|
||||
initialData[node.id] = ''
|
||||
}
|
||||
})
|
||||
return initialData
|
||||
})
|
||||
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [currentNodeId, setCurrentNodeId] = useState<string | null>(null)
|
||||
const [previewImages, setPreviewImages] = useState<Record<string, string>>(() => {
|
||||
const initialPreviews: Record<string, string> = {}
|
||||
startNodes.forEach((node) => {
|
||||
if (node.type === 'image') {
|
||||
const images = node.data?.output?.images
|
||||
if (images?.[0]?.url) {
|
||||
initialPreviews[node.id] = images[0].url
|
||||
// Initialize form state for each node
|
||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||
const initialData: Record<string, string> = {}
|
||||
startNodes.forEach((node) => {
|
||||
if (node.type === 'text') {
|
||||
// Get default value from data.text or output.texts[0]
|
||||
const texts = node.data?.output?.texts
|
||||
initialData[node.id] = texts?.[0] || node.data?.text || ''
|
||||
} else if (node.type === 'select') {
|
||||
// Get default value from output.selections
|
||||
initialData[node.id] = node.data?.output?.selections || ''
|
||||
} else if (node.type === 'image') {
|
||||
// Get default value from output.images
|
||||
const images = node.data?.output?.images
|
||||
initialData[node.id] = images?.[0]?.url || ''
|
||||
} else if (node.type === 'video') {
|
||||
// Get default value from output.videos
|
||||
const videos = node.data?.output?.videos
|
||||
initialData[node.id] = videos?.[0]?.url || ''
|
||||
} else {
|
||||
initialData[node.id] = ''
|
||||
}
|
||||
} else if (node.type === 'video') {
|
||||
const videos = node.data?.output?.videos
|
||||
if (videos?.[0]?.url) {
|
||||
initialPreviews[node.id] = videos[0].url
|
||||
})
|
||||
return initialData
|
||||
})
|
||||
|
||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||
const [currentNodeId, setCurrentNodeId] = useState<string | null>(null)
|
||||
const [previewImages, setPreviewImages] = useState<Record<string, string>>(() => {
|
||||
const initialPreviews: Record<string, string> = {}
|
||||
startNodes.forEach((node) => {
|
||||
if (node.type === 'image') {
|
||||
const images = node.data?.output?.images
|
||||
if (images?.[0]?.url) {
|
||||
initialPreviews[node.id] = images[0].url
|
||||
}
|
||||
} else if (node.type === 'video') {
|
||||
const videos = node.data?.output?.videos
|
||||
if (videos?.[0]?.url) {
|
||||
initialPreviews[node.id] = videos[0].url
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return initialPreviews
|
||||
})
|
||||
return initialPreviews
|
||||
})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const updateFormData = useCallback((nodeId: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [nodeId]: value }))
|
||||
// Clear error for this field when user updates it
|
||||
if (errors[nodeId]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[nodeId]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}, [errors])
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
updateFieldValue: (nodeId: string, value: string, previewUri?: string) => {
|
||||
updateFormData(nodeId, value)
|
||||
if (previewUri) {
|
||||
setPreviewImages((prev) => ({ ...prev, [nodeId]: previewUri }))
|
||||
}
|
||||
},
|
||||
}), [updateFormData])
|
||||
|
||||
const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => {
|
||||
if (!currentNodeId) return
|
||||
|
||||
try {
|
||||
const url = await uploadFile({ uri: imageUri, mimeType, fileName })
|
||||
updateFormData(currentNodeId, url)
|
||||
setPreviewImages((prev) => ({ ...prev, [currentNodeId]: imageUri }))
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
Toast.show({
|
||||
title: t('dynamicForm.uploadFailed') || '上传失败,请重试'
|
||||
})
|
||||
} finally {
|
||||
setDrawerVisible(false)
|
||||
setCurrentNodeId(null)
|
||||
}
|
||||
}, [currentNodeId, t, updateFormData])
|
||||
|
||||
// 直接从相册选择图片并上传
|
||||
const handlePickAndUploadImage = useCallback(async (nodeId: string) => {
|
||||
try {
|
||||
const [imageUri] = await imgPicker({ maxImages: 1 })
|
||||
console.log('[DynamicForm] 选择图片成功:', imageUri)
|
||||
|
||||
// 上传图片
|
||||
const url = await uploadFile({ uri: imageUri })
|
||||
console.log('[DynamicForm] 上传成功,URL:', url)
|
||||
updateFormData(nodeId, url)
|
||||
setPreviewImages((prev) => ({ ...prev, [nodeId]: imageUri }))
|
||||
} catch (error: any) {
|
||||
// 用户取消选择不显示错误
|
||||
if (error?.message === '未选择任何图片') {
|
||||
return
|
||||
}
|
||||
console.error('[DynamicForm] 上传失败:', { error, message: error?.message, stack: error?.stack })
|
||||
Toast.show(t('dynamicForm.uploadFailed') || '上传失败,请重试')
|
||||
}
|
||||
}, [t, updateFormData])
|
||||
|
||||
const handleSelectVideo = useCallback(async (videoUri: string, mimeType?: string, fileName?: string) => {
|
||||
if (!currentNodeId) return
|
||||
|
||||
try {
|
||||
const url = await uploadFile({ uri: videoUri, mimeType, fileName })
|
||||
updateFormData(currentNodeId, url)
|
||||
setPreviewImages((prev) => ({ ...prev, [currentNodeId]: videoUri }))
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
Toast.show({
|
||||
title: t('dynamicForm.uploadFailed') || '上传失败,请重试'
|
||||
})
|
||||
} finally {
|
||||
setDrawerVisible(false)
|
||||
setCurrentNodeId(null)
|
||||
}
|
||||
}, [currentNodeId, t, updateFormData])
|
||||
|
||||
const validateForm = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
startNodes.forEach((node) => {
|
||||
const value = formData[node.id]
|
||||
if (!value || value.trim() === '') {
|
||||
const label = node.data?.label || t('dynamicForm.field') || '字段'
|
||||
newErrors[node.id] = `${label}${t('dynamicForm.required') || '不能为空'}`
|
||||
}
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [formData, startNodes, t])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateForm()) {
|
||||
Toast.show({
|
||||
title: t('dynamicForm.fillRequiredFields') || '请填写所有必填项'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Toast.showLoading()
|
||||
try {
|
||||
const result = await onSubmit(formData)
|
||||
Toast.hideLoading()
|
||||
|
||||
if (result.error) {
|
||||
Toast.show({
|
||||
title: result.error.message || t('dynamicForm.submitFailed') || '提交失败'
|
||||
const updateFormData = useCallback((nodeId: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [nodeId]: value }))
|
||||
// Clear error for this field when user updates it
|
||||
if (errors[nodeId]) {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[nodeId]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.hideLoading()
|
||||
Toast.show({
|
||||
title: t('dynamicForm.submitFailed') || '提交失败'
|
||||
}, [errors])
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
updateFieldValue: (nodeId: string, value: string, previewUri?: string) => {
|
||||
updateFormData(nodeId, value)
|
||||
if (previewUri) {
|
||||
setPreviewImages((prev) => ({ ...prev, [nodeId]: previewUri }))
|
||||
}
|
||||
},
|
||||
}), [updateFormData])
|
||||
|
||||
const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => {
|
||||
if (!currentNodeId) return
|
||||
|
||||
try {
|
||||
const url = await uploadFile({ uri: imageUri, mimeType, fileName })
|
||||
updateFormData(currentNodeId, url)
|
||||
setPreviewImages((prev) => ({ ...prev, [currentNodeId]: imageUri }))
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
Toast.show({
|
||||
title: t('dynamicForm.uploadFailed') || '上传失败,请重试'
|
||||
})
|
||||
} finally {
|
||||
setDrawerVisible(false)
|
||||
setCurrentNodeId(null)
|
||||
}
|
||||
}, [currentNodeId, t, updateFormData])
|
||||
|
||||
// 直接从相册选择图片并上传
|
||||
const handlePickAndUploadImage = useCallback(async (nodeId: string) => {
|
||||
try {
|
||||
const [asset] = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' })
|
||||
if (!asset) {
|
||||
console.warn('No asset selected')
|
||||
return
|
||||
}
|
||||
if (asset?.uri) {
|
||||
Toast.showLoading({ title: '文件传输中...', duration: 0 })
|
||||
console.log('[DynamicForm] 选择图片成功:', asset)
|
||||
// 上传图片
|
||||
const url = await uploadFile({ uri: asset.uri })
|
||||
console.log('[DynamicForm] 上传成功,URL:', url)
|
||||
updateFormData(nodeId, url)
|
||||
setPreviewImages((prev) => ({ ...prev, [nodeId]: url }))
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
// 用户取消选择不显示错误
|
||||
if (error?.message === '未选择任何图片') {
|
||||
return
|
||||
}
|
||||
console.error('[DynamicForm] 上传失败:', { error, message: error?.message, stack: error?.stack })
|
||||
Toast.show(t('dynamicForm.uploadFailed') || '上传失败,请重试')
|
||||
} finally {
|
||||
Toast.hideLoading()
|
||||
}
|
||||
}, [t, updateFormData])
|
||||
|
||||
const handleSelectVideo = useCallback(async (videoUri: string, mimeType?: string, fileName?: string) => {
|
||||
if (!currentNodeId) return
|
||||
|
||||
try {
|
||||
const url = await uploadFile({ uri: videoUri, mimeType, fileName })
|
||||
updateFormData(currentNodeId, url)
|
||||
setPreviewImages((prev) => ({ ...prev, [currentNodeId]: videoUri }))
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
Toast.show({
|
||||
title: t('dynamicForm.uploadFailed') || '上传失败,请重试'
|
||||
})
|
||||
} finally {
|
||||
setDrawerVisible(false)
|
||||
setCurrentNodeId(null)
|
||||
}
|
||||
}, [currentNodeId, t, updateFormData])
|
||||
|
||||
const validateForm = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
startNodes.forEach((node) => {
|
||||
const value = formData[node.id]
|
||||
if (!value || value.trim() === '') {
|
||||
const label = node.data?.label || t('dynamicForm.field') || '字段'
|
||||
newErrors[node.id] = `${label}${t('dynamicForm.required') || '不能为空'}`
|
||||
}
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [formData, startNodes, t])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateForm()) {
|
||||
Toast.show({
|
||||
title: t('dynamicForm.fillRequiredFields') || '请填写所有必填项'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Toast.showLoading()
|
||||
try {
|
||||
const result = await onSubmit(formData)
|
||||
Toast.hideLoading()
|
||||
|
||||
if (result.error) {
|
||||
Toast.show({
|
||||
title: result.error.message || t('dynamicForm.submitFailed') || '提交失败'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.hideLoading()
|
||||
Toast.show({
|
||||
title: t('dynamicForm.submitFailed') || '提交失败'
|
||||
})
|
||||
}
|
||||
}, [formData, onSubmit, validateForm, t])
|
||||
|
||||
const renderTextField = (node: StartNode) => {
|
||||
const value = formData[node.id] || ''
|
||||
const error = errors[node.id]
|
||||
const placeholder = node.data?.description || t('dynamicForm.enterText') || '请输入文本'
|
||||
|
||||
return (
|
||||
<View key={node.id} style={styles.fieldContainer}>
|
||||
{node.data?.label && (
|
||||
<Text style={styles.label}>
|
||||
{node.data.label}
|
||||
<Text style={styles.required}> *</Text>
|
||||
</Text>
|
||||
)}
|
||||
<TextInput
|
||||
testID={`text-input-${node.id}`}
|
||||
style={[styles.textInput, error && styles.textInputError]}
|
||||
value={value}
|
||||
onChangeText={(text) => updateFormData(node.id, text)}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#8A8A8A"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}, [formData, onSubmit, validateForm, t])
|
||||
|
||||
const renderTextField = (node: StartNode) => {
|
||||
const value = formData[node.id] || ''
|
||||
const error = errors[node.id]
|
||||
const placeholder = node.data?.description || t('dynamicForm.enterText') || '请输入文本'
|
||||
const renderImageField = (node: StartNode) => {
|
||||
const value = formData[node.id]
|
||||
const previewUri = previewImages[node.id]
|
||||
const error = errors[node.id]
|
||||
const label = node.data?.label || t('dynamicForm.image') || '图片'
|
||||
|
||||
return (
|
||||
<View key={node.id} style={styles.fieldContainer}>
|
||||
{node.data?.label && (
|
||||
return (
|
||||
<View key={node.id} style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>
|
||||
{node.data.label}
|
||||
{label}
|
||||
<Text style={styles.required}> *</Text>
|
||||
</Text>
|
||||
)}
|
||||
<TextInput
|
||||
testID={`text-input-${node.id}`}
|
||||
style={[styles.textInput, error && styles.textInputError]}
|
||||
value={value}
|
||||
onChangeText={(text) => updateFormData(node.id, text)}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#8A8A8A"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
<Pressable
|
||||
style={styles.uploadButton}
|
||||
onPress={() => handlePickAndUploadImage(node.id)}
|
||||
>
|
||||
{previewUri ? (
|
||||
<Image
|
||||
source={previewUri}
|
||||
style={styles.previewImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.uploadPlaceholder}>
|
||||
<Text style={styles.uploadText}>
|
||||
{t('dynamicForm.uploadImage') || '点击上传图片'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const renderImageField = (node: StartNode) => {
|
||||
const value = formData[node.id]
|
||||
const previewUri = previewImages[node.id]
|
||||
const error = errors[node.id]
|
||||
const label = node.data?.label || t('dynamicForm.image') || '图片'
|
||||
const renderVideoField = (node: StartNode) => {
|
||||
const value = formData[node.id]
|
||||
const previewUri = previewImages[node.id]
|
||||
const error = errors[node.id]
|
||||
const label = node.data?.label || t('dynamicForm.video') || '视频'
|
||||
|
||||
return (
|
||||
<View key={node.id} style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>
|
||||
{label}
|
||||
<Text style={styles.required}> *</Text>
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.uploadButton}
|
||||
onPress={() => handlePickAndUploadImage(node.id)}
|
||||
>
|
||||
{previewUri ? (
|
||||
<Image
|
||||
source={previewUri}
|
||||
style={styles.previewImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.uploadPlaceholder}>
|
||||
<Text style={styles.uploadText}>
|
||||
{t('dynamicForm.uploadImage') || '点击上传图片'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View key={node.id} style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>
|
||||
{label}
|
||||
<Text style={styles.required}> *</Text>
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.uploadButton}
|
||||
onPress={() => {
|
||||
setCurrentNodeId(node.id)
|
||||
if (onOpenDrawer) {
|
||||
onOpenDrawer(node.id)
|
||||
} else {
|
||||
setDrawerVisible(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{previewUri ? (
|
||||
<View style={styles.videoPreview}>
|
||||
<Text style={styles.uploadText}>
|
||||
{t('dynamicForm.videoUploaded') || '视频已上传'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.uploadPlaceholder}>
|
||||
<Text style={styles.uploadText}>
|
||||
{t('dynamicForm.uploadVideo') || '点击上传视频'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const renderVideoField = (node: StartNode) => {
|
||||
const value = formData[node.id]
|
||||
const previewUri = previewImages[node.id]
|
||||
const error = errors[node.id]
|
||||
const label = node.data?.label || t('dynamicForm.video') || '视频'
|
||||
const renderSelectField = (node: StartNode) => {
|
||||
const value = formData[node.id]
|
||||
const error = errors[node.id]
|
||||
const label = node.data?.label || t('dynamicForm.select') || '选择'
|
||||
const options = node.data?.actionData?.options || []
|
||||
const placeholder = node.data?.actionData?.placeholder || t('dynamicForm.selectPlaceholder') || '请选择'
|
||||
const allowMultiple = node.data?.actionData?.allowMultiple || false
|
||||
|
||||
return (
|
||||
<View key={node.id} style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>
|
||||
{label}
|
||||
<Text style={styles.required}> *</Text>
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.uploadButton}
|
||||
onPress={() => {
|
||||
setCurrentNodeId(node.id)
|
||||
if (onOpenDrawer) {
|
||||
onOpenDrawer(node.id)
|
||||
} else {
|
||||
setDrawerVisible(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{previewUri ? (
|
||||
<View style={styles.videoPreview}>
|
||||
<Text style={styles.uploadText}>
|
||||
{t('dynamicForm.videoUploaded') || '视频已上传'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.uploadPlaceholder}>
|
||||
<Text style={styles.uploadText}>
|
||||
{t('dynamicForm.uploadVideo') || '点击上传视频'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSelectField = (node: StartNode) => {
|
||||
const value = formData[node.id]
|
||||
const error = errors[node.id]
|
||||
const label = node.data?.label || t('dynamicForm.select') || '选择'
|
||||
const options = node.data?.actionData?.options || []
|
||||
const placeholder = node.data?.actionData?.placeholder || t('dynamicForm.selectPlaceholder') || '请选择'
|
||||
const allowMultiple = node.data?.actionData?.allowMultiple || false
|
||||
|
||||
return (
|
||||
<View key={node.id} style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>
|
||||
{label}
|
||||
<Text style={styles.required}> *</Text>
|
||||
</Text>
|
||||
{allowMultiple ? (
|
||||
// Multi-select: render as a list of checkboxes
|
||||
<View style={styles.optionsContainer}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<Pressable
|
||||
key={index}
|
||||
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
|
||||
onPress={() => updateFormData(node.id, option.value)}
|
||||
>
|
||||
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
// Single select: render as a list of radio buttons
|
||||
<View style={styles.optionsContainer}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<Pressable
|
||||
key={index}
|
||||
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
|
||||
onPress={() => updateFormData(node.id, option.value)}
|
||||
>
|
||||
<View style={styles.radioContainer}>
|
||||
<View style={[styles.radioCircle, isSelected && styles.radioCircleSelected]}>
|
||||
{isSelected && <View style={styles.radioDot} />}
|
||||
</View>
|
||||
return (
|
||||
<View key={node.id} style={styles.fieldContainer}>
|
||||
<Text style={styles.label}>
|
||||
{label}
|
||||
<Text style={styles.required}> *</Text>
|
||||
</Text>
|
||||
{allowMultiple ? (
|
||||
// Multi-select: render as a list of checkboxes
|
||||
<View style={styles.optionsContainer}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<Pressable
|
||||
key={index}
|
||||
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
|
||||
onPress={() => updateFormData(node.id, option.value)}
|
||||
>
|
||||
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const renderField = (node: StartNode) => {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return renderTextField(node)
|
||||
case 'image':
|
||||
return renderImageField(node)
|
||||
case 'video':
|
||||
return renderVideoField(node)
|
||||
case 'select':
|
||||
return renderSelectField(node)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (startNodes.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
{t('dynamicForm.noFields') || '暂无可填写的表单项'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoidingView}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{startNodes.map(renderField)}
|
||||
|
||||
<View style={styles.submitButtonContainer}>
|
||||
<Button
|
||||
testID="submit-button"
|
||||
variant="gradient"
|
||||
onPress={handleSubmit}
|
||||
disabled={loading}
|
||||
style={styles.submitButton}
|
||||
className="px-0"
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<View style={styles.submitButtonContent}>
|
||||
<Text style={styles.submitButtonText}>
|
||||
{t('dynamicForm.submit') || '提交'}
|
||||
</Text>
|
||||
{points !== undefined && points > 0 && (
|
||||
<View style={styles.pointsContainer}>
|
||||
<Text style={styles.pointsText}>{points}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
// Single select: render as a list of radio buttons
|
||||
<View style={styles.optionsContainer}>
|
||||
{options.map((option, index) => {
|
||||
const isSelected = value === option.value
|
||||
return (
|
||||
<Pressable
|
||||
key={index}
|
||||
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
|
||||
onPress={() => updateFormData(node.id, option.value)}
|
||||
>
|
||||
<View style={styles.radioContainer}>
|
||||
<View style={[styles.radioCircle, isSelected && styles.radioCircleSelected]}>
|
||||
{isSelected && <View style={styles.radioDot} />}
|
||||
</View>
|
||||
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const renderField = (node: StartNode) => {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
return renderTextField(node)
|
||||
case 'image':
|
||||
return renderImageField(node)
|
||||
case 'video':
|
||||
return renderVideoField(node)
|
||||
case 'select':
|
||||
return renderSelectField(node)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (startNodes.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
{t('dynamicForm.noFields') || '暂无可填写的表单项'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoidingView}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{startNodes.map(renderField)}
|
||||
|
||||
<View style={styles.submitButtonContainer}>
|
||||
<Button
|
||||
testID="submit-button"
|
||||
variant="gradient"
|
||||
onPress={handleSubmit}
|
||||
disabled={loading}
|
||||
style={styles.submitButton}
|
||||
className="px-0"
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<View style={styles.submitButtonContent}>
|
||||
<Text style={styles.submitButtonText}>
|
||||
{t('dynamicForm.submit') || '提交'}
|
||||
</Text>
|
||||
{points !== undefined && points > 0 && (
|
||||
<View style={styles.pointsContainer}>
|
||||
<Text style={styles.pointsText}>{points}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActivityIndicator, TextInput } from "react-native";
|
||||
import { ActivityIndicator, TextInput, Pressable } from "react-native";
|
||||
import { authClient, setAuthToken, useSession } from "../../lib/auth";
|
||||
import type { ApiError } from "../../lib/types";
|
||||
import { Block } from "../ui";
|
||||
import { Button } from "../ui/button";
|
||||
import Text from "../ui/Text";
|
||||
import { storage } from "../../lib/storage";
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
|
||||
@@ -26,9 +27,23 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [rememberPassword, setRememberPassword] = useState(false);
|
||||
|
||||
const isLogin = currentMode === "login";
|
||||
|
||||
useEffect(() => {
|
||||
const loadSavedCredentials = async () => {
|
||||
const saved = await storage.getItem("saved_credentials");
|
||||
if (saved) {
|
||||
const { username: savedUsername, password: savedPassword } = JSON.parse(saved);
|
||||
setUsername(savedUsername);
|
||||
setPassword(savedPassword);
|
||||
setRememberPassword(true);
|
||||
}
|
||||
};
|
||||
loadSavedCredentials();
|
||||
}, []);
|
||||
|
||||
// 根据错误代码获取翻译后的错误信息
|
||||
const getErrorMessage = (error: ApiError): string => {
|
||||
if (error.code) {
|
||||
@@ -77,7 +92,13 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
||||
return;
|
||||
}
|
||||
|
||||
// 登录成功
|
||||
// 登录成功后保存凭证
|
||||
if (rememberPassword) {
|
||||
await storage.setItem("saved_credentials", JSON.stringify({ username, password }));
|
||||
} else {
|
||||
await storage.removeItem("saved_credentials");
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
} else {
|
||||
const result = await signUp.email({ email, password, name: username }, {
|
||||
@@ -157,6 +178,21 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
{isLogin && (
|
||||
<Pressable
|
||||
onPress={() => setRememberPassword(!rememberPassword)}
|
||||
className="flex-row items-center mb-4"
|
||||
>
|
||||
<Block
|
||||
className="w-5 h-5 rounded border-2 border-white/30 mr-2 items-center justify-center"
|
||||
style={{ backgroundColor: rememberPassword ? '#ffffff' : 'transparent' }}
|
||||
>
|
||||
{rememberPassword && <Text style={{ color: '#000', fontSize: 12 }}>✓</Text>}
|
||||
</Block>
|
||||
<Text className="text-white/70 text-sm">{t("authForm.rememberPassword")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<Text style={{ color: '#ef4444' }} className="text-sm mb-4 text-center">
|
||||
{error}
|
||||
|
||||
17
components/blocks/__tests__/AuthForm.simple.test.tsx
Normal file
17
components/blocks/__tests__/AuthForm.simple.test.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
import { render } from '@testing-library/react-native'
|
||||
|
||||
// 最简单的测试组件
|
||||
const SimpleComponent = () => (
|
||||
<View>
|
||||
<Text>Hello Test</Text>
|
||||
</View>
|
||||
)
|
||||
|
||||
describe('Simple Test', () => {
|
||||
test('renders simple component', () => {
|
||||
const { getByText } = render(<SimpleComponent />)
|
||||
expect(getByText('Hello Test')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
92
components/blocks/__tests__/AuthForm.test.tsx
Normal file
92
components/blocks/__tests__/AuthForm.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react'
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react-native'
|
||||
import { AuthForm } from '../AuthForm'
|
||||
import { storage } from '@/lib/storage'
|
||||
|
||||
// Mock entire UI module to avoid complex dependencies
|
||||
jest.mock('@/components/ui', () => {
|
||||
const mockReact = require('react')
|
||||
const mockRN = require('react-native')
|
||||
|
||||
const Block = ({ children, onClick, ...props }: any) => {
|
||||
if (onClick) {
|
||||
return mockReact.createElement(mockRN.Pressable, { onPress: onClick, ...props }, children)
|
||||
}
|
||||
return mockReact.createElement(mockRN.View, props, children)
|
||||
}
|
||||
|
||||
const Text = ({ children, onClick, ...props }: any) => {
|
||||
if (onClick) {
|
||||
return mockReact.createElement(mockRN.Pressable, { onPress: onClick },
|
||||
mockReact.createElement(mockRN.Text, props, children)
|
||||
)
|
||||
}
|
||||
return mockReact.createElement(mockRN.Text, props, children)
|
||||
}
|
||||
|
||||
return {
|
||||
Block,
|
||||
Text,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock button component separately since it's imported from a different path
|
||||
jest.mock('../../ui/button', () => {
|
||||
const mockReact = require('react')
|
||||
const mockRN = require('react-native')
|
||||
|
||||
const Button = ({ children, onPress, disabled, ...props }: any) => (
|
||||
mockReact.createElement(mockRN.Pressable, { onPress, disabled, ...props }, children)
|
||||
)
|
||||
|
||||
return {
|
||||
Button,
|
||||
}
|
||||
})
|
||||
|
||||
// Mock ModalPortal to avoid react-native-css-interop issues
|
||||
jest.mock('@/components/ui/ModalPortal', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: any) => children,
|
||||
}))
|
||||
|
||||
// Mock storage
|
||||
jest.mock('@/lib/storage', () => ({
|
||||
storage: {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock auth client
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authClient: {
|
||||
signIn: {
|
||||
username: jest.fn(),
|
||||
},
|
||||
signUp: {
|
||||
email: jest.fn(),
|
||||
},
|
||||
},
|
||||
setAuthToken: jest.fn(),
|
||||
useSession: jest.fn(() => ({ data: null, isPending: false })),
|
||||
}))
|
||||
|
||||
// Mock react-i18next
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AuthForm - Remember Password', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
test('renders remember password checkbox', () => {
|
||||
const { getByText } = render(<AuthForm />)
|
||||
expect(getByText('authForm.rememberPassword')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
113
jest.setup.js
113
jest.setup.js
@@ -1,3 +1,8 @@
|
||||
// Mock react-native-css-interop FIRST to avoid appearance listener issues
|
||||
jest.mock('react-native-css-interop', () => ({
|
||||
__esModule: true,
|
||||
}))
|
||||
|
||||
// Mock react-native BEFORE any other imports to avoid flow type issues
|
||||
jest.mock('react-native', () => {
|
||||
const React = require('react')
|
||||
@@ -11,7 +16,11 @@ jest.mock('react-native', () => {
|
||||
Pressable: 'Pressable',
|
||||
TouchableOpacity: 'TouchableOpacity',
|
||||
TextInput: 'TextInput',
|
||||
Platform: { OS: 'web' },
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Platform: {
|
||||
OS: 'web',
|
||||
select: (obj) => obj.web || obj.default,
|
||||
},
|
||||
Animated: {
|
||||
View: 'Animated.View',
|
||||
Text: 'Animated.Text',
|
||||
@@ -49,11 +58,27 @@ jest.mock('react-native', () => {
|
||||
Linking: {
|
||||
openURL: jest.fn(),
|
||||
},
|
||||
Appearance: {
|
||||
getColorScheme: jest.fn(() => 'light'),
|
||||
addChangeListener: jest.fn(),
|
||||
removeChangeListener: jest.fn(),
|
||||
},
|
||||
BackHandler: {
|
||||
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
|
||||
removeEventListener: jest.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
require('@testing-library/jest-native/extend-expect')
|
||||
|
||||
// Mock global Appearance for react-native-css-interop
|
||||
global.Appearance = {
|
||||
getColorScheme: jest.fn(() => 'light'),
|
||||
addChangeListener: jest.fn(() => ({ remove: jest.fn() })),
|
||||
removeChangeListener: jest.fn(),
|
||||
}
|
||||
|
||||
// Initialize globals for react-native-reanimated
|
||||
global._tagToJSPropNamesMapping = {}
|
||||
global._WORKLET = false
|
||||
@@ -95,6 +120,18 @@ jest.mock('expo-linear-gradient', () => ({
|
||||
LinearGradient: 'LinearGradient',
|
||||
}))
|
||||
|
||||
// Mock expo-blur
|
||||
jest.mock('expo-blur', () => ({
|
||||
BlurView: 'BlurView',
|
||||
}))
|
||||
|
||||
// Mock react-native-safe-area-context
|
||||
jest.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }),
|
||||
SafeAreaProvider: ({ children }: any) => children,
|
||||
SafeAreaView: 'SafeAreaView',
|
||||
}))
|
||||
|
||||
// Mock react-native-gesture-handler
|
||||
jest.mock('react-native-gesture-handler', () => {
|
||||
const { View } = require('react-native')
|
||||
@@ -142,11 +179,34 @@ jest.mock('expo-splash-screen', () => ({
|
||||
}))
|
||||
|
||||
// Mock react-native-reanimated
|
||||
jest.mock('react-native-reanimated', () => {
|
||||
const Reanimated = require('react-native-reanimated/mock')
|
||||
Reanimated.default.call = () => {}
|
||||
return Reanimated
|
||||
})
|
||||
jest.mock('react-native-reanimated', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
createAnimatedComponent: (Component) => Component,
|
||||
call: () => {},
|
||||
},
|
||||
useSharedValue: (v) => ({ value: v }),
|
||||
useAnimatedStyle: (fn) => fn(),
|
||||
withTiming: (v) => v,
|
||||
withSpring: (v) => v,
|
||||
withDecay: (v) => v,
|
||||
withRepeat: (v) => v,
|
||||
withSequence: (...args) => args[0],
|
||||
Easing: {
|
||||
linear: (v) => v,
|
||||
ease: (v) => v,
|
||||
quad: (v) => v,
|
||||
cubic: (v) => v,
|
||||
},
|
||||
runOnJS: (fn) => fn,
|
||||
runOnUI: (fn) => fn,
|
||||
}))
|
||||
|
||||
// Mock react-native-screens
|
||||
jest.mock('react-native-screens', () => ({
|
||||
FullWindowOverlay: 'FullWindowOverlay',
|
||||
enableScreens: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock nativewind
|
||||
jest.mock('nativewind', () => ({
|
||||
@@ -225,8 +285,49 @@ jest.mock('@/components/icon', () => ({
|
||||
LeftArrowIcon: () => null,
|
||||
UploadIcon: () => null,
|
||||
WhitePointsIcon: () => null,
|
||||
RightArrowIcon: () => null,
|
||||
}))
|
||||
|
||||
// Mock UI components that have complex dependencies
|
||||
jest.mock('@/components/ui/Text', () => {
|
||||
const mockReact = require('react')
|
||||
const mockRN = require('react-native')
|
||||
const Text = mockReact.forwardRef(({ children, ...props }, ref) => (
|
||||
mockReact.createElement(mockRN.Text, { ref, ...props }, children)
|
||||
))
|
||||
Text.displayName = 'Text'
|
||||
return {
|
||||
__esModule: true,
|
||||
default: Text,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/components/ui/Block', () => {
|
||||
const mockReact = require('react')
|
||||
const mockRN = require('react-native')
|
||||
const Block = mockReact.forwardRef(({ children, onClick, ...props }, ref) => {
|
||||
if (onClick) {
|
||||
return mockReact.createElement(mockRN.Pressable, { ref, onPress: onClick, ...props }, children)
|
||||
}
|
||||
return mockReact.createElement(mockRN.View, { ref, ...props }, children)
|
||||
})
|
||||
Block.displayName = 'Block'
|
||||
return {
|
||||
__esModule: true,
|
||||
default: Block,
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/components/ui/button', () => {
|
||||
const mockReact = require('react')
|
||||
const mockRN = require('react-native')
|
||||
return {
|
||||
Button: mockReact.forwardRef(({ children, onPress, disabled, ...props }, ref) => (
|
||||
mockReact.createElement(mockRN.Pressable, { ref, onPress, disabled, ...props }, children)
|
||||
)),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/components/skeleton/HomeSkeleton', () => ({
|
||||
HomeSkeleton: () => null,
|
||||
}))
|
||||
|
||||
@@ -58,8 +58,8 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
|
||||
headers['authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// 确保 Content-Type 正确设置
|
||||
if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type']) {
|
||||
// 确保 Content-Type 正确设置(FormData 不需要手动设置,让浏览器自动处理 boundary)
|
||||
if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type'] && !(init?.body instanceof FormData)) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
|
||||
@@ -5,22 +5,27 @@ import { Platform } from 'react-native'
|
||||
import { handleError } from '@/hooks/use-error'
|
||||
|
||||
export async function uploadFile(params: { uri: string; mimeType?: string; fileName?: string }): Promise<string> {
|
||||
const { uri, mimeType, fileName } = params
|
||||
const { uri, mimeType, fileName } = params || {}
|
||||
|
||||
console.log('[uploadFile] 开始上传:', { uri, mimeType, fileName, platform: Platform.OS })
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', {
|
||||
uri: uri,
|
||||
name: fileName || 'uploaded_file.jpg',
|
||||
|
||||
// React Native 中 FormData 需要使用特定格式
|
||||
const fileObject = {
|
||||
uri: Platform.OS === 'android' ? uri : uri.replace('file://', ''),
|
||||
type: mimeType || 'image/jpeg',
|
||||
} as any)
|
||||
name: fileName || 'uploaded_file.jpg',
|
||||
}
|
||||
|
||||
console.log('[uploadFile] FormData file object:', fileObject)
|
||||
formData.append('file', fileObject as any)
|
||||
|
||||
const fileController = root.get(FileController)
|
||||
const { data, error } = await handleError(async () => await fileController.uploadS3(formData))
|
||||
|
||||
if (error || !data?.data) {
|
||||
console.error('[uploadFile] 上传失败:', { error, data, uri })
|
||||
console.error('[uploadFile] 上传失败:', { error, data, uri, formData })
|
||||
throw error || new Error('上传失败')
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"rememberPassword": "Remember Password",
|
||||
"fillCompleteInfo": "Please fill in all information",
|
||||
"fillEmail": "Please fill in email",
|
||||
"loginFailed": "Login failed",
|
||||
|
||||
@@ -221,6 +221,7 @@
|
||||
"username": "用户名",
|
||||
"email": "邮箱",
|
||||
"password": "密码",
|
||||
"rememberPassword": "记住密码",
|
||||
"fillCompleteInfo": "请填写完整信息",
|
||||
"fillEmail": "请填写邮箱",
|
||||
"loginFailed": "登录失败",
|
||||
|
||||
Reference in New Issue
Block a user