feat(agent-office): useActivityFeed 훅 (페이지네이션·필터·refresh)

This commit is contained in:
2026-06-11 09:05:01 +09:00
parent 2afcf487a1
commit ae6454ed37
2 changed files with 116 additions and 0 deletions

View 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 };
}

View 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);
});
});