# 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 */}

Content:

{content.title}

{/* Date Picker */}
{/* Time Picker */}
{/* Preview */}

Preview:

{formatPreview(selectedDate, selectedTime)}

{/* Actions */}
``` --- ## 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([]); 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