feat: add useCategoriesWithTags hook and associated store for category management
- Implemented useCategoriesWithTags hook to fetch categories with tags. - Created zustand store for managing categories and tags state. - Added error handling and loading states for improved user experience. test: add comprehensive tests for useCategoryTemplates hook - Developed unit tests for useCategoryTemplates to ensure correct functionality. - Included tests for loading states, error handling, pagination, and category ID changes. - Verified that templates are fetched and merged correctly based on category ID. feat: create useCategoryTemplates hook for managing category templates - Introduced useCategoryTemplates hook to fetch templates based on category ID. - Implemented pagination and loading states for template fetching. - Added refetch and loadMore functionalities to enhance data retrieval.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
export { useActivates } from './use-activates'
|
||||
export { useCategories } from './use-categories'
|
||||
export { useCategoriesWithTags } from './use-categories-with-tags'
|
||||
export { useCategoryTemplates } from './use-category-templates'
|
||||
export { useTemplateActions } from './use-template-actions'
|
||||
export { useTemplates, type TemplateDetail } from './use-templates'
|
||||
export { useTemplateDetail, type TemplateDetail as TemplateDetailType } from './use-template-detail'
|
||||
|
||||
504
hooks/use-categories-with-tags.test.ts
Normal file
504
hooks/use-categories-with-tags.test.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react-native'
|
||||
import { useCategoriesWithTags } from './use-categories-with-tags'
|
||||
import { useCategoriesWithTagsStore } from './use-categories-with-tags'
|
||||
import { root } from '@repo/core'
|
||||
import { CategoryController } from '@repo/sdk'
|
||||
import { handleError } from './use-error'
|
||||
import { OWNER_ID } from '@/lib/auth'
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@repo/core', () => ({
|
||||
root: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('./use-error', () => ({
|
||||
handleError: jest.fn(async (cb) => {
|
||||
try {
|
||||
const data = await cb()
|
||||
return { data, error: null }
|
||||
} catch (e) {
|
||||
return { data: null, error: e }
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
OWNER_ID: 'test-owner-id',
|
||||
}))
|
||||
|
||||
describe('useCategoriesWithTags', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
// Reset the store state before each test
|
||||
useCategoriesWithTagsStore.setState({
|
||||
data: undefined,
|
||||
loading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should return initial state with no data loaded', async () => {
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue({
|
||||
categories: [
|
||||
{
|
||||
id: '1',
|
||||
name: '分类1',
|
||||
nameEn: 'Category 1',
|
||||
description: '描述1',
|
||||
descriptionEn: 'Description 1',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
ownerId: 'owner-id',
|
||||
isDeleted: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
}),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
// Wait for auto-load to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toBeDefined()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should have loading state initially false', async () => {
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue({
|
||||
categories: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
}),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
// After auto-load completes, loading should be false
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('load function', () => {
|
||||
it('should load categories with tags successfully', async () => {
|
||||
const mockData = {
|
||||
categories: [
|
||||
{
|
||||
id: '1',
|
||||
name: '分类1',
|
||||
nameEn: 'Category 1',
|
||||
description: '描述1',
|
||||
descriptionEn: 'Description 1',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
ownerId: 'owner-id',
|
||||
isDeleted: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [
|
||||
{ id: 'tag-1', name: '标签1', nameEn: 'Tag 1' },
|
||||
{ id: 'tag-2', name: '标签2', nameEn: 'Tag 2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '分类2',
|
||||
nameEn: 'Category 2',
|
||||
description: '描述2',
|
||||
descriptionEn: 'Description 2',
|
||||
sortOrder: 1,
|
||||
isActive: true,
|
||||
ownerId: 'owner-id',
|
||||
isDeleted: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
expect(mockCategoryController.listWithTags).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
isActive: true,
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
const mockError = {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
message: 'Failed to load categories with tags',
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockRejectedValue(mockError),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
expect(result.current.error).toEqual(mockError)
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle unexpected errors', async () => {
|
||||
const mockError = new Error('Network error')
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockRejectedValue(mockError),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeDefined()
|
||||
})
|
||||
|
||||
it('should merge custom params with default params', async () => {
|
||||
const mockData = { categories: [], total: 0, page: 1, limit: 1000 }
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
// Set hasLoaded to true to prevent auto-load
|
||||
useCategoriesWithTagsStore.setState({ hasLoaded: true })
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load({ page: 2, limit: 20, orderBy: 'sortOrder', order: 'desc', ownerId: OWNER_ID })
|
||||
})
|
||||
|
||||
expect(mockCategoryController.listWithTags).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
limit: 20,
|
||||
isActive: true,
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('should not load if already loading', async () => {
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue({ categories: [], total: 0 }),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
// Start first load
|
||||
const firstLoad = act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
// Try to load again while first is still loading
|
||||
act(() => {
|
||||
result.current.load()
|
||||
})
|
||||
|
||||
await firstLoad
|
||||
|
||||
// Should only call listWithTags once due to loading check
|
||||
expect(mockCategoryController.listWithTags).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should set loading to true during fetch', async () => {
|
||||
let resolveFetch: (value: any) => void
|
||||
const fetchPromise = new Promise((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockReturnValue(fetchPromise),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
act(() => {
|
||||
result.current.load()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch!({ categories: [], total: 0 })
|
||||
await fetchPromise
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('should set loading to false after error', async () => {
|
||||
const mockError = {
|
||||
status: 500,
|
||||
statusText: 'Error',
|
||||
message: 'Test error',
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockRejectedValue(mockError),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initial load on mount', () => {
|
||||
it('should auto-load on mount if not loaded', async () => {
|
||||
const mockData = {
|
||||
categories: [
|
||||
{
|
||||
id: '1',
|
||||
name: '分类1',
|
||||
nameEn: 'Category 1',
|
||||
description: '描述1',
|
||||
descriptionEn: 'Description 1',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
ownerId: 'owner-id',
|
||||
isDeleted: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [{ id: 'tag-1', name: '标签1', nameEn: 'Tag 1' }],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
})
|
||||
|
||||
expect(mockCategoryController.listWithTags).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not auto-load if already loaded', async () => {
|
||||
const mockData = {
|
||||
categories: [
|
||||
{
|
||||
id: '1',
|
||||
name: '分类1',
|
||||
nameEn: 'Category 1',
|
||||
description: '描述1',
|
||||
descriptionEn: 'Description 1',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
ownerId: 'owner-id',
|
||||
isDeleted: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
// Set store to already loaded state
|
||||
useCategoriesWithTagsStore.setState({
|
||||
data: mockData,
|
||||
loading: false,
|
||||
error: null,
|
||||
hasLoaded: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
// Should not call listWithTags again since hasLoaded is true
|
||||
expect(mockCategoryController.listWithTags).not.toHaveBeenCalled()
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state persistence', () => {
|
||||
it('should share state across hook instances', async () => {
|
||||
const mockData = {
|
||||
categories: [
|
||||
{
|
||||
id: '1',
|
||||
name: '分类1',
|
||||
nameEn: 'Category 1',
|
||||
description: '描述1',
|
||||
descriptionEn: 'Description 1',
|
||||
sortOrder: 0,
|
||||
isActive: true,
|
||||
ownerId: 'owner-id',
|
||||
isDeleted: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tags: [{ id: 'tag-1', name: '标签1', nameEn: 'Tag 1' }],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
const { result: result1 } = renderHook(() => useCategoriesWithTags())
|
||||
const { result: result2 } = renderHook(() => useCategoriesWithTags())
|
||||
|
||||
await act(async () => {
|
||||
await result1.current.load()
|
||||
})
|
||||
|
||||
// Both hook instances should share the same store state
|
||||
expect(result2.current.data).toEqual(mockData)
|
||||
expect(result1.current.data).toBe(result2.current.data)
|
||||
})
|
||||
})
|
||||
|
||||
describe('params handling', () => {
|
||||
it('should use initial params when calling load without arguments', async () => {
|
||||
const mockData = { categories: [], total: 0, page: 1, limit: 1000 }
|
||||
const initialParams = {
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
isActive: false,
|
||||
ownerId: OWNER_ID,
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
// Set hasLoaded to true to prevent auto-load
|
||||
useCategoriesWithTagsStore.setState({ hasLoaded: true })
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags(initialParams))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load()
|
||||
})
|
||||
|
||||
expect(mockCategoryController.listWithTags).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
isActive: false, // Initial params should override default
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('should override initial params when load is called with arguments', async () => {
|
||||
const mockData = { categories: [], total: 0, page: 1, limit: 1000 }
|
||||
const initialParams = {
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
isActive: true,
|
||||
ownerId: OWNER_ID,
|
||||
}
|
||||
|
||||
const mockCategoryController = {
|
||||
listWithTags: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockCategoryController)
|
||||
|
||||
// Set hasLoaded to true to prevent auto-load
|
||||
useCategoriesWithTagsStore.setState({ hasLoaded: true })
|
||||
|
||||
const { result } = renderHook(() => useCategoriesWithTags(initialParams))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.load({ isActive: false, page: 1, limit: 1000, orderBy: 'sortOrder', order: 'desc', ownerId: OWNER_ID })
|
||||
})
|
||||
|
||||
expect(mockCategoryController.listWithTags).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
isActive: false, // Load params should override initial
|
||||
orderBy: 'sortOrder',
|
||||
order: 'desc',
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
79
hooks/use-categories-with-tags.ts
Normal file
79
hooks/use-categories-with-tags.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { root } from '@repo/core'
|
||||
import {
|
||||
CategoryController,
|
||||
type ListCategoriesWithTagsInput,
|
||||
type ListCategoriesWithTagsResult,
|
||||
} from '@repo/sdk'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { create } from 'zustand'
|
||||
|
||||
import { type ApiError } from '@/lib/types'
|
||||
|
||||
import { OWNER_ID } from '@/lib/auth'
|
||||
import { handleError } from './use-error'
|
||||
|
||||
interface CategoriesWithTagsState {
|
||||
data: ListCategoriesWithTagsResult | null | undefined
|
||||
loading: boolean
|
||||
error: ApiError | null
|
||||
hasLoaded: boolean
|
||||
}
|
||||
|
||||
export const useCategoriesWithTagsStore = create<CategoriesWithTagsState>((set) => ({
|
||||
data: undefined,
|
||||
loading: false,
|
||||
error: null,
|
||||
hasLoaded: false,
|
||||
}))
|
||||
|
||||
export const useCategoriesWithTags = (params?: ListCategoriesWithTagsInput) => {
|
||||
const store = useCategoriesWithTagsStore()
|
||||
const [localLoading, setLocalLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!store.hasLoaded && !store.loading && !localLoading) {
|
||||
load()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const load = async (inputParams?: ListCategoriesWithTagsInput) => {
|
||||
if (store.loading || localLoading) return
|
||||
|
||||
try {
|
||||
setLocalLoading(true)
|
||||
useCategoriesWithTagsStore.setState({ loading: true })
|
||||
const category = root.get(CategoryController)
|
||||
|
||||
// Merge params: inputParams > params > defaults
|
||||
const mergedParams = {
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
isActive: true,
|
||||
orderBy: 'sortOrder' as const,
|
||||
order: 'desc' as const,
|
||||
ownerId: OWNER_ID,
|
||||
...(params ?? {}),
|
||||
...(inputParams ?? {}),
|
||||
}
|
||||
|
||||
const { data, error } = await handleError(async () => await category.listWithTags(mergedParams))
|
||||
|
||||
if (error) {
|
||||
useCategoriesWithTagsStore.setState({ data: null, error, loading: false, hasLoaded: true })
|
||||
return
|
||||
}
|
||||
useCategoriesWithTagsStore.setState({ data, loading: false, error: null, hasLoaded: true })
|
||||
} catch (e) {
|
||||
useCategoriesWithTagsStore.setState({ error: e as ApiError, loading: false, hasLoaded: true })
|
||||
} finally {
|
||||
setLocalLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
loading: store.loading || localLoading,
|
||||
error: store.error,
|
||||
data: store.data,
|
||||
}
|
||||
}
|
||||
749
hooks/use-category-templates.test.ts
Normal file
749
hooks/use-category-templates.test.ts
Normal file
@@ -0,0 +1,749 @@
|
||||
import { renderHook, act } from '@testing-library/react-native'
|
||||
import { useCategoryTemplates } from './use-category-templates'
|
||||
import { root } from '@repo/core'
|
||||
import { TemplateController } from '@repo/sdk'
|
||||
import { handleError } from './use-error'
|
||||
import { OWNER_ID } from '@/lib/auth'
|
||||
|
||||
jest.mock('@repo/core', () => ({
|
||||
root: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('./use-error', () => ({
|
||||
handleError: jest.fn(async (cb) => {
|
||||
try {
|
||||
const data = await cb()
|
||||
return { data, error: null }
|
||||
} catch (e) {
|
||||
return { data: null, error: e }
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
OWNER_ID: 'test-owner-id',
|
||||
}))
|
||||
|
||||
describe('useCategoryTemplates', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should return initial state with no data', () => {
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
expect(result.current.data).toBeUndefined()
|
||||
expect(result.current.templates).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.loadingMore).toBe(false)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.hasMore).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should set loading to true during fetch', async () => {
|
||||
let resolveFetch: (value: any) => void
|
||||
const fetchPromise = new Promise((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockReturnValue(fetchPromise),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
act(() => {
|
||||
result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch!({ templates: [], total: 0, page: 1, limit: 20, totalPages: 1 })
|
||||
await fetchPromise
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('should set loading to false after error', async () => {
|
||||
const mockError = { status: 500, message: 'Error' }
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockRejectedValue(mockError),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute function', () => {
|
||||
it('should load templates with categoryId successfully', async () => {
|
||||
const mockData = {
|
||||
templates: [
|
||||
{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' },
|
||||
{ id: '2', title: 'Template 2', titleEn: 'Template 2 EN', previewUrl: 'url2', coverImageUrl: 'cover2', aspectRatio: '16:9' },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(mockController.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 'category-1',
|
||||
ownerId: OWNER_ID,
|
||||
page: 1,
|
||||
})
|
||||
)
|
||||
expect(result.current.data).toEqual(mockData)
|
||||
expect(result.current.templates).toEqual(mockData.templates)
|
||||
expect(result.current.error).toBeNull()
|
||||
expect(result.current.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle API errors', async () => {
|
||||
const mockError = {
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
message: 'Failed to load templates',
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockRejectedValue(mockError),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.error).toEqual(mockError)
|
||||
expect(result.current.data).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should merge custom params with defaults', async () => {
|
||||
const mockData = {
|
||||
templates: [],
|
||||
total: 0,
|
||||
page: 2,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute({ page: 2, limit: 10 })
|
||||
})
|
||||
|
||||
expect(mockController.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 'category-1',
|
||||
page: 2,
|
||||
limit: 10,
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should use initial params', async () => {
|
||||
const mockData = {
|
||||
templates: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1', { limit: 50 }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(mockController.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 'category-1',
|
||||
limit: 50,
|
||||
page: 1,
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination - loadMore', () => {
|
||||
it('should load more templates and append to existing data', async () => {
|
||||
const page1Data = {
|
||||
templates: [
|
||||
{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' },
|
||||
{ id: '2', title: 'Template 2', titleEn: 'Template 2 EN', previewUrl: 'url2', coverImageUrl: 'cover2', aspectRatio: '16:9' },
|
||||
],
|
||||
total: 4,
|
||||
page: 1,
|
||||
limit: 2,
|
||||
totalPages: 2,
|
||||
}
|
||||
|
||||
const page2Data = {
|
||||
templates: [
|
||||
{ id: '3', title: 'Template 3', titleEn: 'Template 3 EN', previewUrl: 'url3', coverImageUrl: 'cover3', aspectRatio: '16:9' },
|
||||
{ id: '4', title: 'Template 4', titleEn: 'Template 4 EN', previewUrl: 'url4', coverImageUrl: 'cover4', aspectRatio: '16:9' },
|
||||
],
|
||||
total: 4,
|
||||
page: 2,
|
||||
limit: 2,
|
||||
totalPages: 2,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn()
|
||||
.mockResolvedValueOnce(page1Data)
|
||||
.mockResolvedValueOnce(page2Data),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1', { limit: 2 }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toHaveLength(2)
|
||||
expect(result.current.hasMore).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toHaveLength(4)
|
||||
expect(result.current.templates).toEqual([
|
||||
...page1Data.templates,
|
||||
...page2Data.templates,
|
||||
])
|
||||
expect(result.current.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('should not load more if already loading', async () => {
|
||||
let resolveFetch: (value: any) => void
|
||||
const fetchPromise = new Promise((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockReturnValue(fetchPromise),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
act(() => {
|
||||
result.current.execute()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.loadMore()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
resolveFetch!({ templates: [], total: 0, page: 1, limit: 20, totalPages: 1 })
|
||||
await fetchPromise
|
||||
})
|
||||
|
||||
expect(mockController.list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not load more if no more data', async () => {
|
||||
const mockData = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.hasMore).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore()
|
||||
})
|
||||
|
||||
expect(mockController.list).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should set loadingMore state correctly', async () => {
|
||||
const page1Data = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 1,
|
||||
totalPages: 2,
|
||||
}
|
||||
|
||||
let resolveLoadMore: (value: any) => void
|
||||
const loadMorePromise = new Promise((resolve) => {
|
||||
resolveLoadMore = resolve
|
||||
})
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn()
|
||||
.mockResolvedValueOnce(page1Data)
|
||||
.mockReturnValueOnce(loadMorePromise),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1', { limit: 1 }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.loadMore()
|
||||
})
|
||||
|
||||
expect(result.current.loadingMore).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
resolveLoadMore!({ templates: [{ id: '2', title: 'Template 2', titleEn: 'Template 2 EN', previewUrl: 'url2', coverImageUrl: 'cover2', aspectRatio: '16:9' }], total: 2, page: 2, limit: 1, totalPages: 2 })
|
||||
await loadMorePromise
|
||||
})
|
||||
|
||||
expect(result.current.loadingMore).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass categoryId when loading more', async () => {
|
||||
const page1Data = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 1,
|
||||
totalPages: 2,
|
||||
}
|
||||
|
||||
const page2Data = {
|
||||
templates: [{ id: '2', title: 'Template 2', titleEn: 'Template 2 EN', previewUrl: 'url2', coverImageUrl: 'cover2', aspectRatio: '16:9' }],
|
||||
total: 2,
|
||||
page: 2,
|
||||
limit: 1,
|
||||
totalPages: 2,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn()
|
||||
.mockResolvedValueOnce(page1Data)
|
||||
.mockResolvedValueOnce(page2Data),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1', { limit: 1 }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore()
|
||||
})
|
||||
|
||||
expect(mockController.list).toHaveBeenNthCalledWith(2,
|
||||
expect.objectContaining({
|
||||
categoryId: 'category-1',
|
||||
page: 2,
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refetch function', () => {
|
||||
it('should reset and reload data from page 1', async () => {
|
||||
const initialData = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const refreshedData = {
|
||||
templates: [
|
||||
{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' },
|
||||
{ id: '2', title: 'Template 2', titleEn: 'Template 2 EN', previewUrl: 'url2', coverImageUrl: 'cover2', aspectRatio: '16:9' },
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn()
|
||||
.mockResolvedValueOnce(initialData)
|
||||
.mockResolvedValueOnce(refreshedData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toHaveLength(1)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refetch()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toHaveLength(2)
|
||||
expect(mockController.list).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should reset hasMore flag', async () => {
|
||||
const mockData = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.hasMore).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refetch()
|
||||
})
|
||||
|
||||
expect(result.current.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('should pass categoryId when refetching', async () => {
|
||||
const mockData = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refetch()
|
||||
})
|
||||
|
||||
expect(mockController.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 'category-1',
|
||||
page: 1,
|
||||
ownerId: OWNER_ID,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasMore flag', () => {
|
||||
it('should set hasMore to true when more pages exist', async () => {
|
||||
const mockData = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 40,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 2,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.hasMore).toBe(true)
|
||||
})
|
||||
|
||||
it('should set hasMore to false on last page', async () => {
|
||||
const mockData = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 20,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn().mockResolvedValue(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.hasMore).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should clear error on successful execute', async () => {
|
||||
const mockError = { status: 500, message: 'Error' }
|
||||
const mockData = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn()
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockResolvedValueOnce(mockData),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.error).toEqual(mockError)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle loadMore errors without affecting existing data', async () => {
|
||||
const page1Data = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 1,
|
||||
totalPages: 2,
|
||||
}
|
||||
|
||||
const mockError = { status: 500, message: 'Error loading more' }
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn()
|
||||
.mockResolvedValueOnce(page1Data)
|
||||
.mockRejectedValueOnce(mockError),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result } = renderHook(() => useCategoryTemplates('category-1', { limit: 1 }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toHaveLength(1)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toHaveLength(1)
|
||||
expect(result.current.loadingMore).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('categoryId change', () => {
|
||||
it('should reload data when categoryId changes', async () => {
|
||||
const category1Data = {
|
||||
templates: [{ id: '1', title: 'Category 1 Template', titleEn: 'Category 1 Template EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const category2Data = {
|
||||
templates: [{ id: '2', title: 'Category 2 Template', titleEn: 'Category 2 Template EN', previewUrl: 'url2', coverImageUrl: 'cover2', aspectRatio: '16:9' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn()
|
||||
.mockResolvedValueOnce(category1Data)
|
||||
.mockResolvedValueOnce(category2Data),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ categoryId }) => useCategoryTemplates(categoryId),
|
||||
{ initialProps: { categoryId: 'category-1' } }
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toEqual(category1Data.templates)
|
||||
expect(mockController.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 'category-1',
|
||||
})
|
||||
)
|
||||
|
||||
rerender({ categoryId: 'category-2' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toEqual(category2Data.templates)
|
||||
expect(mockController.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 'category-2',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should reset pagination state when categoryId changes and refetch is called', async () => {
|
||||
const page1Category1 = {
|
||||
templates: [{ id: '1', title: 'Template 1', titleEn: 'Template 1 EN', previewUrl: 'url1', coverImageUrl: 'cover1', aspectRatio: '16:9' }],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 1,
|
||||
totalPages: 2,
|
||||
}
|
||||
|
||||
const page2Category1 = {
|
||||
templates: [{ id: '2', title: 'Template 2', titleEn: 'Template 2 EN', previewUrl: 'url2', coverImageUrl: 'cover2', aspectRatio: '16:9' }],
|
||||
total: 2,
|
||||
page: 2,
|
||||
limit: 1,
|
||||
totalPages: 2,
|
||||
}
|
||||
|
||||
const page1Category2 = {
|
||||
templates: [{ id: '3', title: 'Template 3', titleEn: 'Template 3 EN', previewUrl: 'url3', coverImageUrl: 'cover3', aspectRatio: '16:9' }],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 1,
|
||||
totalPages: 1,
|
||||
}
|
||||
|
||||
const mockController = {
|
||||
list: jest.fn()
|
||||
.mockResolvedValueOnce(page1Category1)
|
||||
.mockResolvedValueOnce(page2Category1)
|
||||
.mockResolvedValueOnce(page1Category2),
|
||||
}
|
||||
;(root.get as jest.Mock).mockReturnValue(mockController)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ categoryId }) => useCategoryTemplates(categoryId, { limit: 1 }),
|
||||
{ initialProps: { categoryId: 'category-1' } }
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.execute()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.loadMore()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toHaveLength(2)
|
||||
|
||||
rerender({ categoryId: 'category-2' })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refetch()
|
||||
})
|
||||
|
||||
expect(result.current.templates).toEqual(page1Category2.templates)
|
||||
expect(mockController.list).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
categoryId: 'category-2',
|
||||
page: 1,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
145
hooks/use-category-templates.ts
Normal file
145
hooks/use-category-templates.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { root } from '@repo/core'
|
||||
import { type ListTemplatesInput, type ListTemplatesResult, TemplateController, type TemplateDetail } from '@repo/sdk'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { type ApiError } from '@/lib/types'
|
||||
|
||||
import { OWNER_ID } from '@/lib/auth'
|
||||
import { handleError } from './use-error'
|
||||
|
||||
type ListCategoryTemplatesParams = Omit<ListTemplatesInput, 'ownerId'>
|
||||
|
||||
interface UseCategoryTemplatesOptions {
|
||||
categoryId?: string
|
||||
limit?: number
|
||||
sortBy?: 'createdAt' | 'updatedAt'
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
limit: 20,
|
||||
sortBy: 'createdAt' as const,
|
||||
sortOrder: 'desc' as const,
|
||||
}
|
||||
|
||||
export const useCategoryTemplates = (
|
||||
categoryIdOrOptions?: string | UseCategoryTemplatesOptions,
|
||||
additionalOptions?: Omit<UseCategoryTemplatesOptions, 'categoryId'>
|
||||
) => {
|
||||
// 支持多种调用方式:
|
||||
// useCategoryTemplates('category-id')
|
||||
// useCategoryTemplates('category-id', { limit: 50 })
|
||||
// useCategoryTemplates({ categoryId: 'category-id', limit: 50 })
|
||||
const options: UseCategoryTemplatesOptions | undefined = typeof categoryIdOrOptions === 'string'
|
||||
? { categoryId: categoryIdOrOptions, ...additionalOptions }
|
||||
: categoryIdOrOptions
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<ApiError | null>(null)
|
||||
const [data, setData] = useState<ListTemplatesResult>()
|
||||
const currentPageRef = useRef(1)
|
||||
const hasMoreRef = useRef(true)
|
||||
const currentCategoryIdRef = useRef<string | undefined>(options?.categoryId)
|
||||
|
||||
const execute = useCallback(async (params?: ListCategoryTemplatesParams) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
currentPageRef.current = params?.page || 1
|
||||
|
||||
// 确定要使用的 categoryId:params > options(来自 props)> currentCategoryIdRef
|
||||
const categoryIdToUse = params?.categoryId ?? options?.categoryId ?? currentCategoryIdRef.current
|
||||
|
||||
// 更新当前 categoryId ref
|
||||
if (categoryIdToUse !== undefined) {
|
||||
currentCategoryIdRef.current = categoryIdToUse
|
||||
}
|
||||
|
||||
const template = root.get(TemplateController)
|
||||
const requestParams: ListTemplatesInput = {
|
||||
...DEFAULT_PARAMS,
|
||||
...options,
|
||||
...params,
|
||||
page: params?.page ?? 1,
|
||||
categoryId: categoryIdToUse,
|
||||
ownerId: OWNER_ID,
|
||||
}
|
||||
|
||||
const { data, error } = await handleError(
|
||||
async () => await template.list(requestParams),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
setError(error)
|
||||
setLoading(false)
|
||||
return { data: undefined, error }
|
||||
}
|
||||
|
||||
const templates = data?.templates || []
|
||||
const currentPage = requestParams.page || 1
|
||||
const totalPages = data?.totalPages || 1
|
||||
hasMoreRef.current = currentPage < totalPages
|
||||
setData(data)
|
||||
setLoading(false)
|
||||
return { data, error: null }
|
||||
}, [options])
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || loading || !hasMoreRef.current) return { data: undefined, error: null }
|
||||
|
||||
setLoadingMore(true)
|
||||
const nextPage = currentPageRef.current + 1
|
||||
|
||||
// 使用最新的 categoryId:options(来自 props)> currentCategoryIdRef
|
||||
const categoryIdToUse = options?.categoryId ?? currentCategoryIdRef.current
|
||||
|
||||
const template = root.get(TemplateController)
|
||||
const requestParams: ListTemplatesInput = {
|
||||
...DEFAULT_PARAMS,
|
||||
...options,
|
||||
page: nextPage,
|
||||
categoryId: categoryIdToUse,
|
||||
ownerId: OWNER_ID,
|
||||
}
|
||||
|
||||
const { data: newData, error } = await handleError(
|
||||
async () => await template.list(requestParams),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
setLoadingMore(false)
|
||||
return { data: undefined, error }
|
||||
}
|
||||
|
||||
const newTemplates = newData?.templates || []
|
||||
const totalPages = newData?.totalPages || 1
|
||||
hasMoreRef.current = nextPage < totalPages
|
||||
currentPageRef.current = nextPage
|
||||
|
||||
setData((prev) => ({
|
||||
...newData,
|
||||
templates: [...(prev?.templates || []), ...newTemplates],
|
||||
}))
|
||||
setLoadingMore(false)
|
||||
return { data: newData, error: null }
|
||||
}, [loading, loadingMore, options])
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
hasMoreRef.current = true
|
||||
return execute()
|
||||
}, [execute])
|
||||
|
||||
return {
|
||||
data,
|
||||
templates: data?.templates || [],
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
execute,
|
||||
refetch,
|
||||
loadMore,
|
||||
hasMore: hasMoreRef.current,
|
||||
}
|
||||
}
|
||||
|
||||
export type { TemplateDetail }
|
||||
Reference in New Issue
Block a user