Skip to main content

Overview

The Preview Panel provides a fully interactive, real-time preview of your multi-step form. As you build your layout in the visual editor, the preview updates instantly to show exactly how users will experience your form - complete with validation, multi-step navigation, and responsive styling.
The Preview Panel is implemented in main/frontend/src/components/PreviewPanel.tsx and uses Shadow DOM for style isolation.

Key Features

Real-Time Updates

Changes in the editor appear instantly in the preview

Interactive Testing

Fill out fields and navigate between steps

Field Validation

Test required fields, email validation, phone numbers

Style Isolation

Shadow DOM prevents CSS conflicts

Preview Components

Form Header

Displays the form name and step indicators:
// From PreviewPanel.tsx:598-617
<div className="preview-header">
  <h2 className="preview-title">{schema.name}</h2>
  {showStepIndicators && (
    <div className="preview-steps">
      {layout.steps.map((_, index) => `
        <div className="step-indicator 
          ${index < currentStep ? 'is-complete' : ''} 
          ${index === currentStep ? 'is-active' : ''}"
        ></div>
      `)}
    </div>
  )}
</div>
Step Indicators:
  • Gray: Not yet reached
  • Blue: Currently active step
  • Green: Completed steps
Step indicators only appear in multi-step mode when you have 2+ steps (line 595 in PreviewPanel.tsx).

Step Title

Displays the current step’s title:
// From PreviewPanel.tsx:620
{showStepIndicators ? `<h3 class="step-title">${currentStepData.title}</h3>` : ''}

Form Fields

Fields are rendered based on your layout with responsive row support:
// From PreviewPanel.tsx:622-634
${currentStepData.rows.map((row) => `
  <div class="field-row-preview">
    ${row.fields
      .map((fieldName) => schema.fields.find((f) => f.name === fieldName))
      .filter((f): f is FieldSchema => f !== undefined)
      .map((field) => `<div class="field-wrapper-preview">${renderField(field)}</div>`)
      .join('')}
  </div>
`).join('')}
The CSS grid automatically adjusts field width based on how many fields are in each row - matching your canvas layout exactly.
<button disabled>Anterior</button>
<button id="next-btn">Siguiente</button>
Previous button is disabled on the first step.

Field Types

The preview supports all HubSpot field types with accurate rendering:

Text Inputs

// From PreviewPanel.tsx:450-463
case 'text':
case 'email':
case 'phone':
case 'number':
  fieldHtml = `
    <input
      type="${field.type === 'phone' ? 'tel' : field.type}"
      class="field-input ${errorClass}"
      data-field="${field.name}"
      value="${value}"
      placeholder="${field.label}"
      ${field.required ? 'required' : ''}
    />
  `;
  break;
Supported types: text, email, phone (tel), number

Textarea

// From PreviewPanel.tsx:465-474
case 'textarea':
  fieldHtml = `
    <textarea
      class="field-textarea ${errorClass}"
      data-field="${field.name}"
      placeholder="${field.label}"
      ${field.required ? 'required' : ''}
    >${value}</textarea>
  `;
  break;

Select Dropdown

// From PreviewPanel.tsx:476-493
case 'select':
case 'dropdown': {
  const options = field.options || [];
  fieldHtml = `
    <select class="field-select ${errorClass}" data-field="${field.name}">
      <option value="">Select an option</option>
      ${options.map((opt) => `
        <option value="${opt.value}" ${value === opt.value ? 'selected' : ''}>
          ${opt.label}
        </option>
      `).join('')}
    </select>
  `;
  break;
}

Radio Buttons & Checkboxes

// From PreviewPanel.tsx:495-527
case 'radio':
case 'checkbox':
case 'multiple_checkboxes': {
  const choices = field.options || [];
  fieldHtml = `
    <div class="field-choices">
      ${choices.map((opt) => {
        const isChecked = field.type === 'checkbox' || field.type === 'multiple_checkboxes'
          ? value.split(',').includes(opt.value)
          : value === opt.value;
        return `
          <label class="choice-label">
            <input
              type="${field.type === 'multiple_checkboxes' ? 'checkbox' : field.type}"
              class="choice-input"
              data-field="${field.name}"
              value="${opt.value}"
              ${isChecked ? 'checked' : ''}
              ${field.required ? 'required' : ''}
            />
            <span>${opt.label}</span>
          </label>
        `;
      }).join('')}
    </div>
  `;
  break;
}

Interactive Testing

Filling Out Fields

