feat(agent-office): useActivityFeed 훅 (페이지네이션·필터·refresh)
This commit is contained in:
57
src/pages/agent-office/hooks/useActivityFeed.js
Normal file
57
src/pages/agent-office/hooks/useActivityFeed.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// src/pages/agent-office/hooks/useActivityFeed.js
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { agentActivity } from '../../../api';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
export function useActivityFeed(filters, refreshTrigger = 0) {
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const offsetRef = useRef(0);
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
const filtersRef = useRef(filters);
|
||||||
|
filtersRef.current = filters;
|
||||||
|
|
||||||
|
const filterKey = JSON.stringify(filters);
|
||||||
|
|
||||||
|
const fetchPage = useCallback(async (offset, replace) => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
|
||||||
|
const newItems = Array.isArray(data?.items) ? data.items : [];
|
||||||
|
setTotal(data?.total || 0);
|
||||||
|
setItems(prev => (replace ? newItems : [...prev, ...newItems]));
|
||||||
|
offsetRef.current = offset + newItems.length;
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || '불러오기 실패');
|
||||||
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
offsetRef.current = 0;
|
||||||
|
fetchPage(0, true);
|
||||||
|
}, [filterKey, refreshTrigger, fetchPage]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
if (offsetRef.current >= total) return;
|
||||||
|
fetchPage(offsetRef.current, false);
|
||||||
|
}, [fetchPage, total]);
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
offsetRef.current = 0;
|
||||||
|
fetchPage(0, true);
|
||||||
|
}, [fetchPage]);
|
||||||
|
|
||||||
|
const hasMore = items.length < total;
|
||||||
|
return { items, total, loading, error, hasMore, loadMore, retry };
|
||||||
|
}
|
||||||
59
src/pages/agent-office/hooks/useActivityFeed.test.js
Normal file
59
src/pages/agent-office/hooks/useActivityFeed.test.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
|
import { useActivityFeed } from './useActivityFeed.js';
|
||||||
|
|
||||||
|
const mockAgentActivity = vi.fn();
|
||||||
|
vi.mock('../../../api', () => ({
|
||||||
|
agentActivity: (...args) => mockAgentActivity(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => mockAgentActivity.mockReset());
|
||||||
|
|
||||||
|
const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over });
|
||||||
|
|
||||||
|
describe('useActivityFeed', () => {
|
||||||
|
it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 });
|
||||||
|
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
|
expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 }));
|
||||||
|
expect(result.current.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadMore는 다음 offset으로 append한다', async () => {
|
||||||
|
mockAgentActivity
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 })
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 });
|
||||||
|
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
|
await act(async () => { result.current.loadMore(); });
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(2));
|
||||||
|
expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('필터 변경 시 offset 리셋 + items 교체', async () => {
|
||||||
|
mockAgentActivity
|
||||||
|
.mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 })
|
||||||
|
.mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 });
|
||||||
|
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||||
|
await waitFor(() => expect(result.current.items[0].task_id).toBe('a'));
|
||||||
|
rerender({ f: { days: 7, agent_id: 'insta' } });
|
||||||
|
await waitFor(() => expect(result.current.items[0].task_id).toBe('c'));
|
||||||
|
expect(result.current.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshTrigger 변경 시 첫 페이지 재조회', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 });
|
||||||
|
const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } });
|
||||||
|
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1));
|
||||||
|
rerender({ rt: 1 });
|
||||||
|
await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasMore는 items.length < total', async () => {
|
||||||
|
mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 });
|
||||||
|
const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0));
|
||||||
|
await waitFor(() => expect(result.current.items).toHaveLength(1));
|
||||||
|
expect(result.current.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user