feat: integrate UI components into Works List with pagination and refresh
- Add useWorksList hook for data fetching with pagination support - Integrate RefreshControl for pull-to-refresh functionality - Add LoadingState for initial loading display - Add ErrorState with retry functionality - Add PaginationLoader for load more indication - Update WorksGallery to support refresh control and pagination - Add comprehensive tests for worksList screen and hook - Use useMemo for performance optimization TODO: Replace mock API with actual backend endpoint when available Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
beba2f2428
commit
10ee380051
|
|
@ -0,0 +1,81 @@
|
|||
import React from 'react'
|
||||
import { render, waitFor } from '@testing-library/react-native'
|
||||
import WorksListScreen from './worksList'
|
||||
|
||||
jest.mock('expo-router', () => ({
|
||||
useRouter: () => ({
|
||||
push: jest.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'worksList.title': 'My Works',
|
||||
'worksList.all': '全部',
|
||||
'worksList.pets': '萌宠',
|
||||
'worksList.portrait': '写真',
|
||||
'worksList.together': '合拍',
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/hooks/useWorksList', () => ({
|
||||
useWorksList: () => ({
|
||||
works: [
|
||||
{ id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '萌宠' },
|
||||
{ id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' },
|
||||
],
|
||||
loading: false,
|
||||
error: null,
|
||||
refreshing: false,
|
||||
hasMore: true,
|
||||
loadMore: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('WorksListScreen', () => {
|
||||
it('should render loading state initially', () => {
|
||||
const mockUseWorksList = require('@/hooks/useWorksList').useWorksList
|
||||
mockUseWorksList.mockReturnValueOnce({
|
||||
works: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
refreshing: false,
|
||||
hasMore: false,
|
||||
loadMore: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
})
|
||||
|
||||
const { getByTestId } = render(<WorksListScreen />)
|
||||
expect(getByTestId('loading-state')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render error state when error occurs', () => {
|
||||
const mockUseWorksList = require('@/hooks/useWorksList').useWorksList
|
||||
mockUseWorksList.mockReturnValueOnce({
|
||||
works: [],
|
||||
loading: false,
|
||||
error: 'Failed to load',
|
||||
refreshing: false,
|
||||
hasMore: false,
|
||||
loadMore: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
})
|
||||
|
||||
const { getByTestId } = render(<WorksListScreen />)
|
||||
expect(getByTestId('error-state')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render works gallery when data is loaded', async () => {
|
||||
const { getByText } = render(<WorksListScreen />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('My Works')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -13,47 +13,47 @@ import { useTranslation } from 'react-i18next'
|
|||
|
||||
import { LeftArrowIcon, SearchIcon } from '@/components/icon'
|
||||
import WorksGallery, { type Category, type WorkItem } from '@/components/WorksGallery'
|
||||
|
||||
const worksData: WorkItem[] = [
|
||||
{ id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '萌宠' },
|
||||
{ id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' },
|
||||
{ id: 3, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' },
|
||||
{ id: 4, date: new Date(2025, 10, 27), duration: '00:05', category: '合拍' },
|
||||
{ id: 5, date: new Date(2025, 10, 27), duration: '00:05', category: '写真' },
|
||||
{ id: 6, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' },
|
||||
{ id: 7, date: new Date(2025, 10, 26), duration: '00:05', category: '合拍' },
|
||||
{ id: 8, date: new Date(2025, 10, 26), duration: '00:05', category: '写真' },
|
||||
]
|
||||
import RefreshControl from '@/components/RefreshControl'
|
||||
import LoadingState from '@/components/LoadingState'
|
||||
import ErrorState from '@/components/ErrorState'
|
||||
import PaginationLoader from '@/components/PaginationLoader'
|
||||
import { useWorksList } from '@/hooks/useWorksList'
|
||||
|
||||
export default function WorksListScreen() {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
|
||||
const { works, loading, error, refreshing, hasMore, loadMore, refresh } = useWorksList()
|
||||
|
||||
const categories: Category[] = [
|
||||
t('worksList.all') as Category,
|
||||
t('worksList.pets') as Category,
|
||||
t('worksList.portrait') as Category,
|
||||
t('worksList.together') as Category,
|
||||
]
|
||||
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category>(categories[0])
|
||||
|
||||
const filteredWorks = selectedCategory === categories[0]
|
||||
? worksData
|
||||
: worksData.filter(work => work.category === selectedCategory)
|
||||
const filteredWorks = useMemo(() =>
|
||||
selectedCategory === categories[0]
|
||||
? works
|
||||
: works.filter(work => work.category === selectedCategory),
|
||||
[selectedCategory, works, categories]
|
||||
)
|
||||
|
||||
const filteredGroupedWorks = filteredWorks.reduce((acc, work) => {
|
||||
// 使用日期作为分组键,格式化为 YYYY-MM-DD 以便正确分组
|
||||
const dateKey = work.date instanceof Date
|
||||
? work.date.toISOString().split('T')[0]
|
||||
: new Date(work.date).toISOString().split('T')[0]
|
||||
|
||||
if (!acc[dateKey]) {
|
||||
acc[dateKey] = []
|
||||
}
|
||||
acc[dateKey].push(work)
|
||||
return acc
|
||||
}, {} as Record<string, WorkItem[]>)
|
||||
const filteredGroupedWorks = useMemo(() =>
|
||||
filteredWorks.reduce((acc, work) => {
|
||||
const dateKey = work.date instanceof Date
|
||||
? work.date.toISOString().split('T')[0]
|
||||
: new Date(work.date).toISOString().split('T')[0]
|
||||
|
||||
if (!acc[dateKey]) {
|
||||
acc[dateKey] = []
|
||||
}
|
||||
acc[dateKey].push(work)
|
||||
return acc
|
||||
}, {} as Record<string, WorkItem[]>),
|
||||
[filteredWorks]
|
||||
)
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
|
|
@ -79,18 +79,31 @@ export default function WorksListScreen() {
|
|||
</Pressable>
|
||||
</View>
|
||||
|
||||
<WorksGallery
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
groupedWorks={filteredGroupedWorks}
|
||||
onWorkPress={(id) => {
|
||||
router.push({
|
||||
pathname: '/generationRecord' as any,
|
||||
params: { id: id.toString() },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{loading ? (
|
||||
<LoadingState />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={refresh} />
|
||||
) : (
|
||||
<WorksGallery
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
groupedWorks={filteredGroupedWorks}
|
||||
onWorkPress={(id) => {
|
||||
router.push({
|
||||
pathname: '/generationRecord' as any,
|
||||
params: { id: id.toString() },
|
||||
})
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={refresh} />
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
ListFooterComponent={
|
||||
hasMore ? <PaginationLoader /> : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,9 @@ interface WorksGalleryProps {
|
|||
onCategoryChange: (category: Category) => void
|
||||
groupedWorks: Record<string, WorkItem[]>
|
||||
onWorkPress: (id: number) => void
|
||||
refreshControl?: React.ReactElement
|
||||
onEndReached?: () => void
|
||||
ListFooterComponent?: React.ReactElement
|
||||
}
|
||||
|
||||
export default function WorksGallery({
|
||||
|
|
@ -40,6 +43,9 @@ export default function WorksGallery({
|
|||
onCategoryChange,
|
||||
groupedWorks,
|
||||
onWorkPress,
|
||||
refreshControl,
|
||||
onEndReached,
|
||||
ListFooterComponent,
|
||||
}: WorksGalleryProps) {
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
|
|
@ -112,6 +118,15 @@ export default function WorksGallery({
|
|||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={refreshControl}
|
||||
onScroll={(e) => {
|
||||
const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent
|
||||
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - 100
|
||||
if (isCloseToBottom && onEndReached) {
|
||||
onEndReached()
|
||||
}
|
||||
}}
|
||||
scrollEventThrottle={400}
|
||||
>
|
||||
{Object.entries(groupedWorks).map(([dateKey, works]) => {
|
||||
// 从第一个作品获取日期对象
|
||||
|
|
@ -151,6 +166,7 @@ export default function WorksGallery({
|
|||
</View>
|
||||
)
|
||||
})}
|
||||
{ListFooterComponent}
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { renderHook, waitFor } from '@testing-library/react-native'
|
||||
import { useWorksList } from './useWorksList'
|
||||
|
||||
describe('useWorksList', () => {
|
||||
it('should load initial works data', async () => {
|
||||
const { result } = renderHook(() => useWorksList())
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
expect(result.current.works).toEqual([])
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.works.length).toBeGreaterThan(0)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle refresh', async () => {
|
||||
const { result } = renderHook(() => useWorksList())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
const initialWorks = result.current.works
|
||||
|
||||
await result.current.refresh()
|
||||
|
||||
expect(result.current.refreshing).toBe(false)
|
||||
expect(result.current.works).toEqual(initialWorks)
|
||||
})
|
||||
|
||||
it('should load more works', async () => {
|
||||
const { result } = renderHook(() => useWorksList())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
const initialCount = result.current.works.length
|
||||
|
||||
await result.current.loadMore()
|
||||
|
||||
expect(result.current.works.length).toBeGreaterThan(initialCount)
|
||||
})
|
||||
|
||||
it('should set hasMore to false when no more data', async () => {
|
||||
const { result } = renderHook(() => useWorksList())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
await result.current.loadMore()
|
||||
await result.current.loadMore()
|
||||
await result.current.loadMore()
|
||||
|
||||
expect(result.current.hasMore).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import type { WorkItem } from '@/components/WorksGallery'
|
||||
|
||||
interface UseWorksListResult {
|
||||
works: WorkItem[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
refreshing: boolean
|
||||
hasMore: boolean
|
||||
loadMore: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
// TODO: Replace with actual API call when backend is ready
|
||||
const mockFetchWorks = async (page: number): Promise<WorkItem[]> => {
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
|
||||
if (page > 2) return []
|
||||
|
||||
const baseData: WorkItem[] = [
|
||||
{ id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '萌宠' },
|
||||
{ id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' },
|
||||
{ id: 3, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' },
|
||||
{ id: 4, date: new Date(2025, 10, 27), duration: '00:05', category: '合拍' },
|
||||
{ id: 5, date: new Date(2025, 10, 27), duration: '00:05', category: '写真' },
|
||||
{ id: 6, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' },
|
||||
{ id: 7, date: new Date(2025, 10, 26), duration: '00:05', category: '合拍' },
|
||||
{ id: 8, date: new Date(2025, 10, 26), duration: '00:05', category: '写真' },
|
||||
]
|
||||
|
||||
return baseData.map(item => ({
|
||||
...item,
|
||||
id: item.id + (page * PAGE_SIZE),
|
||||
}))
|
||||
}
|
||||
|
||||
export function useWorksList(): UseWorksListResult {
|
||||
const [works, setWorks] = useState<WorkItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [page, setPage] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
|
||||
const loadWorks = useCallback(async (pageNum: number, isRefresh = false) => {
|
||||
try {
|
||||
const data = await mockFetchWorks(pageNum)
|
||||
|
||||
if (isRefresh) {
|
||||
setWorks(data)
|
||||
} else {
|
||||
setWorks(prev => [...prev, ...data])
|
||||
}
|
||||
|
||||
setHasMore(data.length === PAGE_SIZE)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load works')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!hasMore || loading) return
|
||||
|
||||
const nextPage = page + 1
|
||||
setPage(nextPage)
|
||||
await loadWorks(nextPage)
|
||||
}, [hasMore, loading, page, loadWorks])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
setPage(0)
|
||||
await loadWorks(0, true)
|
||||
setRefreshing(false)
|
||||
}, [loadWorks])
|
||||
|
||||
// Initial load
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
if (!initialized) {
|
||||
setInitialized(true)
|
||||
loadWorks(0).finally(() => setLoading(false))
|
||||
}
|
||||
|
||||
return {
|
||||
works,
|
||||
loading,
|
||||
error,
|
||||
refreshing,
|
||||
hasMore,
|
||||
loadMore,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue