Source: components/ComponentPalette/ComponentPalette.jsx

/**
 * Left-hand sidebar of the AutoRA Workflow Editor that lists the available
 * workflow building blocks. Groups control nodes and protocol components
 * (theorists, experimentalists, experiment runners) into collapsible,
 * searchable sections that can be dragged onto the canvas or clicked to preview.
 *
 * @module components/ComponentPalette/ComponentPalette
 */
import React, { useState, useMemo, useEffect, useRef } from 'react'
import { useWorkflow } from '../../context/WorkflowContext'
import './ComponentPalette.css'

const typeConfig = {
  controls: { label: 'Controls', icon: '⚙', color: 'var(--node-controls)' },
  theorists: { label: 'Theorists', icon: '🧠', color: 'var(--node-theorists)' },
  experimentalists: { label: 'Experimentalists', icon: '🔬', color: 'var(--node-experimentalists)' },
  experiment_runners: { label: 'Experiment Runners', icon: '⚡', color: 'var(--node-experiment-runners)' }
}

// Maps a component's protocolType (singular) to its palette/category key (plural).
const protocolTypeToCategory = {
  theorist: 'theorists',
  experimentalist: 'experimentalists',
  experiment_runner: 'experiment_runners'
}

const controlNodes = [
  {
    uuid: 'start-node',
    protocolType: 'start_point',
    name: 'Start',
    description: 'Starting point of the workflow',
    isControlNode: true,
    icon: '▶'
  },
  {
    uuid: 'end-node',
    protocolType: 'end_point',
    name: 'End',
    description: 'Ending point of the workflow',
    isControlNode: true,
    icon: '⏹'
  },
  {
    uuid: 'filter-node',
    protocolType: 'filter_point',
    name: 'Filter',
    description: 'Filter/decision point in the workflow',
    isControlNode: true,
    icon: '◆'
  }
]

/**
 * Renders the component palette sidebar: a search box plus collapsible sections
 * of control nodes and protocol components. Auto-expands the section containing
 * the currently selected/previewed component, and supports drag-to-canvas and
 * click-to-preview interactions.
 *
 * @returns {JSX.Element}
 */
