32 KiB
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
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
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
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:
{
content_id: number;
destinations: ['wordpress' | 'shopify' | 'custom']; // Based on site.platform_type
}
Success Response:
{
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:
{
success: false;
error: string; // User-friendly error message
}
Publishing Flow
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:
{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:
const handleRetry = () => {
setError(null);
setProgress(0);
handlePublish();
};
Success State
{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:
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
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
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:
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
<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
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
interface PublishLimitModalProps {
isOpen: boolean;
onClose: () => void;
selectedCount: number;
onScheduleInstead: () => void;
}
Implementation
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
// 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
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
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
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
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
<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):
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:
// ❌ 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:
# 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)
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:
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:
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:
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)
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
- Progress Animation: Use requestAnimationFrame for smoother animations
- Queue Rendering: Virtualize list if > 20 items
- API Calls: Implement request cancellation on modal close
- State Updates: Batch setState calls with useReducer
- Memory Leaks: Clear all intervals/timeouts on unmount
Example: Optimized Progress Animation
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
// ❌ 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
// ❌ 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
// ❌ 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