1322 lines
32 KiB
Markdown
1322 lines
32 KiB
Markdown
# Publishing Modals - Developer Documentation
|
|
|
|
**Last Updated**: January 2026
|
|
**Status**: Production
|
|
**Audience**: Frontend Developers, System Architects
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
This document provides technical documentation for the publishing modal components system. These modals provide real-time progress feedback for content publishing operations across multiple platforms (WordPress, Shopify, Custom Sites).
|
|
|
|
**Design Pattern**: Follows the existing `ImageQueueModal` pattern for consistency.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
### Component Hierarchy
|
|
|
|
```
|
|
PublishingProgressModal (single item)
|
|
└─ Uses: Progress animation, status stages, error handling
|
|
|
|
BulkPublishingModal (multiple items)
|
|
└─ Uses: Queue state management, sequential processing
|
|
└─ PublishingProgressModal logic per item
|
|
|
|
PublishLimitModal (validation)
|
|
└─ Simple informational modal
|
|
|
|
ScheduleContentModal (date/time picker)
|
|
└─ Date/time selection, validation
|
|
|
|
BulkScheduleModal (manual scheduling)
|
|
└─ Single date/time for all items
|
|
|
|
BulkSchedulePreviewModal (site defaults)
|
|
└─ Preview schedule based on site settings
|
|
```
|
|
|
|
### State Management
|
|
|
|
**Local Component State**: Used for modal-specific data
|
|
- Modal open/close
|
|
- Progress tracking
|
|
- Error states
|
|
- Queue items
|
|
|
|
**Parent Page State**: Manages content list and selected items
|
|
- Content array
|
|
- Selected IDs
|
|
- Refresh triggers
|
|
|
|
**No Global State**: No Redux/Zustand needed for current implementation
|
|
|
|
---
|
|
|
|
## 1. PublishingProgressModal
|
|
|
|
### Purpose
|
|
|
|
Shows real-time progress for publishing a single content item to a site (WordPress, Shopify, or Custom).
|
|
|
|
### File Location
|
|
|
|
```
|
|
frontend/src/components/common/PublishingProgressModal.tsx
|
|
```
|
|
|
|
### Props Interface
|
|
|
|
```typescript
|
|
interface PublishingProgressModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
content: {
|
|
id: number;
|
|
title: string;
|
|
};
|
|
site: {
|
|
id: number;
|
|
name: string;
|
|
platform_type: 'wordpress' | 'shopify' | 'custom';
|
|
};
|
|
onSuccess?: (publishedUrl: string, externalId: string) => void;
|
|
onError?: (error: string) => void;
|
|
}
|
|
```
|
|
|
|
### State Interface
|
|
|
|
```typescript
|
|
interface PublishingProgressState {
|
|
contentId: number;
|
|
contentTitle: string;
|
|
siteName: string;
|
|
platformType: string;
|
|
status: 'preparing' | 'uploading' | 'processing' | 'finalizing' | 'completed' | 'failed';
|
|
progress: number; // 0-100
|
|
statusMessage: string;
|
|
error: string | null;
|
|
externalUrl: string | null;
|
|
externalId: string | null;
|
|
}
|
|
```
|
|
|
|
### Progress Stages
|
|
|
|
| Stage | Progress | Duration | Description |
|
|
|-------|----------|----------|-------------|
|
|
| Preparing | 0-25% | 2.5s | Validating content structure |
|
|
| Uploading | 25-50% | Variable | POST request to platform API |
|
|
| Processing | 50-75% | 1s | Handling platform response |
|
|
| Finalizing | 75-100% | 0.8s | Updating content record |
|
|
|
|
### Progress Animation Logic
|
|
|
|
```typescript
|
|
const animateProgress = (start: number, end: number, duration: number) => {
|
|
const steps = Math.ceil(duration / 100); // Update every 100ms
|
|
const increment = (end - start) / steps;
|
|
let currentProgress = start;
|
|
|
|
const interval = setInterval(() => {
|
|
currentProgress += increment;
|
|
if (currentProgress >= end) {
|
|
setProgress(end);
|
|
clearInterval(interval);
|
|
} else {
|
|
setProgress(Math.round(currentProgress));
|
|
}
|
|
}, 100);
|
|
|
|
return () => clearInterval(interval);
|
|
};
|
|
```
|
|
|
|
### API Integration
|
|
|
|
**Endpoint**: `POST /api/v1/publisher/publish/`
|
|
|
|
**Request Payload**:
|
|
```typescript
|
|
{
|
|
content_id: number;
|
|
destinations: ['wordpress' | 'shopify' | 'custom']; // Based on site.platform_type
|
|
}
|
|
```
|
|
|
|
**Success Response**:
|
|
```typescript
|
|
{
|
|
success: true;
|
|
data: {
|
|
success: true;
|
|
results: [
|
|
{
|
|
destination: string; // 'wordpress', 'shopify', or 'custom'
|
|
success: true;
|
|
external_id: string;
|
|
url: string;
|
|
publishing_record_id: number;
|
|
platform_type: string;
|
|
}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
**Error Response**:
|
|
```typescript
|
|
{
|
|
success: false;
|
|
error: string; // User-friendly error message
|
|
}
|
|
```
|
|
|
|
### Publishing Flow
|
|
|
|
```typescript
|
|
const handlePublish = async () => {
|
|
try {
|
|
// Stage 1: Preparing (0-25%)
|
|
setStatus('preparing');
|
|
setStatusMessage('Validating content...');
|
|
animateProgress(0, 25, 2500);
|
|
await wait(2500);
|
|
|
|
// Stage 2: Uploading (25-50%)
|
|
setStatus('uploading');
|
|
setStatusMessage(`Uploading to ${site.name}...`);
|
|
setProgress(25);
|
|
|
|
const response = await fetchAPI('/v1/publisher/publish/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
content_id: content.id,
|
|
destinations: [site.platform_type]
|
|
})
|
|
});
|
|
|
|
// Stage 3: Processing (50-75%)
|
|
setStatus('processing');
|
|
setStatusMessage('Processing response...');
|
|
animateProgress(50, 75, 1000);
|
|
await wait(1000);
|
|
|
|
if (response.success && response.data?.results?.[0]?.success) {
|
|
const result = response.data.results[0];
|
|
|
|
// Stage 4: Finalizing (75-100%)
|
|
setStatus('finalizing');
|
|
setStatusMessage('Finalizing...');
|
|
animateProgress(75, 100, 800);
|
|
await wait(800);
|
|
|
|
// Success
|
|
setStatus('completed');
|
|
setProgress(100);
|
|
setExternalUrl(result.url);
|
|
setExternalId(result.external_id);
|
|
setStatusMessage('Published successfully!');
|
|
|
|
onSuccess?.(result.url, result.external_id);
|
|
} else {
|
|
throw new Error(response.error || 'Publishing failed');
|
|
}
|
|
} catch (error) {
|
|
setStatus('failed');
|
|
setProgress(0);
|
|
setError(error.message);
|
|
setStatusMessage('Publishing failed');
|
|
onError?.(error.message);
|
|
}
|
|
};
|
|
```
|
|
|
|
### Error Handling
|
|
|
|
**Display Error State**:
|
|
```typescript
|
|
{status === 'failed' && (
|
|
<div className="bg-red-50 border border-red-200 rounded p-4">
|
|
<div className="flex items-start gap-3">
|
|
<ExclamationCircleIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-red-800">Publishing Failed</p>
|
|
<p className="text-sm text-red-600 mt-1">{error}</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 flex gap-2">
|
|
<Button variant="danger" size="sm" onClick={handleRetry}>
|
|
Retry
|
|
</Button>
|
|
<Button variant="outline" size="sm" onClick={copyErrorToClipboard}>
|
|
Copy Error
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
**Retry Logic**:
|
|
```typescript
|
|
const handleRetry = () => {
|
|
setError(null);
|
|
setProgress(0);
|
|
handlePublish();
|
|
};
|
|
```
|
|
|
|
### Success State
|
|
|
|
```typescript
|
|
{status === 'completed' && (
|
|
<div className="bg-green-50 border border-green-200 rounded p-4">
|
|
<div className="flex items-center gap-3 mb-3">
|
|
<CheckCircleIcon className="w-8 h-8 text-green-500" />
|
|
<div>
|
|
<p className="text-lg font-semibold text-green-800">
|
|
Published Successfully!
|
|
</p>
|
|
<p className="text-sm text-green-600">
|
|
Content is now live on {siteName}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{externalUrl && (
|
|
<Button
|
|
variant="success"
|
|
onClick={() => window.open(externalUrl, '_blank')}
|
|
icon={<ExternalLinkIcon className="w-4 h-4" />}
|
|
>
|
|
View on {siteName}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
### Modal Control
|
|
|
|
**Cannot Close During Publishing**:
|
|
```typescript
|
|
const canClose = status === 'completed' || status === 'failed' || status === null;
|
|
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={canClose ? onClose : undefined}
|
|
closeOnOverlayClick={canClose}
|
|
closeOnEsc={canClose}
|
|
>
|
|
{/* Modal content */}
|
|
|
|
{canClose && (
|
|
<Button onClick={onClose}>Close</Button>
|
|
)}
|
|
</Modal>
|
|
```
|
|
|
|
---
|
|
|
|
## 2. BulkPublishingModal
|
|
|
|
### Purpose
|
|
|
|
Publishes multiple content items sequentially with individual progress tracking for each item.
|
|
|
|
### File Location
|
|
|
|
```
|
|
frontend/src/components/common/BulkPublishingModal.tsx
|
|
```
|
|
|
|
### Props Interface
|
|
|
|
```typescript
|
|
interface BulkPublishingModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
contentItems: Array<{
|
|
id: number;
|
|
title: string;
|
|
}>;
|
|
site: {
|
|
id: number;
|
|
name: string;
|
|
platform_type: 'wordpress' | 'shopify' | 'custom';
|
|
};
|
|
onComplete?: (results: PublishResult[]) => void;
|
|
}
|
|
|
|
interface PublishResult {
|
|
contentId: number;
|
|
success: boolean;
|
|
externalUrl?: string;
|
|
externalId?: string;
|
|
error?: string;
|
|
}
|
|
```
|
|
|
|
### Queue Item State
|
|
|
|
```typescript
|
|
interface PublishQueueItem {
|
|
contentId: number;
|
|
contentTitle: string;
|
|
index: number; // Display as 1-based
|
|
status: 'pending' | 'processing' | 'completed' | 'failed';
|
|
progress: number; // 0-100
|
|
statusMessage: string;
|
|
error: string | null;
|
|
externalUrl: string | null;
|
|
externalId: string | null;
|
|
}
|
|
```
|
|
|
|
### Sequential Processing
|
|
|
|
**Why Sequential?**
|
|
- Avoid overwhelming platform APIs
|
|
- Easier error tracking
|
|
- Respects rate limits
|
|
- Better UX (clear progress)
|
|
|
|
**Implementation**:
|
|
```typescript
|
|
const processBulkPublish = async () => {
|
|
const queue: PublishQueueItem[] = contentItems.map((item, index) => ({
|
|
contentId: item.id,
|
|
contentTitle: item.title,
|
|
index: index + 1,
|
|
status: 'pending',
|
|
progress: 0,
|
|
statusMessage: 'Pending',
|
|
error: null,
|
|
externalUrl: null,
|
|
externalId: null,
|
|
}));
|
|
|
|
setQueue(queue);
|
|
|
|
// Process sequentially
|
|
for (let i = 0; i < queue.length; i++) {
|
|
await processQueueItem(i);
|
|
}
|
|
|
|
setAllComplete(true);
|
|
onComplete?.(queue.map(item => ({
|
|
contentId: item.contentId,
|
|
success: item.status === 'completed',
|
|
externalUrl: item.externalUrl,
|
|
externalId: item.externalId,
|
|
error: item.error,
|
|
})));
|
|
};
|
|
|
|
const processQueueItem = async (index: number) => {
|
|
// Update to processing
|
|
updateQueueItem(index, {
|
|
status: 'processing',
|
|
progress: 0,
|
|
statusMessage: 'Preparing...',
|
|
});
|
|
|
|
try {
|
|
// Animate 0-25%
|
|
await animateQueueItemProgress(index, 0, 25, 2500);
|
|
|
|
// API call
|
|
updateQueueItem(index, {
|
|
progress: 25,
|
|
statusMessage: `Uploading to ${site.name}...`,
|
|
});
|
|
|
|
const response = await fetchAPI('/v1/publisher/publish/', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
content_id: queue[index].contentId,
|
|
destinations: [site.platform_type]
|
|
})
|
|
});
|
|
|
|
// Animate 25-50%
|
|
await animateQueueItemProgress(index, 25, 50, 500);
|
|
|
|
if (response.success && response.data?.results?.[0]?.success) {
|
|
const result = response.data.results[0];
|
|
|
|
// Animate to completion
|
|
updateQueueItem(index, {
|
|
progress: 50,
|
|
statusMessage: 'Finalizing...',
|
|
});
|
|
await animateQueueItemProgress(index, 50, 100, 1000);
|
|
|
|
// Success
|
|
updateQueueItem(index, {
|
|
status: 'completed',
|
|
progress: 100,
|
|
statusMessage: 'Published',
|
|
externalUrl: result.url,
|
|
externalId: result.external_id,
|
|
});
|
|
} else {
|
|
throw new Error(response.error || 'Unknown error');
|
|
}
|
|
} catch (error) {
|
|
updateQueueItem(index, {
|
|
status: 'failed',
|
|
progress: 0,
|
|
statusMessage: 'Failed',
|
|
error: error.message,
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
### Queue UI Rendering
|
|
|
|
```typescript
|
|
<div className="space-y-3 max-h-96 overflow-y-auto">
|
|
{queue.map((item) => (
|
|
<div
|
|
key={item.contentId}
|
|
className={cn(
|
|
'border rounded-lg p-4',
|
|
item.status === 'completed' && 'bg-green-50 border-green-200',
|
|
item.status === 'failed' && 'bg-red-50 border-red-200',
|
|
item.status === 'processing' && 'bg-blue-50 border-blue-200',
|
|
item.status === 'pending' && 'bg-gray-50 border-gray-200'
|
|
)}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg font-semibold text-gray-600">
|
|
{item.index}.
|
|
</span>
|
|
<span className="font-medium text-gray-800 truncate max-w-md">
|
|
{item.contentTitle}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Status Icon */}
|
|
<div className="flex items-center gap-2">
|
|
{item.status === 'completed' && (
|
|
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
|
)}
|
|
{item.status === 'processing' && (
|
|
<ArrowPathIcon className="w-5 h-5 text-blue-500 animate-spin" />
|
|
)}
|
|
{item.status === 'failed' && (
|
|
<XCircleIcon className="w-5 h-5 text-red-500" />
|
|
)}
|
|
{item.status === 'pending' && (
|
|
<ClockIcon className="w-5 h-5 text-gray-400" />
|
|
)}
|
|
<span className="text-sm font-medium text-gray-600">
|
|
{item.progress}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="w-full bg-gray-200 rounded-full h-2 mb-2">
|
|
<div
|
|
className={cn(
|
|
'h-2 rounded-full transition-all duration-300',
|
|
item.status === 'completed' && 'bg-green-500',
|
|
item.status === 'processing' && 'bg-blue-500',
|
|
item.status === 'failed' && 'bg-red-500',
|
|
item.status === 'pending' && 'bg-gray-400'
|
|
)}
|
|
style={{ width: `${item.progress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Status Message */}
|
|
<p className="text-sm text-gray-600">{item.statusMessage}</p>
|
|
|
|
{/* Success: Published URL */}
|
|
{item.status === 'completed' && item.externalUrl && (
|
|
<a
|
|
href={item.externalUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-blue-600 hover:underline mt-1 inline-flex items-center gap-1"
|
|
>
|
|
View on {site.name}
|
|
<ExternalLinkIcon className="w-3 h-3" />
|
|
</a>
|
|
)}
|
|
|
|
{/* Error: Message + Retry */}
|
|
{item.status === 'failed' && (
|
|
<div className="mt-2">
|
|
<p className="text-sm text-red-600">{item.error}</p>
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={() => retryQueueItem(item.index - 1)}
|
|
className="mt-2"
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
|
<p className="text-sm text-gray-600">
|
|
{completedCount} completed, {failedCount} failed, {pendingCount} pending
|
|
</p>
|
|
</div>
|
|
```
|
|
|
|
### Retry Individual Item
|
|
|
|
```typescript
|
|
const retryQueueItem = async (index: number) => {
|
|
// Reset item state
|
|
updateQueueItem(index, {
|
|
status: 'pending',
|
|
progress: 0,
|
|
statusMessage: 'Retrying...',
|
|
error: null,
|
|
});
|
|
|
|
// Process again
|
|
await processQueueItem(index);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 3. PublishLimitModal
|
|
|
|
### Purpose
|
|
|
|
Informs user when they try to bulk publish more than 5 items and suggests scheduling instead.
|
|
|
|
### File Location
|
|
|
|
```
|
|
frontend/src/components/common/PublishLimitModal.tsx
|
|
```
|
|
|
|
### Props Interface
|
|
|
|
```typescript
|
|
interface PublishLimitModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
selectedCount: number;
|
|
onScheduleInstead: () => void;
|
|
}
|
|
```
|
|
|
|
### Implementation
|
|
|
|
```typescript
|
|
const PublishLimitModal: React.FC<PublishLimitModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
selectedCount,
|
|
onScheduleInstead,
|
|
}) => {
|
|
return (
|
|
<Modal isOpen={isOpen} onClose={onClose}>
|
|
<div className="text-center">
|
|
{/* Icon */}
|
|
<ExclamationTriangleIcon className="w-16 h-16 text-yellow-500 mx-auto mb-4" />
|
|
|
|
{/* Title */}
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
Publishing Limit Exceeded
|
|
</h3>
|
|
|
|
{/* Message */}
|
|
<p className="text-gray-600 mb-4">
|
|
You can publish only <strong>5 content pages</strong> directly.
|
|
</p>
|
|
<p className="text-gray-600 mb-4">
|
|
You have selected <strong>{selectedCount} items</strong>.
|
|
</p>
|
|
|
|
{/* Options Box */}
|
|
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6 text-left">
|
|
<p className="font-semibold mb-2">Options:</p>
|
|
<ul className="list-disc list-inside space-y-1 text-sm text-gray-700">
|
|
<li>Deselect items to publish 5 or fewer</li>
|
|
<li>Use "Schedule Selected" to schedule all items</li>
|
|
</ul>
|
|
<p className="text-xs text-gray-600 mt-3 flex items-start gap-2">
|
|
<InformationCircleIcon className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
|
<span>
|
|
Tip: Scheduling has no limit and uses your site's default publishing schedule.
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 justify-center">
|
|
<Button variant="outline" onClick={onClose}>
|
|
Go Back
|
|
</Button>
|
|
<Button variant="primary" onClick={onScheduleInstead}>
|
|
Schedule Selected
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
};
|
|
```
|
|
|
|
### Usage in Parent Component
|
|
|
|
```typescript
|
|
// In Approved.tsx
|
|
const [showPublishLimitModal, setShowPublishLimitModal] = useState(false);
|
|
|
|
const handleBulkPublishClick = () => {
|
|
const selectedCount = selectedIds.length;
|
|
|
|
// Validate: Max 5 items
|
|
if (selectedCount > 5) {
|
|
setShowPublishLimitModal(true);
|
|
return;
|
|
}
|
|
|
|
// Proceed with bulk publish
|
|
setShowBulkPublishingModal(true);
|
|
};
|
|
|
|
const handleScheduleInstead = () => {
|
|
setShowPublishLimitModal(false);
|
|
// Open bulk schedule modal with site defaults
|
|
handleBulkScheduleWithDefaults();
|
|
};
|
|
|
|
// In JSX
|
|
<PublishLimitModal
|
|
isOpen={showPublishLimitModal}
|
|
onClose={() => setShowPublishLimitModal(false)}
|
|
selectedCount={selectedIds.length}
|
|
onScheduleInstead={handleScheduleInstead}
|
|
/>
|
|
```
|
|
|
|
---
|
|
|
|
## 4. ScheduleContentModal
|
|
|
|
### Purpose
|
|
|
|
Allows user to schedule or reschedule a single content item for future publishing.
|
|
|
|
### File Location
|
|
|
|
```
|
|
frontend/src/components/common/ScheduleContentModal.tsx
|
|
```
|
|
|
|
### Props Interface
|
|
|
|
```typescript
|
|
interface ScheduleContentModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
content: {
|
|
id: number;
|
|
title: string;
|
|
scheduled_publish_at?: string | null; // ISO8601 datetime if rescheduling
|
|
};
|
|
mode: 'schedule' | 'reschedule';
|
|
onSchedule: (contentId: number, scheduledDate: string) => Promise<void>;
|
|
}
|
|
```
|
|
|
|
### State
|
|
|
|
```typescript
|
|
const [selectedDate, setSelectedDate] = useState<Date>(getInitialDate());
|
|
const [selectedTime, setSelectedTime] = useState<string>(getInitialTime());
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
const getInitialDate = () => {
|
|
if (mode === 'reschedule' && content.scheduled_publish_at) {
|
|
return new Date(content.scheduled_publish_at);
|
|
}
|
|
// Default: Tomorrow
|
|
const tomorrow = new Date();
|
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
return tomorrow;
|
|
};
|
|
|
|
const getInitialTime = () => {
|
|
if (mode === 'reschedule' && content.scheduled_publish_at) {
|
|
const date = new Date(content.scheduled_publish_at);
|
|
return format(date, 'hh:mm a');
|
|
}
|
|
return '09:00 AM'; // Default
|
|
};
|
|
```
|
|
|
|
### Validation
|
|
|
|
```typescript
|
|
const validateSchedule = () => {
|
|
const scheduledDateTime = combineDateAndTime(selectedDate, selectedTime);
|
|
const now = new Date();
|
|
|
|
if (scheduledDateTime <= now) {
|
|
toast.error('Scheduled time must be in the future');
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
```
|
|
|
|
### Submit Handler
|
|
|
|
```typescript
|
|
const handleSchedule = async () => {
|
|
if (!validateSchedule()) return;
|
|
|
|
const scheduledDateTime = combineDateAndTime(selectedDate, selectedTime);
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
await onSchedule(content.id, scheduledDateTime.toISOString());
|
|
toast.success(
|
|
mode === 'reschedule'
|
|
? 'Rescheduled successfully'
|
|
: `Scheduled for ${formatScheduledTime(scheduledDateTime)}`
|
|
);
|
|
onClose();
|
|
} catch (error) {
|
|
toast.error(`Failed to ${mode}: ${error.message}`);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
```
|
|
|
|
### UI Implementation
|
|
|
|
```typescript
|
|
<Modal isOpen={isOpen} onClose={onClose}>
|
|
<div className="space-y-4">
|
|
{/* Title */}
|
|
<h3 className="text-lg font-semibold">
|
|
{mode === 'reschedule' ? 'Reschedule Content' : 'Schedule Content'}
|
|
</h3>
|
|
|
|
{/* Content Info */}
|
|
<div className="bg-gray-50 p-3 rounded">
|
|
<p className="text-sm text-gray-600">Content:</p>
|
|
<p className="font-medium text-gray-900">{content.title}</p>
|
|
</div>
|
|
|
|
{/* Date Picker */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Schedule Date
|
|
</label>
|
|
<DatePicker
|
|
selected={selectedDate}
|
|
onChange={setSelectedDate}
|
|
minDate={new Date()}
|
|
dateFormat="MM/dd/yyyy"
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* Time Picker */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Schedule Time
|
|
</label>
|
|
<TimePicker
|
|
value={selectedTime}
|
|
onChange={setSelectedTime}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
|
|
{/* Preview */}
|
|
<div className="bg-blue-50 border border-blue-200 rounded p-3">
|
|
<p className="text-sm text-gray-600">Preview:</p>
|
|
<p className="font-medium text-gray-900">
|
|
{formatPreview(selectedDate, selectedTime)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex gap-3 justify-end">
|
|
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={handleSchedule}
|
|
disabled={isSubmitting}
|
|
loading={isSubmitting}
|
|
>
|
|
{mode === 'reschedule' ? 'Reschedule' : 'Schedule'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Integration Patterns
|
|
|
|
### Site Selector Integration
|
|
|
|
**Critical Pattern**: All pages must reload data when `activeSite` changes.
|
|
|
|
**Correct Implementation** (from ContentCalendar.tsx):
|
|
```typescript
|
|
const loadQueue = useCallback(async () => {
|
|
if (!activeSite?.id) {
|
|
console.log('[ContentCalendar] No active site selected, skipping load');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
// All API calls MUST include site_id parameter
|
|
const scheduledResponse = await fetchAPI('/v1/writer/content/', {
|
|
params: {
|
|
site_id: activeSite.id, // ← REQUIRED
|
|
page_size: 1000,
|
|
site_status: 'scheduled',
|
|
}
|
|
});
|
|
|
|
// ... more queries
|
|
setAllContent(uniqueItems);
|
|
} catch (error: any) {
|
|
toast.error(`Failed to load content: ${error.message}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [activeSite?.id, toast]);
|
|
|
|
// ✅ CORRECT: Only depend on activeSite?.id
|
|
useEffect(() => {
|
|
if (activeSite?.id) {
|
|
console.log('[ContentCalendar] Site changed to:', activeSite.id, activeSite.name);
|
|
loadQueue();
|
|
} else {
|
|
console.log('[ContentCalendar] No active site, clearing content');
|
|
setAllContent([]);
|
|
}
|
|
}, [activeSite?.id]); // Do NOT include loadQueue here
|
|
```
|
|
|
|
**Common Mistakes**:
|
|
```typescript
|
|
// ❌ WRONG: Circular dependency
|
|
useEffect(() => {
|
|
if (activeSite?.id) {
|
|
loadQueue();
|
|
}
|
|
}, [activeSite?.id, loadQueue]); // loadQueue changes on every render
|
|
|
|
// ❌ WRONG: Missing site_id in API call
|
|
const response = await fetchAPI('/v1/writer/content/', {
|
|
params: {
|
|
site_status: 'scheduled', // Missing site_id!
|
|
}
|
|
});
|
|
|
|
// ❌ WRONG: Not clearing data when site is null
|
|
useEffect(() => {
|
|
if (activeSite?.id) {
|
|
loadQueue();
|
|
}
|
|
// Should clear data here if activeSite is null
|
|
}, [activeSite?.id]);
|
|
```
|
|
|
|
**Backend Requirements**:
|
|
The backend ContentFilter must include `site_id` in filterable fields:
|
|
```python
|
|
# backend/igny8_core/modules/writer/views.py
|
|
class ContentFilter(django_filters.FilterSet):
|
|
class Meta:
|
|
model = Content
|
|
fields = [
|
|
'cluster_id',
|
|
'site_id', # ← REQUIRED for site filtering
|
|
'status',
|
|
'site_status', # ← REQUIRED for status filtering
|
|
'content_type',
|
|
# ...
|
|
]
|
|
```
|
|
|
|
### Parent Component Setup (Approved.tsx)
|
|
|
|
```typescript
|
|
const Approved = () => {
|
|
// State
|
|
const [content, setContent] = useState<Content[]>([]);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const [showPublishingModal, setShowPublishingModal] = useState(false);
|
|
const [showBulkPublishingModal, setShowBulkPublishingModal] = useState(false);
|
|
const [showPublishLimitModal, setShowPublishLimitModal] = useState(false);
|
|
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
|
const [publishingContent, setPublishingContent] = useState<Content | null>(null);
|
|
const [isRescheduling, setIsRescheduling] = useState(false);
|
|
|
|
const { activeSite } = useSiteStore();
|
|
|
|
// Handlers
|
|
const handlePublishSingle = (content: Content) => {
|
|
setPublishingContent(content);
|
|
setShowPublishingModal(true);
|
|
};
|
|
|
|
const handleBulkPublishClick = () => {
|
|
if (selectedIds.length > 5) {
|
|
setShowPublishLimitModal(true);
|
|
return;
|
|
}
|
|
setShowBulkPublishingModal(true);
|
|
};
|
|
|
|
const handleScheduleContent = async (contentId: number, scheduledDate: string) => {
|
|
const endpoint = isRescheduling ? 'reschedule' : 'schedule';
|
|
await fetchAPI(`/v1/writer/content/${contentId}/${endpoint}/`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
scheduled_publish_at: scheduledDate
|
|
})
|
|
});
|
|
await loadContent(); // Refresh list
|
|
};
|
|
|
|
const openScheduleModal = (content: Content, reschedule = false) => {
|
|
setPublishingContent(content);
|
|
setIsRescheduling(reschedule);
|
|
setShowScheduleModal(true);
|
|
};
|
|
|
|
// JSX
|
|
return (
|
|
<>
|
|
{/* Content table */}
|
|
<DataTable
|
|
data={content}
|
|
columns={columns}
|
|
selectedIds={selectedIds}
|
|
onSelectedIdsChange={setSelectedIds}
|
|
onRowAction={handleRowAction}
|
|
/>
|
|
|
|
{/* Modals */}
|
|
{publishingContent && (
|
|
<>
|
|
<PublishingProgressModal
|
|
isOpen={showPublishingModal}
|
|
onClose={() => {
|
|
setShowPublishingModal(false);
|
|
setPublishingContent(null);
|
|
}}
|
|
content={publishingContent}
|
|
site={activeSite}
|
|
onSuccess={() => loadContent()}
|
|
/>
|
|
|
|
<ScheduleContentModal
|
|
isOpen={showScheduleModal}
|
|
onClose={() => {
|
|
setShowScheduleModal(false);
|
|
setPublishingContent(null);
|
|
setIsRescheduling(false);
|
|
}}
|
|
content={publishingContent}
|
|
mode={isRescheduling ? 'reschedule' : 'schedule'}
|
|
onSchedule={handleScheduleContent}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<BulkPublishingModal
|
|
isOpen={showBulkPublishingModal}
|
|
onClose={() => setShowBulkPublishingModal(false)}
|
|
contentItems={getSelectedContent()}
|
|
site={activeSite}
|
|
onComplete={() => loadContent()}
|
|
/>
|
|
|
|
<PublishLimitModal
|
|
isOpen={showPublishLimitModal}
|
|
onClose={() => setShowPublishLimitModal(false)}
|
|
selectedCount={selectedIds.length}
|
|
onScheduleInstead={handleBulkScheduleWithDefaults}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Testing Guidelines
|
|
|
|
### Unit Testing
|
|
|
|
**Test Progress Animation**:
|
|
```typescript
|
|
describe('PublishingProgressModal - Progress Animation', () => {
|
|
it('should animate from 0 to 25% in 2.5 seconds', async () => {
|
|
const { getByRole } = render(<PublishingProgressModal {...props} />);
|
|
const progressBar = getByRole('progressbar');
|
|
|
|
expect(progressBar).toHaveAttribute('aria-valuenow', '0');
|
|
|
|
await waitFor(() => {
|
|
expect(progressBar).toHaveAttribute('aria-valuenow', '25');
|
|
}, { timeout: 3000 });
|
|
});
|
|
});
|
|
```
|
|
|
|
**Test Error Handling**:
|
|
```typescript
|
|
describe('PublishingProgressModal - Error Handling', () => {
|
|
it('should display error and retry button on failure', async () => {
|
|
const mockFetch = jest.fn().mockRejectedValue(new Error('API Error'));
|
|
|
|
const { getByText, getByRole } = render(
|
|
<PublishingProgressModal {...props} />
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(getByText(/API Error/i)).toBeInTheDocument();
|
|
expect(getByRole('button', { name: /retry/i })).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
### Integration Testing
|
|
|
|
**Test Full Publishing Flow**:
|
|
```typescript
|
|
describe('Approved Page - Publishing Integration', () => {
|
|
it('should publish single content with progress modal', async () => {
|
|
const { getByText, getByRole } = render(<Approved />);
|
|
|
|
// Click publish on first item
|
|
const publishButton = getByText(/publish now/i);
|
|
fireEvent.click(publishButton);
|
|
|
|
// Modal opens
|
|
expect(getByText(/Publishing Content/i)).toBeInTheDocument();
|
|
|
|
// Wait for completion
|
|
await waitFor(() => {
|
|
expect(getByText(/Published Successfully/i)).toBeInTheDocument();
|
|
});
|
|
|
|
// Close modal
|
|
fireEvent.click(getByRole('button', { name: /close/i }));
|
|
|
|
// Content list refreshed
|
|
expect(mockLoadContent).toHaveBeenCalled();
|
|
});
|
|
});
|
|
```
|
|
|
|
### E2E Testing (Playwright/Cypress)
|
|
|
|
```typescript
|
|
test('bulk publish with limit validation', async ({ page }) => {
|
|
await page.goto('/writer/approved');
|
|
|
|
// Select 6 items
|
|
const checkboxes = page.locator('[type="checkbox"]');
|
|
for (let i = 0; i < 6; i++) {
|
|
await checkboxes.nth(i).check();
|
|
}
|
|
|
|
// Click bulk publish
|
|
await page.click('button:has-text("Publish to Site")');
|
|
|
|
// Limit modal appears
|
|
await expect(page.locator('text=Publishing Limit Exceeded')).toBeVisible();
|
|
await expect(page.locator('text=You have selected 6 items')).toBeVisible();
|
|
|
|
// Click "Schedule Selected"
|
|
await page.click('button:has-text("Schedule Selected")');
|
|
|
|
// Bulk schedule preview modal appears
|
|
await expect(page.locator('text=Schedule 6 Articles')).toBeVisible();
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Performance Considerations
|
|
|
|
### Optimization Strategies
|
|
|
|
1. **Progress Animation**: Use requestAnimationFrame for smoother animations
|
|
2. **Queue Rendering**: Virtualize list if > 20 items
|
|
3. **API Calls**: Implement request cancellation on modal close
|
|
4. **State Updates**: Batch setState calls with useReducer
|
|
5. **Memory Leaks**: Clear all intervals/timeouts on unmount
|
|
|
|
### Example: Optimized Progress Animation
|
|
|
|
```typescript
|
|
const useProgressAnimation = (start: number, end: number, duration: number) => {
|
|
const [progress, setProgress] = useState(start);
|
|
const animationRef = useRef<number>();
|
|
const startTimeRef = useRef<number>();
|
|
|
|
useEffect(() => {
|
|
startTimeRef.current = performance.now();
|
|
|
|
const animate = (currentTime: number) => {
|
|
const elapsed = currentTime - (startTimeRef.current || 0);
|
|
const percentage = Math.min(elapsed / duration, 1);
|
|
const currentProgress = start + (end - start) * percentage;
|
|
|
|
setProgress(Math.round(currentProgress));
|
|
|
|
if (percentage < 1) {
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
animationRef.current = requestAnimationFrame(animate);
|
|
|
|
return () => {
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
}
|
|
};
|
|
}, [start, end, duration]);
|
|
|
|
return progress;
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Common Pitfalls
|
|
|
|
### 1. Not Clearing Intervals
|
|
|
|
**Problem**: Intervals continue after component unmounts
|
|
```typescript
|
|
// ❌ Bad
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setProgress(p => p + 1);
|
|
}, 100);
|
|
}, []);
|
|
|
|
// ✅ Good
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setProgress(p => p + 1);
|
|
}, 100);
|
|
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
```
|
|
|
|
### 2. Race Conditions
|
|
|
|
**Problem**: Multiple API calls interfere with each other
|
|
```typescript
|
|
// ❌ Bad
|
|
const handlePublish = async () => {
|
|
const result = await fetchAPI(...);
|
|
setResult(result); // May be stale if called multiple times
|
|
};
|
|
|
|
// ✅ Good
|
|
const handlePublish = async () => {
|
|
const abortController = new AbortController();
|
|
|
|
try {
|
|
const result = await fetchAPI(..., { signal: abortController.signal });
|
|
setResult(result);
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') return;
|
|
handleError(error);
|
|
}
|
|
|
|
return () => abortController.abort();
|
|
};
|
|
```
|
|
|
|
### 3. Not Handling Modal Backdrop Clicks
|
|
|
|
**Problem**: User can close modal during publishing
|
|
```typescript
|
|
// ❌ Bad
|
|
<Modal isOpen={isOpen} onClose={onClose}>
|
|
|
|
// ✅ Good
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={canClose ? onClose : undefined}
|
|
closeOnOverlayClick={canClose}
|
|
closeOnEsc={canClose}
|
|
>
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Accessibility Checklist
|
|
|
|
- [ ] All modals have proper ARIA labels
|
|
- [ ] Progress bars use role="progressbar" with aria-valuenow
|
|
- [ ] Error messages announced to screen readers
|
|
- [ ] Keyboard navigation works (Tab, Esc, Enter)
|
|
- [ ] Focus trapped in modal when open
|
|
- [ ] Color not sole indicator of status (icons included)
|
|
- [ ] Sufficient color contrast (WCAG AA)
|
|
- [ ] Loading states announced
|
|
|
|
---
|
|
|
|
## 10. Browser Compatibility
|
|
|
|
**Supported Browsers**:
|
|
- Chrome 90+
|
|
- Firefox 88+
|
|
- Safari 14+
|
|
- Edge 90+
|
|
|
|
**Known Issues**:
|
|
- Safari < 14: requestAnimationFrame timing inconsistencies
|
|
- Firefox < 88: Date picker styling differences
|
|
|
|
---
|
|
|
|
**Document Version**: 1.0
|
|
**Last Updated**: January 2026
|
|
**Status**: Production Ready
|