import React, { useState, useEffect, useRef } from 'react'; import type { ChainMetadata } from '../../../types/protein'; type SearchStatus = 'idle' | 'typing' | 'loading' | 'success' | 'not_found' | 'error'; interface ChainSearchInputProps { value: string; onValueChange: (value: string) => void; onChainSelect: (pdb_id: string, auth_asym_id: string) => void; onMetadataLoaded: (metadata: ChainMetadata) => void; onClear: () => void; resolveChain: (pdb_id: string, auth_asym_id: string) => Promise; searchStatus: SearchStatus; resolvedLabel?: string; errorMessage?: string; disabled?: boolean; } interface ParsedChain { pdb_id: string; auth_asym_id: string; valid: boolean; error?: string; } export const ChainSearchInput: React.FC = ({ value, onValueChange, onChainSelect, onMetadataLoaded, onClear, resolveChain, searchStatus, resolvedLabel, errorMessage, disabled }) => { const [parsed, setParsed] = useState(null); const [debouncedValue, setDebouncedValue] = useState(''); const lastResolvedKey = useRef(null); // Debounce input (500ms) useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value); }, 500); return () => clearTimeout(timer); }, [value]); // Parse chain identifier const parseChainId = (value: string): ParsedChain => { const trimmed = value.trim(); if (!trimmed) { return { pdb_id: '', auth_asym_id: '', valid: false }; } // Accept formats: "3vgm.A", "3VGM:A", "3VGM_A", "3vgm A" const separatorRegex = /[.\s:_]+/; const parts = trimmed.split(separatorRegex); if (parts.length < 2) { return { pdb_id: '', auth_asym_id: '', valid: false, error: 'Format: PDB_ID + Chain (e.g., "3vgm.A", "3VGM:A", "3VGM_A")' }; } const pdb_id = parts[0].trim(); const auth_asym_id = parts.slice(1).join('').trim(); // Handle multi-char chain IDs // Validate PDB ID (4 characters) if (pdb_id.length !== 4) { return { pdb_id, auth_asym_id, valid: false, error: 'PDB ID must be exactly 4 characters' }; } // Validate chain ID (at least 1 character) if (auth_asym_id.length === 0) { return { pdb_id, auth_asym_id, valid: false, error: 'Chain ID is required (e.g., "A", "B", "AA")' }; } return { pdb_id: pdb_id.toLowerCase(), auth_asym_id: auth_asym_id.toUpperCase(), valid: true }; }; // Parse and validate when debounced value changes useEffect(() => { if (!debouncedValue) { setParsed(null); lastResolvedKey.current = null; return; } const result = parseChainId(debouncedValue); setParsed(result); if (!result.valid) { return; } // Create key for this chain const currentKey = `${result.pdb_id}|${result.auth_asym_id}`; // Only resolve if key changed (prevent duplicate requests) if (currentKey === lastResolvedKey.current) { return; } lastResolvedKey.current = currentKey; // Valid - trigger resolution (parent manages loading/error state) resolveChain(result.pdb_id, result.auth_asym_id) .then(metadata => { onChainSelect(result.pdb_id, result.auth_asym_id); onMetadataLoaded(metadata); }) .catch(() => { // Parent handles error state }); }, [debouncedValue, resolveChain, onChainSelect, onMetadataLoaded]); const handleClear = () => { setParsed(null); setDebouncedValue(''); lastResolvedKey.current = null; onClear(); }; const getBorderColor = () => { if (searchStatus === 'loading') return 'border-blue-400'; if (searchStatus === 'error' || searchStatus === 'not_found' || (parsed && !parsed.valid)) return 'border-red-400'; if (searchStatus === 'success') return 'border-green-400'; return 'border-gray-300'; }; const showError = searchStatus === 'error' || searchStatus === 'not_found' || (parsed && !parsed.valid && value); const displayError = errorMessage || parsed?.error || 'Invalid chain identifier'; return (
onValueChange(e.target.value)} placeholder="e.g., 3vgm.A" disabled={disabled} /> {value && searchStatus !== 'loading' && ( )} {searchStatus === 'loading' && (
)}
{searchStatus === 'success' && resolvedLabel && (
{resolvedLabel}
)} {showError && (
{displayError}
)}
); };