Skip to content

Commit 5585b4f

Browse files
authored
Merge pull request #336 from datum-cloud/feat/324-task-queue
Background Task Queue integration for Staff Portal
2 parents b2dc046 + 4aa19bf commit 5585b4f

37 files changed

+3325
-50
lines changed

app/components/app-topbar/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import AppSearch from '@/components/app-search';
22
import { Separator } from '@/modules/shadcn/ui/separator';
33
import { SidebarTrigger } from '@/modules/shadcn/ui/sidebar';
4+
import { TaskQueueDropdown } from '@datum-ui/task-queue';
45

56
const AppTopbar = () => {
67
return (
78
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
89
<SidebarTrigger className="-ml-1" />
910
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
1011
<AppSearch />
11-
<div className="ml-auto flex items-center space-x-4" />
12+
<div className="ml-auto flex items-center space-x-4">
13+
<TaskQueueDropdown />
14+
</div>
1215
</header>
1316
);
1417
};

app/layouts/private.layout.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { AppProvider } from '@/providers/app.provider';
88
import { userDetailQuery } from '@/resources/request/server';
99
import { getLoginUrl, getRedirectToPath } from '@/utils/cookies';
1010
import { metaObject } from '@/utils/helpers';
11+
import { TaskQueueProvider } from '@datum-ui/task-queue';
1112
import { data, Outlet, redirect, useLoaderData } from 'react-router';
1213