All fields are fully functional:
// From PreviewPanel.tsx:392-409
const handleFieldChange = (fieldName: string, value: string) => {
  console.log('Field change:', fieldName, '=', value);
  setFormData((prev) => {
    const next = { ...prev, [fieldName]: value };
    formDataRef.current = next; // Update ref immediately
    return next;
  });

  // Clear error when user types
  if (formErrorsRef.current[fieldName]) {
    setFormErrors((prev) => {
      const next = { ...prev };
      delete next[fieldName];
      formErrorsRef.current = next;
      return next;
    });
  }
};
Form data persists as you navigate between steps. Values are retained even if you go back to previous steps.

Step Navigation

1

Fill Current Step

Enter data in any or all fields on the current step
2

Click Next

Validation runs automatically for all required fields
3

Fix Errors or Proceed

If validation passes, you advance to the next step
4

Go Back Anytime

Previous button lets you review/edit earlier steps
// From PreviewPanel.tsx:411-422
const handleNext = () => {
  if (!layout) return;

  console.log('Validating step', currentStep, 'with data:', formData);
  if (validateStep(currentStep)) {
    if (currentStep < layout.steps.length - 1) {
      setCurrentStep(currentStep + 1);
    }
  } else {
    console.log('Validation failed. Errors:', formErrors);
  }
};

Field Validation

The preview includes comprehensive client-side validation:

Required Field Check

// From PreviewPanel.tsx:334-337
if (field.required && !value.trim()) {
  return 'Please complete this required field.';
}
Required fields must have a non-empty value to proceed.

Email Validation

// From PreviewPanel.tsx:339-344
if (field.type === 'email' && value) {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(value)) {
    return 'Please enter a valid email address.';
  }
}
Validates standard email format: [email protected]

Phone Number Validation

// From PreviewPanel.tsx:346-352
if (field.type === 'phone' && value) {
  const phoneRegex = /^[+]?[(]?[0-9]{1,3}[)]?[-\s.]?[0-9]{1,4}[-\s.]?[0-9]{1,4}[-\s.]?[0-9]{1,9}$/;
  if (!phoneRegex.test(value)) {
    return 'Please enter a valid phone number.';
  }
}
Supports various phone formats:
  • +1 234-567-8900
  • (234) 567-8900
  • 234.567.8900
  • 2345678900

Step Validation

Validation runs when you click Next or Submit:
// From PreviewPanel.tsx:357-390
const validateStep = (stepIndex: number): boolean => {
  if (!schema || !layout) return true;

  const step = layout.steps[stepIndex];
  const stepFieldNames: string[] = [];
  step.rows.forEach((row) => stepFieldNames.push(...row.fields));
  const errors: FormErrors = {};
  let isValid = true;

  stepFieldNames.forEach((fieldName) => {
    const field = schema.fields.find((f) => f.name === fieldName);
    if (!field) return;

    const value = formDataRef.current[fieldName] || '';
    const error = validateField(fieldName, value, field);

    if (error) {
      errors[fieldName] = error;
      isValid = false;
    }
  });

  setFormErrors(errors);
  return isValid;
};
Validation only checks fields in the current step. You can’t proceed to the next step until all required fields in the current step are valid.

Error Display

Errors appear below the field with styling:
// From PreviewPanel.tsx:542-550
return `
  <div class="field-group">
    <label class="field-label">
      ${field.label}
      ${field.required ? '<span class="field-required">*</span>' : ''}
    </label>
    ${fieldHtml}
    ${error ? `<span class="field-error">${error}</span>` : ''}
  </div>
`;
Error styling:
// From PreviewPanel.tsx:116-132
.field-input.has-error,
.field-select.has-error,
.field-textarea.has-error {
  border-color: #ef4444;
}

.field-error {
  display: block;
  margin-top: 6px;
  font-size: 13px;
  color: #ef4444;
}

Success State

After submitting the form:
// From PreviewPanel.tsx:565-588
if (isSubmitted) {
  shadowRoot.innerHTML = `
    <style>${PREVIEW_STYLES}</style>
    <div class="preview-container">
      <div class="success-message">
        <h3>✓ Formulario enviado</h3>
        <p>Este es un preview simulado. En producción, los datos se enviarían a HubSpot.</p>
      </div>
    </div>
  `;

  // Reset on click
  setTimeout(() => {
    const container = shadowRoot.querySelector('.success-message');
    if (container) {
      container.addEventListener('click', () => {
        setIsSubmitted(false);
        setFormData({});
        setFormErrors({});
        setCurrentStep(0);
      });
    }
  }, 0);
  return;
}
The preview is for testing only. In production, form data will be submitted to HubSpot using the actual form submission system.

