Files
igny8/docs/30-FRONTEND/PUBLISHING-MODALS.md
2026-01-16 16:55:39 +00:00

30 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

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

  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

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