feat(agent-office): ActivityTimeline 컨테이너 (필터+무한스크롤)
This commit is contained in:
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// src/pages/agent-office/components/ActivityTimeline.jsx
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useActivityFeed } from '../hooks/useActivityFeed.js';
|
||||
import ActivityFilters from './ActivityFilters.jsx';
|
||||
import ActivityItem from './ActivityItem.jsx';
|
||||
|
||||
const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 };
|
||||
|
||||
export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) {
|
||||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||||
const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger);
|
||||
|
||||
const sentinelRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(entries => {
|
||||
if (entries[0].isIntersecting) loadMore();
|
||||
}, { rootMargin: '120px' });
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [loadMore, items.length]);
|
||||
|
||||
return (
|
||||
<div className="ao-sidepanel ao-activity">
|
||||
<div className="ao-sidepanel-header ao-activity-header">
|
||||
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||
</div>
|
||||
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||
<div className="ao-sidepanel-content ao-activity-content">
|
||||
{error && (
|
||||
<div className="ao-activity-error">
|
||||
불러오기 실패: {error}
|
||||
<button type="button" onClick={retry}>재시도</button>
|
||||
</div>
|
||||
)}
|
||||
{!error && items.length === 0 && !loading && (
|
||||
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<ActivityItem
|
||||
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||
item={item}
|
||||
onSelectAgent={onSelectAgent}
|
||||
/>
|
||||
))}
|
||||
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import ActivityTimeline from './ActivityTimeline.jsx';
|
||||
|
||||
// jsdom IntersectionObserver stub
|
||||
beforeEach(() => {
|
||||
global.IntersectionObserver = class {
|
||||
observe() {} unobserve() {} disconnect() {}
|
||||
};
|
||||
});
|
||||
|
||||
const mockAgentActivity = vi.fn();
|
||||
vi.mock('../../../api', () => ({
|
||||
agentActivity: (...args) => mockAgentActivity(...args),
|
||||
}));
|
||||
|
||||
describe('ActivityTimeline', () => {
|
||||
it('항목을 렌더하고 헤더에 total을 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('항목 클릭 시 onSelectAgent가 호출된다', async () => {
|
||||
const onSelect = vi.fn();
|
||||
mockAgentActivity.mockResolvedValueOnce({
|
||||
items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }],
|
||||
total: 1,
|
||||
});
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||
const row = await screen.findByText('signal_check');
|
||||
fireEvent.click(row.closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('lotto');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user