From 23fae07a5817a69de2a89e352420051e89861e88 Mon Sep 17 00:00:00 2001 From: imeepos Date: Mon, 26 Jan 2026 17:05:44 +0800 Subject: [PATCH] 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. --- hooks/index.ts | 2 + hooks/use-categories-with-tags.test.ts | 504 +++++++++++++++++ hooks/use-categories-with-tags.ts | 79 +++ hooks/use-category-templates.test.ts | 749 +++++++++++++++++++++++++ hooks/use-category-templates.ts | 145 +++++ 5 files changed, 1479 insertions(+) create mode 100644 hooks/use-categories-with-tags.test.ts create mode 100644 hooks/use-categories-with-tags.ts create mode 100644 hooks/use-category-templates.test.ts create mode 100644 hooks/use-category-templates.ts diff --git a/hooks/index.ts b/hooks/index.ts index c687a21..f1dd063 100644 --- a/hooks/index.ts +++ b/hooks/index.ts @@ -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' diff --git a/hooks/use-categories-with-tags.test.ts b/hooks/use-categories-with-tags.test.ts new file mode 100644 index 0000000..638492b --- /dev/null +++ b/hooks/use-categories-with-tags.test.ts @@ -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, + }) + }) + }) +}) \ No newline at end of file diff --git a/hooks/use-categories-with-tags.ts b/hooks/use-categories-with-tags.ts new file mode 100644 index 0000000..b3e62d1 --- /dev/null +++ b/hooks/use-categories-with-tags.ts @@ -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((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, + } +} diff --git a/hooks/use-category-templates.test.ts b/hooks/use-category-templates.test.ts new file mode 100644 index 0000000..66c0a70 --- /dev/null +++ b/hooks/use-category-templates.test.ts @@ -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, + }) + ) + }) + }) +}) \ No newline at end of file diff --git a/hooks/use-category-templates.ts b/hooks/use-category-templates.ts new file mode 100644 index 0000000..b346278 --- /dev/null +++ b/hooks/use-category-templates.ts @@ -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 + +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 +) => { + // 支持多种调用方式: + // 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(null) + const [data, setData] = useState() + const currentPageRef = useRef(1) + const hasMoreRef = useRef(true) + const currentCategoryIdRef = useRef(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 }