Shadow DOM Implementation

The preview uses Shadow DOM for style isolation:
// From PreviewPanel.tsx:257-272
useEffect(() => {
  if (!containerRef.current) return;

  if (!shadowRootRef.current) {
    shadowRootRef.current = containerRef.current.attachShadow({ mode: 'open' });
  }

  const shadowRoot = shadowRootRef.current;
  renderPreview(shadowRoot);

  return () => {
    if (shadowRoot) {
      shadowRoot.innerHTML = '';
    }
  };
}, [schema, layout, currentStep, isSubmitted]);
Benefits:
  • CSS from your main app won’t affect the preview
  • Preview styles won’t leak into the editor
  • Accurate representation of the final form
All preview styles are defined inline (lines 12-233 in PreviewPanel.tsx) and injected into the Shadow DOM.

Responsive Design

The preview is fully responsive:
// From PreviewPanel.tsx:217-232
.field-row-preview {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
  gap: 16px;
  margin-bottom: 20px;
}

.field-wrapper-preview {
  min-width: 0;
}

@media (max-width: 640px) {
  .field-row-preview {
    grid-template-columns: 1fr;
  }
}
On mobile screens (< 640px), all fields stack vertically regardless of your row layout. Test this by resizing your browser.

Preview Styling

The preview includes production-ready styles:
--primary: #0c63ff;      /* Primary blue */
--success: #10b981;      /* Success green */
--error: #ef4444;        /* Error red */
--text: #0f172a;         /* Dark text */
--text-muted: #64748b;   /* Muted text */
--border: #cbd5e1;       /* Default border */
--background: #f1f5f9;   /* Light background */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 
             'Helvetica Neue', Arial, sans-serif;
Uses system fonts for best performance and native look.
  • Container padding: 24px
  • Field margin: 20px bottom
  • Form actions: 24px top padding
  • Row gap: 16px between fields
.field-input:focus {
  outline: none;
  border-color: #0c63ff;
  box-shadow: 0 0 0 3px rgba(12, 99, 255, 0.1);
}
Smooth transitions and focus states on all interactive elements.

Technical Details

State Management

// From PreviewPanel.tsx:238-254
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<FormData>({});
const [formErrors, setFormErrors] = useState<FormErrors>({});
const [isSubmitted, setIsSubmitted] = useState(false);

// Use refs to avoid stale closures
const formDataRef = useRef<FormData>({});
const formErrorsRef = useRef<FormErrors>({});

// Keep refs in sync with state
useEffect(() => {
  formDataRef.current = formData;
}, [formData]);

useEffect(() => {
  formErrorsRef.current = formErrors;
}, [formErrors]);

Event Handling

Event listeners are attached after DOM render:
// From PreviewPanel.tsx:650-706
const attachEventListeners = (shadowRoot: ShadowRoot) => {
  // Form inputs
  const inputs = shadowRoot.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
    '.field-input, .field-textarea, .field-select, .choice-input'
  );

  inputs.forEach((input) => {
    const fieldName = input.getAttribute('data-field');
    if (!fieldName) return;

    if (input.type === 'checkbox') {
      input.addEventListener('change', (e) => {
        // Handle checkbox state
      });
    } else {
      input.addEventListener('input', (e) => {
        handleFieldChange(fieldName, target.value);
      });
    }
  });

  // Navigation buttons
  const prevBtn = shadowRoot.querySelector('#prev-btn');
  if (prevBtn) prevBtn.addEventListener('click', handlePrevious);

  const nextBtn = shadowRoot.querySelector('#next-btn');
  if (nextBtn) nextBtn.addEventListener('click', handleNext);

  // Submit
  const form = shadowRoot.querySelector('.preview-form');
  if (form) form.addEventListener('submit', handleSubmit);
};

Best Practices

Test All Steps

Navigate through every step to ensure validation and flow work correctly

Fill Required Fields

Test the form with and without required fields to verify validation

Check Responsive

Resize browser to test mobile layout

Test Edge Cases

Try invalid emails, incomplete phone numbers, and empty submissions

Limitations

Preview Limitations:
  • Form data is not actually sent to HubSpot
  • File upload fields are not yet supported
  • Conditional logic is not implemented in preview
  • Custom styling from HubSpot is not applied
These features work in the production form after export.

Build docs developers (and LLMs) love