# 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' && (
Publishing Failed
{error}
)}
```
**Retry Logic**:
```typescript
const handleRetry = () => {
setError(null);
setProgress(0);
handlePublish();
};
```
### Success State
```typescript
{status === 'completed' && (
Published Successfully!
Content is now live on {siteName}
{externalUrl && (
)}
)}
```
### Modal Control
**Cannot Close During Publishing**:
```typescript
const canClose = status === 'completed' || status === 'failed' || status === null;
{/* Modal content */}
{canClose && (
)}
```
---
## 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
{queue.map((item) => (
{/* Header */}
{item.index}.
{item.contentTitle}
{/* Status Icon */}
{item.status === 'completed' && (
)}
{item.status === 'processing' && (
)}
{item.status === 'failed' && (
)}
{item.status === 'pending' && (
)}
{item.progress}%
{/* Progress Bar */}
{/* Status Message */}
{item.statusMessage}
{/* Success: Published URL */}
{item.status === 'completed' && item.externalUrl && (
View on {site.name}
)}
{/* Error: Message + Retry */}
{item.status === 'failed' && (
{item.error}
)}
))}
{/* Summary */}
{completedCount} completed, {failedCount} failed, {pendingCount} pending
```
### 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 = ({
isOpen,
onClose,
selectedCount,
onScheduleInstead,
}) => {
return (
{/* Icon */}
{/* Title */}
Publishing Limit Exceeded
{/* Message */}
You can publish only 5 content pages directly.
You have selected {selectedCount} items.
{/* Options Box */}
Options:
- Deselect items to publish 5 or fewer
- Use "Schedule Selected" to schedule all items
Tip: Scheduling has no limit and uses your site's default publishing schedule.
{/* Actions */}
);
};
```
### 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
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;
}
```
### State
```typescript
const [selectedDate, setSelectedDate] = useState(getInitialDate());
const [selectedTime, setSelectedTime] = useState(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
{/* Title */}
{mode === 'reschedule' ? 'Reschedule Content' : 'Schedule Content'}
{/* Content Info */}
{/* Date Picker */}
{/* Time Picker */}
{/* Preview */}
Preview:
{formatPreview(selectedDate, selectedTime)}
{/* Actions */}
```
---
## 5. Integration Patterns
### Parent Component Setup (Approved.tsx)
```typescript
const Approved = () => {
// State
const [content, setContent] = useState([]);
const [selectedIds, setSelectedIds] = useState([]);
const [showPublishingModal, setShowPublishingModal] = useState(false);
const [showBulkPublishingModal, setShowBulkPublishingModal] = useState(false);
const [showPublishLimitModal, setShowPublishLimitModal] = useState(false);
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [publishingContent, setPublishingContent] = useState(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 */}
{/* Modals */}
{publishingContent && (
<>
{
setShowPublishingModal(false);
setPublishingContent(null);
}}
content={publishingContent}
site={activeSite}
onSuccess={() => loadContent()}
/>
{
setShowScheduleModal(false);
setPublishingContent(null);
setIsRescheduling(false);
}}
content={publishingContent}
mode={isRescheduling ? 'reschedule' : 'schedule'}
onSchedule={handleScheduleContent}
/>
>
)}
setShowBulkPublishingModal(false)}
contentItems={getSelectedContent()}
site={activeSite}
onComplete={() => loadContent()}
/>
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();
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(
);
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();
// 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();
const startTimeRef = useRef();
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
// ✅ Good
```
---
## 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