MuSProt / frontend /src /components /datasets /protein /ChainSearchInput.tsx
wenruifan's picture
Deploy MuSProt React and FastAPI application
3993320
Raw
History Blame Contribute Delete
6.59 kB
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<ChainMetadata>;
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<ChainSearchInputProps> = ({
value,
onValueChange,
onChainSelect,
onMetadataLoaded,
onClear,
resolveChain,
searchStatus,
resolvedLabel,
errorMessage,
disabled
}) => {
const [parsed, setParsed] = useState<ParsedChain | null>(null);
const [debouncedValue, setDebouncedValue] = useState('');
const lastResolvedKey = useRef<string | null>(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 (
<div className="space-y-3">
<label className="block">
<span className="text-sm font-medium text-slate-900">Chain Identifier</span>
<span className="block text-xs text-slate-500 mt-1">
Format: PDB ID + Chain (e.g., 3vgm.A, 3VGM:A, 3VGM_A, 3vgm A)
</span>
</label>
<div className={`relative flex items-center border-2 ${getBorderColor()} rounded-lg transition-all duration-200 focus-within:ring-2 focus-within:ring-slate-900 focus-within:ring-offset-2 bg-white`}>
<input
type="text"
className="flex-1 px-4 py-3 text-sm text-slate-900 bg-transparent rounded-lg outline-none placeholder:text-slate-400 disabled:bg-slate-50 disabled:cursor-not-allowed"
value={value}
onChange={(e) => onValueChange(e.target.value)}
placeholder="e.g., 3vgm.A"
disabled={disabled}
/>
{value && searchStatus !== 'loading' && (
<button
className="absolute right-3 w-7 h-7 flex items-center justify-center text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-md transition-colors disabled:cursor-not-allowed"
onClick={handleClear}
disabled={disabled}
title="Clear"
>
</button>
)}
{searchStatus === 'loading' && (
<div className="absolute right-3 w-5 h-5 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin" title="Resolving chain..."></div>
)}
</div>
{searchStatus === 'success' && resolvedLabel && (
<div className="flex items-center gap-2 text-sm text-green-700 bg-green-50 px-3 py-2 rounded-lg border border-green-200">
<span className="text-base"></span>
<span className="font-medium">{resolvedLabel}</span>
</div>
)}
{showError && (
<div className="flex items-center gap-2 text-sm text-red-700 bg-red-50 px-3 py-2 rounded-lg border border-red-200">
<span className="text-base"></span>
<span>{displayError}</span>
</div>
)}
</div>
);
};