Skip to content

Commit 91f44b3

Browse files
committed
feat: add BackgroundJobItem component with job status handling and actions
1 parent ce95bad commit 91f44b3

File tree

3 files changed

+292
-3
lines changed

3 files changed

+292
-3
lines changed

.storybook/preview.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import type { Preview } from '@storybook/react-vite'
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
23
import '../src/styles.css'
34

5+
const queryClient = new QueryClient({
6+
defaultOptions: {
7+
queries: {
8+
retry: false,
9+
staleTime: Infinity,
10+
},
11+
},
12+
})
13+
414
const preview: Preview = {
515
parameters: {
616
darkMode: {
@@ -21,9 +31,11 @@ const preview: Preview = {
2131
},
2232
decorators: [
2333
(Story) => (
24-
<div className="p-4">
25-
<Story />
26-
</div>
34+
<QueryClientProvider client={queryClient}>
35+
<div className="p-4">
36+
<Story />
37+
</div>
38+
</QueryClientProvider>
2739
),
2840
],
2941
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { Meta, StoryObj } from '@storybook/react'
2+
import { BackgroundJobItem } from './BackgroundJobItem'
3+
import type { BackgroundJob } from '@/lib/schemas'
4+
5+
const meta: Meta<typeof BackgroundJobItem> = {
6+
title: 'Components/BackgroundJobItem',
7+
component: BackgroundJobItem,
8+
parameters: {
9+
layout: 'padded',
10+
},
11+
argTypes: {
12+
onLoadResponse: { action: 'loadResponse' },
13+
onCancelJob: { action: 'cancelJob' },
14+
updateJob: { action: 'updateJob' },
15+
removeJob: { action: 'removeJob' },
16+
},
17+
}
18+
19+
export default meta
20+
type Story = StoryObj<typeof BackgroundJobItem>
21+
22+
const baseJob: BackgroundJob = {
23+
id: 'job-1',
24+
status: 'running',
25+
createdAt: '2024-01-15T10:30:00Z',
26+
title: 'Analyzing code repository structure',
27+
}
28+
29+
export const Running: Story = {
30+
args: {
31+
job: baseJob,
32+
},
33+
}
34+
35+
export const Completed: Story = {
36+
args: {
37+
job: {
38+
...baseJob,
39+
status: 'completed',
40+
completedAt: '2024-01-15T10:35:00Z',
41+
response:
42+
'Analysis complete. Found 15 components, 8 hooks, and 12 utility functions.',
43+
},
44+
},
45+
}
46+
47+
export const Failed: Story = {
48+
args: {
49+
job: {
50+
...baseJob,
51+
status: 'failed',
52+
completedAt: '2024-01-15T10:32:00Z',
53+
},
54+
},
55+
}
56+
57+
export const FailedWithResponse: Story = {
58+
args: {
59+
job: {
60+
...baseJob,
61+
status: 'failed',
62+
completedAt: '2024-01-15T10:32:00Z',
63+
response: 'Partial analysis completed before error occurred.',
64+
error: 'Network timeout during file analysis.',
65+
},
66+
},
67+
}
68+
69+
export const LongTitle: Story = {
70+
args: {
71+
job: {
72+
...baseJob,
73+
title:
74+
'Performing comprehensive security audit of the entire application including all dependencies and configuration files',
75+
},
76+
},
77+
}
78+
79+
export const NoCallbacks: Story = {
80+
args: {
81+
job: baseJob,
82+
onLoadResponse: undefined,
83+
onCancelJob: undefined,
84+
},
85+
}

src/components/BackgroundJobItem.tsx

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { Button } from './ui/button'
2+
import { Clock, Play, X, Trash2 } from 'lucide-react'
3+
import { useBackgroundJobStatus } from '@/hooks/useBackgroundJobStatus'
4+
import type { BackgroundJob } from '@/lib/schemas'
5+
import { useEffect } from 'react'
6+
7+
export function BackgroundJobItem({
8+
job,
9+
onLoadResponse,
10+
onCancelJob,
11+
updateJob,
12+
removeJob,
13+
}: {
14+
job: BackgroundJob
15+
onLoadResponse?: (jobId: string, response: string) => void
16+
onCancelJob?: (jobId: string) => void
17+
updateJob: (jobId: string, updates: Partial<BackgroundJob>) => void
18+
removeJob: (jobId: string) => void
19+
}) {
20+
// Use polling hook to check job status - continue polling until definitely complete or failed
21+
const shouldPoll =
22+
job.status === 'running' || (job.status === 'failed' && !job.completedAt)
23+
24+
const { data: statusData } = useBackgroundJobStatus(
25+
shouldPoll ? job : undefined,
26+
2000,
27+
)
28+
29+
// Update job when status changes
30+
useEffect(() => {
31+
if (statusData) {
32+
console.log('Polling data received:', statusData)
33+
console.log('Current job:', job)
34+
35+
const needsUpdate =
36+
statusData.status !== job.status ||
37+
statusData.response !== job.response ||
38+
statusData.error !== job.error ||
39+
statusData.completedAt !== job.completedAt
40+
41+
if (needsUpdate) {
42+
console.log('Updating job with:', {
43+
status: statusData.status,
44+
response: statusData.response,
45+
error: statusData.error,
46+
completedAt: statusData.completedAt,
47+
})
48+
49+
updateJob(job.id, {
50+
status: statusData.status,
51+
response: statusData.response || job.response,
52+
error: statusData.error,
53+
completedAt: statusData.completedAt || job.completedAt,
54+
})
55+
}
56+
}
57+
}, [statusData, job, updateJob])
58+
59+
const handleLoadResponse = () => {
60+
if (onLoadResponse) {
61+
// If no response yet, load a placeholder or empty content
62+
const responseContent =
63+
job.response ||
64+
'[Job is still running - partial response will appear here]'
65+
onLoadResponse(job.id, responseContent)
66+
}
67+
}
68+
69+
const handleCancelJob = () => {
70+
if (onCancelJob) {
71+
onCancelJob(job.id)
72+
}
73+
// Update job status to indicate cancellation attempt
74+
updateJob(job.id, { status: 'failed', error: 'Cancelled by user' })
75+
}
76+
77+
const handleDeleteJob = () => {
78+
removeJob(job.id)
79+
}
80+
81+
const getStatusIcon = (status: BackgroundJob['status']) => {
82+
switch (status) {
83+
case 'running':
84+
return <Clock className="h-4 w-4 text-blue-500 animate-spin" />
85+
case 'completed':
86+
return <Play className="h-4 w-4 text-green-500" />
87+
case 'failed':
88+
return <X className="h-4 w-4 text-red-500" />
89+
default:
90+
return <Clock className="h-4 w-4 text-gray-500" />
91+
}
92+
}
93+
94+
const getStatusText = (status: BackgroundJob['status']) => {
95+
switch (status) {
96+
case 'running':
97+
return 'Running'
98+
case 'completed':
99+
return 'Completed'
100+
case 'failed':
101+
return 'Failed'
102+
default:
103+
return 'Unknown'
104+
}
105+
}
106+
107+
const formatTimestamp = (timestamp: string) => {
108+
try {
109+
return new Date(timestamp).toLocaleString()
110+
} catch {
111+
return timestamp
112+
}
113+
}
114+
115+
return (
116+
<section
117+
className="grid gap-2 border rounded-lg p-4 space-y-3 bg-card"
118+
aria-label={job.title}
119+
>
120+
<header className="flex items-center justify-between">
121+
<div className="flex items-center gap-2">
122+
{getStatusIcon(job.status)}
123+
<span className="font-medium">{getStatusText(job.status)}</span>
124+
</div>
125+
<time
126+
className="text-xs text-muted-foreground"
127+
dateTime={job.completedAt ?? job.createdAt}
128+
>
129+
{formatTimestamp(job.createdAt)}
130+
</time>
131+
</header>
132+
133+
<span className="text-sm font-medium truncate" title={job.title}>
134+
{job.title}
135+
</span>
136+
137+
{job.error && (
138+
<div
139+
className="text-sm text-red-600 bg-red-50 p-2 rounded"
140+
role="alert"
141+
aria-live="assertive"
142+
>
143+
{job.error}
144+
</div>
145+
)}
146+
147+
<nav
148+
aria-label={`Job actions for background job ${job.title}`}
149+
className="flex justify-end"
150+
>
151+
<ul className="flex gap-1">
152+
<li>
153+
<Button
154+
variant="outline"
155+
size="sm"
156+
onClick={handleLoadResponse}
157+
disabled={job.status === 'failed' && !job.response}
158+
>
159+
<Play className="h-3 w-3 mr-1" />
160+
<span className="sr-only md:not-sr-only">
161+
{job.status === 'running' && !job.response
162+
? 'Load Progress'
163+
: job.status === 'running' && job.response
164+
? 'Load Partial'
165+
: 'Load Response'}
166+
</span>
167+
</Button>
168+
</li>
169+
{job.status === 'running' && (
170+
<li>
171+
<Button variant="outline" size="sm" onClick={handleCancelJob}>
172+
<X className="h-3 w-3 mr-1" />
173+
<span>Cancel</span>
174+
</Button>
175+
</li>
176+
)}
177+
<li>
178+
<Button
179+
variant="outline"
180+
size="sm"
181+
onClick={handleDeleteJob}
182+
className="px-2"
183+
>
184+
<Trash2 className="h-3 w-3" />
185+
<span className="sr-only md:not-sr-only">Delete</span>
186+
</Button>
187+
</li>
188+
</ul>
189+
</nav>
190+
</section>
191+
)
192+
}

0 commit comments

Comments
 (0)