1314
export const meta: Route.MetaFunction = () => {
@@ -36,14 +37,16 @@ export default function PrivateLayout() {
3637

3738
return (
3839
<AppProvider user={data.user ?? undefined}>
39-
<SidebarProvider defaultOpen={false}>
40-
<AppSidebar />
41-
<SidebarInset>
42-
<AppTopbar />
43-
<AppToolbar />
44-
<Outlet />
45-
</SidebarInset>
46-
</SidebarProvider>
40+
<TaskQueueProvider config={{ storageType: 'memory' }}>
41+
<SidebarProvider defaultOpen={false}>
42+
<AppSidebar />
43+
<SidebarInset>
44+
<AppTopbar />
45+
<AppToolbar />
46+
<Outlet />
47+
</SidebarInset>
48+
</SidebarProvider>
49+
</TaskQueueProvider>
4750
</AppProvider>
4851
);
4952
}

app/modules/datum-ui/data-table/components/data-table-select-actions.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,16 @@ export function enhanceFirstColumnWithSelection<TData>(
5353

5454
return (
5555
<div className="flex items-center justify-start gap-2">
56-
<Checkbox
57-
checked={
58-
table.getIsAllPageRowsSelected() ||
59-
(table.getIsSomePageRowsSelected() && 'indeterminate')
60-
}
61-
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
62-
aria-label="Select all"
63-
/>
56+
<div onClick={(e) => e.stopPropagation()} role="presentation">
57+
<Checkbox
58+
checked={
59+
table.getIsAllPageRowsSelected() ||
60+
(table.getIsSomePageRowsSelected() && 'indeterminate')
61+
}
62+
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
63+
aria-label="Select all"
64+
/>
65+
</div>
6466
<div>{originalHeaderContent}</div>
6567
</div>
6668
);
@@ -74,11 +76,13 @@ export function enhanceFirstColumnWithSelection<TData>(
7476

7577
return (
7678
<div className="flex items-center justify-start gap-2">
77-
<Checkbox
78-
checked={row.getIsSelected()}
79-
onCheckedChange={(value) => row.toggleSelected(!!value)}
80-
aria-label="Select row"
81-
/>
79+
<div onClick={(e) => e.stopPropagation()} role="presentation">
80+
<Checkbox
81+
checked={row.getIsSelected()}
82+
onCheckedChange={(value) => row.toggleSelected(!!value)}
83+
aria-label="Select row"
84+
/>
85+
</div>
8286
<div>{originalCellContent}</div>
8387
</div>
8488
);
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# Future Enhancements
2+
3+
## Retry After Page Reload
4+
5+
### Current Limitation
6+
7+
The retry functionality only works within the same browser session. After a page reload, the retry button is hidden because:
8+
9+
1. Task data (title, status, items, progress) is persisted to localStorage
10+
2. Processor functions cannot be serialized to localStorage
11+
3. Without the processor, retry has no function to execute
12+
13+
### Solution: Processor Registry Pattern
14+
15+
To enable retry after page reload, implement a **processor registry** that maps task categories to their processor functions.
16+
17+
#### 1. Update Types
18+
19+
```typescript
20+
// types.ts
21+
export interface ProcessorRegistry {
22+
[category: string]: Task['_processor'];
23+
}
24+
25+
export interface TaskQueueConfig {
26+
concurrency?: number;
27+
storage?: TaskStorage;
28+
storageKey?: string;
29+
redisClient?: RedisClient;
30+
processorRegistry?: ProcessorRegistry; // Add this
31+
}
32+
```
33+
34+
#### 2. Update TaskQueue
35+
36+
```typescript
37+
// engine/queue.ts
38+
export class TaskQueue {
39+
private processorRegistry: ProcessorRegistry = {};
40+
41+
constructor(config: TaskQueueConfig = {}) {
42+
// ... existing code
43+
if (config.processorRegistry) {
44+
this.processorRegistry = config.processorRegistry;
45+
}
46+
}
47+
48+
retry = (taskId: string): void => {
49+
const task = this.storage.get(taskId);
50+
if (!task) return;
51+
if (task.status !== 'failed' && task.status !== 'cancelled') return;
52+
53+
// Try memory first (same session), then registry (after reload)
54+
let processor = this.processors.get(taskId);
55+
56+
if (!processor && task.category) {
57+
processor = this.processorRegistry[task.category];
58+
if (processor) {
59+
this.processors.set(taskId, processor);
60+
}
61+
}
62+
63+
if (!processor) {
64+
console.error('[TaskQueue] retry: no processor found for task', taskId);
65+
return;
66+
}
67+
68+
// ... rest of retry logic
69+
};
70+
}
71+
```
72+
73+
#### 3. Consumer Setup
74+
75+
```tsx
76+
// app/providers/task-queue-setup.tsx
77+
78+
// Define all processors in a centralized registry
79+
export const taskProcessors: ProcessorRegistry = {
80+
'domain-add': async (ctx) => {
81+
for (const domain of ctx.items) {
82+
if (ctx.cancelled) break;
83+
try {
84+
await addDomain(domain);
85+
ctx.succeed(domain);
86+
} catch (error) {
87+
ctx.fail(domain, error.message);
88+
}
89+
}
90+
},
91+
92+
'file-upload': async (ctx) => {
93+
for (const file of ctx.items) {
94+
if (ctx.cancelled) break;
95+
try {
96+
await uploadFile(file);
97+
ctx.succeed(file.name);
98+
} catch (error) {
99+
ctx.fail(file.name, error.message);
100+
}
101+
}
102+
},
103+
104+
'bulk-delete': async (ctx) => {
105+
for (const item of ctx.items) {
106+
if (ctx.cancelled) break;
107+
try {
108+
await deleteItem(item.id);
109+
ctx.succeed(item.id);
110+
} catch (error) {
111+
ctx.fail(item.id, error.message);
112+
}
113+
}
114+
},
115+
};
116+
117+
// Provide to TaskQueueProvider
118+
export function AppProviders({ children }: { children: React.ReactNode }) {
119+
return (
120+
<TaskQueueProvider config={{ processorRegistry: taskProcessors }}>{children}</TaskQueueProvider>
121+
);
122+
}
123+
```
124+
125+
#### 4. Enqueue with Category
126+
127+
```tsx
128+
// When enqueuing, always specify a category that matches the registry
129+
const { enqueue } = useTaskQueue();
130+
131+
function handleAddDomains(domains: string[]) {
132+
enqueue({
133+
title: `Adding ${domains.length} domains`,
134+
category: 'domain-add', // Must match registry key
135+
items: domains,
136+
processor: taskProcessors['domain-add'],
137+
retryable: true,
138+
});
139+
}
140+
```
141+
142+
### How It Works
143+
144+
| Scenario | Processor Source | Works? |
145+
| ------------------------ | ----------------------------- | ------ |
146+
| Same session, no reload | In-memory `processors` Map | Yes |
147+
| After page reload | Registry lookup by `category` | Yes |
148+
| No category specified | Cannot find processor | No |
149+
| Category not in registry | Cannot find processor | No |
150+
151+
### Trade-offs
152+
153+
**Pros:**
154+
155+
- Retry works after page reload
156+
- Processors defined once, reused everywhere
157+
- Clear mapping between task types and processors
158+
159+
**Cons:**
160+
161+
- Requires centralized processor definitions
162+
- All processors must be registered at app startup
163+
- Category must be specified on every enqueue call
164+
165+
### Alternative: Session-Only Retry
166+
167+
If centralized processors are too restrictive, keep retry working only within the same session:
168+
169+
1. Keep retry logic as-is (works within session)
170+
2. Show retry button only when processor exists in memory
171+
3. After reload, show only dismiss button
172+
173+
This requires exposing processor availability to the UI:
174+
175+
```typescript
176+
// In TaskQueue
177+
hasProcessor(taskId: string): boolean {
178+
return this.processors.has(taskId);
179+
}
180+
181+
// In useTaskQueue hook
182+
const canRetry = (task: Task) => {
183+
return task.retryable &&
184+
(task.status === 'failed' || task.status === 'cancelled') &&
185+
queue.hasProcessor(task.id);
186+
};
187+
```
188+
189+
## Other Potential Enhancements
190+
191+
### Task Priority
192+
193+
Add priority levels to control execution order:
194+
195+
```typescript
196+
interface EnqueueOptions {
197+
priority?: 'high' | 'normal' | 'low';
198+
}
199+
```
200+
201+
### Progress Bar UI
202+
203+
Add visual progress indicator for batch tasks:
204+
205+
```tsx
206+
<div className="bg-muted h-1 overflow-hidden rounded-full">
207+
<div
208+
className="bg-primary h-full transition-all"
209+
style={{ width: `${(completed / total) * 100}%` }}
210+
/>
211+
</div>
212+
```
213+
214+
### Task Groups
215+
216+
Group related tasks together:
217+
218+
```typescript
219+
interface EnqueueOptions {
220+
groupId?: string;
221+
groupTitle?: string;
222+
}
223+
```
224+
225+
### Notification Integration
226+
227+
Notify user when background tasks complete:
228+
229+
```typescript
230+
interface TaskQueueConfig {
231+
onTaskComplete?: (task: Task) => void;
232+
onTaskFail?: (task: Task) => void;
233+
}
234+
```

0 commit comments

Comments
 (0)