function ComponentPalette() {
  const { state, dispatch } = useWorkflow()
  const [searchTerm, setSearchTerm] = useState('')
  const fileInputRef = useRef(null)
  const [expandedSections, setExpandedSections] = useState({
    controls: true,
    theorists: false,
    experimentalists: false,
    experiment_runners: false
  })

  // Get the selected/previewed component UUID
  const selectedNode = state.nodes.find(n => n.id === state.selectedNodeId)
  const selectedComponentUuid = selectedNode?.protocolUuid || state.previewedComponent?.uuid

  // Auto-expand section containing the selected component, collapse others
  useEffect(() => {
    if (!selectedComponentUuid) return

    // Check control nodes first
    if (controlNodes.some(c => c.uuid === selectedComponentUuid)) {
      setExpandedSections({
        controls: true,
        theorists: false,
        experimentalists: false,
        experiment_runners: false
      })
      return
    }

    // Check other component types
    if (state.components) {
      for (const [type, components] of Object.entries(state.components)) {
        if (Array.isArray(components) && components.some(c => c.uuid === selectedComponentUuid)) {
          setExpandedSections({
            controls: false,
            theorists: false,
            experimentalists: false,
            experiment_runners: false,
            [type]: true
          })
          break
        }
      }
    }
  }, [selectedComponentUuid, state.components])

  const filteredComponents = useMemo(() => {
    const result = {}

    // Add control nodes (Start, End)
    const filteredControls = controlNodes.filter(c => {
      const nameMatch = c.name.toLowerCase().includes(searchTerm.toLowerCase())
      const descMatch = c.description?.toLowerCase().includes(searchTerm.toLowerCase())
      return nameMatch || descMatch
    })
    if (filteredControls.length > 0) {
      result.controls = filteredControls.sort((a, b) => a.name.localeCompare(b.name))
    }

    // Add protocol components from state
    if (!state.components) return result
    Object.entries(state.components).forEach(([type, components]) => {
      if (!Array.isArray(components)) return
      const filtered = components.filter(c => {
        if (!c || !c.name) return false
        const nameMatch = c.name.toLowerCase().includes(searchTerm.toLowerCase())
        const descMatch = c.description?.toLowerCase().includes(searchTerm.toLowerCase())
        return nameMatch || descMatch
      })
      if (filtered.length > 0) {
        result[type] = filtered.sort((a, b) => a.name.localeCompare(b.name))
      }
    })
    return result
  }, [state.components, searchTerm])

  const toggleSection = (section) => {
    setExpandedSections(prev => ({
      ...prev,
      [section]: !prev[section]
    }))
  }

  const handleDragStart = (e, component) => {
    e.dataTransfer.setData('application/json', JSON.stringify(component))
    e.dataTransfer.effectAllowed = 'copy'
  }

  const handleComponentClick = (component) => {
    dispatch({ type: 'SET_PREVIEWED_COMPONENT', payload: component })
  }

  /**
   * Upload a component JSON file and add it to the palette for the current
   * session. Components are not persisted to disk, so the behaviour is identical
   * in both the local dev and standalone web versions.
   *
   * @param {Event} e - Change event from the hidden file input
   */
  const handleFileSelected = async (e) => {
    const file = e.target.files?.[0]
    e.target.value = ''
    if (!file) return

    let component
    try {
      component = JSON.parse(await file.text())
    } catch (err) {
      alert('Invalid JSON file: ' + err.message)
      return
    }

    const category = protocolTypeToCategory[component?.protocolType]
    if (!category) {
      alert(
        `Unknown or missing protocolType: ${component?.protocolType}. ` +
        'Expected one of: theorist, experimentalist, experiment_runner.'
      )
      return
    }
    if (!component.uuid || !component.name) {
      alert('Component is missing a required "uuid" or "name" field.')
      return
    }

    dispatch({ type: 'ADD_COMPONENT', payload: { category, component } })
    setExpandedSections(prev => ({ ...prev, [category]: true }))
    alert(`Added "${component.name}" to ${category} for this session.`)
  }

  return (
    <aside className="component-palette">
      <div className="palette-header">
        <div className="palette-title-row">
          <h2>Components</h2>
          <button
            type="button"
            className="add-component-btn"
            onClick={() => fileInputRef.current?.click()}
            title="Upload a component JSON file"
          >
            + Add
          </button>
        </div>
        <input
          type="file"
          accept=".json,application/json"
          ref={fileInputRef}
          onChange={handleFileSelected}
          style={{ display: 'none' }}
        />
        <input
          type="text"
          placeholder="Search components..."
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          className="search-input"
        />
      </div>
      <div className="palette-content">
        {Object.entries(filteredComponents).map(([type, components]) => {
          const config = typeConfig[type] || { label: type, icon: '●', color: '#666' }
          return (
            <div key={type} className="component-section">
              <button
                className="section-header"
                onClick={() => toggleSection(type)}
              >
                <span className="section-icon" style={{ color: config.color }}>
                  {config.icon}
                </span>
                <span className="section-title">{config.label}</span>
                <span className="section-count">{components.length}</span>
                <span className={`section-toggle ${expandedSections[type] ? 'expanded' : ''}`}>
                  ▼
                </span>
              </button>
              {expandedSections[type] && (
                <div className="component-list">
                  {components.map(component => (
                    <div
                      key={component.uuid}
                      className={`component-item ${selectedComponentUuid === component.uuid ? 'selected' : ''}`}
                      draggable
                      onClick={() => handleComponentClick(component)}
                      onDragStart={(e) => handleDragStart(e, component)}
                      title={component.description}
                    >
                      <span className="component-icon" style={{ backgroundColor: config.color }}>
                        {component.icon || config.icon}
                      </span>
                      <span className="component-name">{component.name}</span>
                    </div>
                  ))}
                </div>
              )}
            </div>
          )
        })}
        {Object.keys(filteredComponents).length === 0 && (
          <div className="no-results">No components found</div>
        )}
      </div>
    </aside>
  )
}

export default ComponentPalette