import { useState, useRef, useEffect, useMemo } from 'react'; import { Search, Loader2, ChevronDown, Cloud, CloudOff } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { FilamentSectionProps, FilamentOption } from './types'; import { KNOWN_VARIANTS } from './constants'; import { parsePresetName } from './utils'; // The identity fields a slicer preset can auto-fill. type PresetFilledField = 'material' | 'brand' | 'subtype'; // Split a dropdown's options into the ones the catalog/presets pair with the // other field's current value and everything else (#1905). Suggestions are only // a sort order — nothing is ever hidden, so an unusual-but-real combination // (Elegoo ASA) stays one click away. function splitSuggested(options: string[], suggested: string[]): { top: string[]; rest: string[] } { if (suggested.length === 0) return { top: [], rest: options }; const keys = new Set(suggested.map(s => s.toLowerCase())); return { top: options.filter(o => keys.has(o.toLowerCase())), rest: options.filter(o => !keys.has(o.toLowerCase())), }; } function GroupHeading({ label }: { label: string }) { return (
{label}
); } export function FilamentSection({ formData, updateField, cloudAuthenticated, loadingCloudPresets, presetInputValue, setPresetInputValue, selectedPresetOption, filamentOptions, availableBrands, availableMaterials, suggestedBrands, suggestedMaterials, quickAdd, detailsRequired, quantity, onQuantityChange, errors, }: FilamentSectionProps) { const { t } = useTranslation(); const [presetDropdownOpen, setPresetDropdownOpen] = useState(false); const [brandDropdownOpen, setBrandDropdownOpen] = useState(false); const [subtypeDropdownOpen, setSubtypeDropdownOpen] = useState(false); const [materialDropdownOpen, setMaterialDropdownOpen] = useState(false); const [brandSearch, setBrandSearch] = useState(''); const [subtypeSearch, setSubtypeSearch] = useState(''); const [materialSearch, setMaterialSearch] = useState(''); const [labelInput, setLabelInput] = useState(String(formData.label_weight)); const [isLabelFocused, setIsLabelFocused] = useState(false); // Which identity fields the currently selected preset filled in (#1905). // Anything outside this set was set by the user (or loaded from the spool // being edited) and must survive picking a preset. const [presetFilled, setPresetFilled] = useState>(new Set()); const presetRef = useRef(null); const brandRef = useRef(null); const subtypeRef = useRef(null); const materialRef = useRef(null); // Close dropdowns on outside click useEffect(() => { const handleClick = (e: MouseEvent) => { if (presetRef.current && !presetRef.current.contains(e.target as Node)) { setPresetDropdownOpen(false); } if (materialRef.current && !materialRef.current.contains(e.target as Node)) { setMaterialDropdownOpen(false); } if (brandRef.current && !brandRef.current.contains(e.target as Node)) { setBrandDropdownOpen(false); } if (subtypeRef.current && !subtypeRef.current.contains(e.target as Node)) { setSubtypeDropdownOpen(false); } }; document.addEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick); }, []); // Filtered presets based on search const filteredPresets = useMemo(() => { if (!presetInputValue) return filamentOptions; const search = presetInputValue.toLowerCase(); return filamentOptions.filter(o => o.displayName.toLowerCase().includes(search) || o.code.toLowerCase().includes(search), ); }, [filamentOptions, presetInputValue]); // Filtered brands const filteredBrands = useMemo(() => { if (!brandSearch) return availableBrands; const search = brandSearch.toLowerCase(); const filtered = availableBrands.filter(b => b.toLowerCase().includes(search)); // Sort: exact match first, then others return filtered.sort((a, b) => { const aExact = a.toLowerCase() === search; const bExact = b.toLowerCase() === search; if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; return a.localeCompare(b); }); }, [availableBrands, brandSearch]); const filteredVariants = useMemo(() => { if (!subtypeSearch) return KNOWN_VARIANTS; const search = subtypeSearch.toLowerCase(); return KNOWN_VARIANTS.filter(v => v.toLowerCase().includes(search)); }, [subtypeSearch]); const filteredMaterials = useMemo(() => { if (!materialSearch) return availableMaterials; const search = materialSearch.toLowerCase(); const filtered = availableMaterials.filter(m => m.toLowerCase().includes(search)); // Sort: exact match first, then others return filtered.sort((a, b) => { const aExact = a.toLowerCase() === search; const bExact = b.toLowerCase() === search; if (aExact && !bExact) return -1; if (!aExact && bExact) return 1; return a.localeCompare(b); }); }, [materialSearch, availableMaterials]); const brandGroups = useMemo( () => splitSuggested(filteredBrands, suggestedBrands), [filteredBrands, suggestedBrands], ); const materialGroups = useMemo( () => splitSuggested(filteredMaterials, suggestedMaterials), [filteredMaterials, suggestedMaterials], ); useEffect(() => { if (!isLabelFocused) { setLabelInput(String(formData.label_weight)); } }, [formData.label_weight, isLabelFocused]); // Handle preset selection const handlePresetSelect = (option: FilamentOption) => { updateField('slicer_filament', option.code); setPresetInputValue(option.displayName); setPresetDropdownOpen(false); // Auto-fill material, brand and subtype from the preset name — but only // where the value is still empty or was itself put there by the previously // selected preset (#1905). Overwriting unconditionally silently rewrote the // spool's own manufacturer ("Elegoo" → "Generic") whenever the user was // forced to assign a preset, so the spool vanished from where they filed it. // Switching between presets still replaces what the earlier one filled in. const parsed = parsePresetName(option.name); const filled = new Set(); const entries: [PresetFilledField, string][] = [ ['material', parsed.material], ['brand', parsed.brand], ['subtype', parsed.variant], ]; for (const [field, value] of entries) { if (!value) continue; if (formData[field] && !presetFilled.has(field)) continue; updateField(field, value); filled.add(field); } setPresetFilled(filled); }; // A manual edit to an identity field takes it back out of the preset's hands. const updateIdentityField = (field: PresetFilledField, value: string) => { if (presetFilled.has(field)) { setPresetFilled(prev => { const next = new Set(prev); next.delete(field); return next; }); } updateField(field, value); }; const renderBrandOption = (brand: string) => ( ); const renderMaterialOption = (material: string) => ( ); return (
{/* Cloud status indicator */} {!quickAdd && (
{loadingCloudPresets ? ( <> {t('inventory.loadingPresets')} ) : cloudAuthenticated ? ( <> {t('inventory.cloudConnected')} ) : ( <> {t('inventory.cloudNotConnected')} )}
)} {/* Slicer Preset (autocomplete) — hidden in quick-add mode */} {!quickAdd && (
{ setPresetInputValue(e.target.value); setPresetDropdownOpen(true); }} onFocus={() => { setPresetDropdownOpen(true); setPresetInputValue(''); }} /> {presetDropdownOpen && (
{filteredPresets.length === 0 ? (
{t('inventory.noPresetsFound')}
) : ( filteredPresets.map(option => ( )) )}
)}
{selectedPresetOption && (
{t('inventory.selectedPreset')}: {selectedPresetOption.code}
)} {errors?.slicer_filament && (

{errors.slicer_filament}

)}
)} {/* Material */}
{ setMaterialSearch(e.target.value); setMaterialDropdownOpen(true); }} onFocus={() => { setMaterialDropdownOpen(true); setMaterialSearch(''); }} /> {materialDropdownOpen && (
{filteredMaterials.length === 0 ? (
{t('inventory.noResults')}
) : ( <> {materialGroups.top.length > 0 && ( <> {materialGroups.top.map(renderMaterialOption)} )} {materialGroups.rest.length > 0 && ( <> {materialGroups.top.length > 0 && } {materialGroups.rest.map(renderMaterialOption)} )} )} {/* Allow custom material */} {materialSearch && !filteredMaterials.includes(materialSearch) && ( )}
)}
{errors?.material && (

{errors.material}

)}
{/* Brand (dropdown with search) */}
{ setBrandSearch(e.target.value); setBrandDropdownOpen(true); }} onFocus={() => { setBrandDropdownOpen(true); setBrandSearch(''); }} /> {brandDropdownOpen && (
{filteredBrands.length === 0 ? (
{t('inventory.noResults')}
) : ( <> {brandGroups.top.length > 0 && ( <> {brandGroups.top.map(renderBrandOption)} )} {brandGroups.rest.length > 0 && ( <> {brandGroups.top.length > 0 && } {brandGroups.rest.map(renderBrandOption)} )} )} {/* Allow custom brand */} {brandSearch && !filteredBrands.includes(brandSearch) && ( )}
)}
{errors?.brand && (

{errors.brand}

)}
{/* Variant / Subtype */}
{ setSubtypeSearch(e.target.value); setSubtypeDropdownOpen(true); }} onFocus={() => { setSubtypeDropdownOpen(true); setSubtypeSearch(''); }} placeholder="Basic, Matte, Silk..." className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm placeholder:text-bambu-gray/50 focus:outline-none focus:border-bambu-green" /> {subtypeDropdownOpen && (
{filteredVariants.length === 0 ? (
{t('inventory.noResults')}
) : ( filteredVariants.map(variant => ( )) )} {subtypeSearch && !KNOWN_VARIANTS.some(v => v.toLowerCase() === subtypeSearch.toLowerCase().trim()) && ( )}
)}
{errors?.subtype && (

{errors.subtype}

)}
{/* Label Weight */}
setIsLabelFocused(true)} onChange={(e) => setLabelInput(e.target.value)} onBlur={() => { setIsLabelFocused(false); const raw = labelInput.trim(); const next = Number(raw); if (!raw || !Number.isFinite(next) || next < 0) { setLabelInput(String(formData.label_weight)); return; } const rounded = Math.round(next); updateField('label_weight', rounded); setLabelInput(String(rounded)); }} /> g
{/* Quantity — only in quick-add mode */} {quickAdd && (
{ const val = Math.max(1, Math.min(100, parseInt(e.target.value) || 1)); onQuantityChange(val); }} />
)}
); }