import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { InformationCircleIcon, BookmarkIcon, ChevronDownIcon, ChevronUpIcon, } from '@heroicons/react/24/outline'; import FilterBadge from './FilterBadge'; import SearchResults from './SearchResults'; import { useToast } from '../Shared/ToastContext'; import { getApiPath } from '../../config/paths'; interface SearchMenuProps { searchQuery: string; selectedFilters: string[]; onFilterToggle: (filter: string) => void; onClose: () => void; } const filterTypes = [ { value: 'Task', labelKey: 'search.entityTypes.task', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', }, { value: 'Project', labelKey: 'search.entityTypes.project', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', }, { value: 'Area', labelKey: 'search.entityTypes.area', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', }, { value: 'Note', labelKey: 'search.entityTypes.note', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', }, ]; const priorityOptions = [ { value: 'high', labelKey: 'priority.high' }, { value: 'medium', labelKey: 'priority.medium' }, { value: 'low', labelKey: 'priority.low' }, ]; const dueOptions = [ { value: 'today', labelKey: 'dateIndicators.today' }, { value: 'tomorrow', labelKey: 'dateIndicators.tomorrow' }, { value: 'next_week', labelKey: 'dateIndicators.nextWeek' }, { value: 'next_month', labelKey: 'dateIndicators.nextMonth' }, ]; const deferOptions = [ { value: 'today', labelKey: 'dateIndicators.today' }, { value: 'tomorrow', labelKey: 'dateIndicators.tomorrow' }, { value: 'next_week', labelKey: 'dateIndicators.nextWeek' }, { value: 'next_month', labelKey: 'dateIndicators.nextMonth' }, ]; const extrasOptions = [ { value: 'recurring', labelKey: 'search.extrasFilter.isRecurring' }, { value: 'overdue', labelKey: 'search.extrasFilter.isOverdue' }, { value: 'has_content', labelKey: 'search.extrasFilter.hasContent' }, { value: 'deferred', labelKey: 'search.extrasFilter.isDeferred' }, { value: 'has_tags', labelKey: 'search.extrasFilter.hasTags' }, { value: 'assigned_to_project', labelKey: 'search.extrasFilter.isAssignedToProject', }, ]; const SearchMenu: React.FC = ({ searchQuery, selectedFilters, onFilterToggle, onClose, }) => { const { t } = useTranslation(); const { showSuccessToast, showErrorToast } = useToast(); const [selectedPriority, setSelectedPriority] = useState( null ); const [selectedDue, setSelectedDue] = useState(null); const [selectedDefer, setSelectedDefer] = useState(null); const [selectedTags, setSelectedTags] = useState([]); const [selectedExtras, setSelectedExtras] = useState([]); const [availableTags, setAvailableTags] = useState< Array<{ id: number; name: string }> >([]); const [showSaveForm, setShowSaveForm] = useState(false); const [viewName, setViewName] = useState(''); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(''); const [showCriteria, setShowCriteria] = useState(false); // Fetch available tags useEffect(() => { const fetchTags = async () => { try { const response = await fetch(getApiPath('tags'), { credentials: 'include', }); if (response.ok) { const tags = await response.json(); setAvailableTags(tags); } } catch (error) { console.error('Error fetching tags:', error); } }; fetchTags(); }, []); const handlePriorityToggle = (priority: string) => { setSelectedPriority(selectedPriority === priority ? null : priority); }; const handleDueToggle = (due: string) => { setSelectedDue(selectedDue === due ? null : due); }; const handleDeferToggle = (defer: string) => { setSelectedDefer(selectedDefer === defer ? null : defer); }; const handleTagToggle = (tagName: string) => { setSelectedTags((prev) => prev.includes(tagName) ? prev.filter((t) => t !== tagName) : [...prev, tagName] ); }; const handleExtrasToggle = (extra: string) => { setSelectedExtras((prev) => prev.includes(extra) ? prev.filter((e) => e !== extra) : [...prev, extra] ); }; const handleSaveView = async () => { if (!viewName.trim()) { setSaveError(t('search.viewNameRequired')); return; } setIsSaving(true); setSaveError(''); try { const response = await fetch(getApiPath('views'), { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', body: JSON.stringify({ name: viewName.trim(), search_query: searchQuery || null, filters: selectedFilters, priority: selectedPriority || null, due: selectedDue || null, defer: selectedDefer || null, tags: selectedTags.length > 0 ? selectedTags : null, extras: selectedExtras.length > 0 ? selectedExtras : null, }), }); if (!response.ok) { throw new Error('Failed to save view'); } const savedView = await response.json(); // Reset form setViewName(''); setShowSaveForm(false); setSaveError(''); // Show success toast with link to the view showSuccessToast(
View saved successfully! View now
); // Notify sidebar to refresh window.dispatchEvent(new CustomEvent('viewUpdated')); } catch (err) { setSaveError(t('search.failedToSave')); showErrorToast('Failed to save view. Please try again.'); console.error('Error saving view:', err); } finally { setIsSaving(false); } }; const handleCancelSave = () => { setShowSaveForm(false); setViewName(''); setSaveError(''); }; const buildSearchDescription = () => { const parts: React.ReactNode[] = []; // Build entity types part if (selectedFilters.length > 0) { const entities = selectedFilters.map((f) => ( {f.toLowerCase()}s )); const entitiesWithSeparators: React.ReactNode[] = []; entities.forEach((entity, index) => { if (index > 0) { entitiesWithSeparators.push( {' ' + t('search.and') + ' '} ); } entitiesWithSeparators.push(entity); }); parts.push(...entitiesWithSeparators); } else { // If no specific entities selected, show "all items" parts.push( {t('search.allItems')} ); } // Add search query if (searchQuery.trim()) { parts.push( {t('search.containingText') + ' '} ); parts.push( "{searchQuery.trim()}" ); } // Add priority filter if (selectedPriority) { parts.push( {t('search.withPriority') + ' '} ); parts.push( {selectedPriority} ); parts.push( {' ' + t('search.priority')} ); } // Add due date filter if (selectedDue) { const dueOption = dueOptions.find( (opt) => opt.value === selectedDue ); const dueLabel = dueOption ? t(dueOption.labelKey) : selectedDue; parts.push({t('search.due') + ' '}); parts.push( {dueLabel} ); } // Add defer until filter if (selectedDefer) { const deferOption = deferOptions.find( (opt) => opt.value === selectedDefer ); const deferLabel = deferOption ? t(deferOption.labelKey) : selectedDefer; parts.push( {t('search.deferUntil') + ' '} ); parts.push( {deferLabel} ); } // Add tags filter if (selectedTags.length > 0) { parts.push( {t('search.taggedWith') + ' '} ); const tagElements = selectedTags.map((tag) => ( {tag} )); const tagsWithSeparators: React.ReactNode[] = []; tagElements.forEach((tagEl, index) => { if (index > 0) { if (index === tagElements.length - 1) { tagsWithSeparators.push( {' ' + t('search.and') + ' '} ); } else { tagsWithSeparators.push( {', '} ); } } tagsWithSeparators.push(tagEl); }); parts.push(...tagsWithSeparators); } // Add extras filters if (selectedExtras.length > 0) { parts.push( {t('search.thatAre') + ' '} ); const extrasElements = selectedExtras.map((extra) => { const extraOption = extrasOptions.find( (opt) => opt.value === extra ); const extraLabel = extraOption ? t(extraOption.labelKey) : extra; return ( {extraLabel} ); }); const extrasWithSeparators: React.ReactNode[] = []; extrasElements.forEach((extraEl, index) => { if (index > 0) { if (index === extrasElements.length - 1) { extrasWithSeparators.push( {' ' + t('search.and') + ' '} ); } else { extrasWithSeparators.push( {', '} ); } } extrasWithSeparators.push(extraEl); }); parts.push(...extrasWithSeparators); } if (parts.length === 0) return null; // Construct the sentence return ( <> {t('search.searchingFor')} {parts} ); }; const searchDescription = buildSearchDescription(); const hasActiveFilters = selectedFilters.length > 0 || searchQuery.trim() || selectedPriority || selectedDue || selectedDefer || selectedTags.length > 0 || selectedExtras.length > 0; return (
{ // Prevent input blur on mobile when clicking inside the search menu e.preventDefault(); }} > {/* Filter Badges Section */}
{/* Search Description */} {hasActiveFilters && searchDescription && ( <>

{searchDescription}

)} {/* Toggle Criteria Button */}
{/* Collapsible Criteria Section */} {showCriteria && (
{/* Entity Type Badges */}
{filterTypes.map((filter) => { const isSelected = selectedFilters.includes( filter.value ); return ( onFilterToggle(filter.value) } /> ); })}
{/* Divider */}
{/* Metadata Filters */}
{t('search.metadataFilters')}
{/* Priority Filters */}
{t('search.priorityFilter')}
{priorityOptions.map((option) => ( handlePriorityToggle( option.value ) } /> ))}
{/* Due Date Filters */}
{t('search.dueFilter')}
{dueOptions.map((option) => ( handleDueToggle(option.value) } /> ))}
{/* Defer Until Filters */}
{t('search.deferUntilFilter')}
{deferOptions.map((option) => ( handleDeferToggle(option.value) } /> ))}
{/* Tags Filters */} {availableTags.length > 0 && (
{t('search.tagsFilter')}
{availableTags.map((tag) => ( handleTagToggle(tag.name) } /> ))}
)}
{/* Divider */}
{/* Extras Section */}
{t('search.extras')}
{/* Extras Filters */}
{extrasOptions.map((option) => ( handleExtrasToggle(option.value) } /> ))}
{/* Save as Smart View Section */} {hasActiveFilters && (
{!showSaveForm ? ( ) : (
{ setViewName(e.target.value); setSaveError(''); }} onKeyDown={(e) => { if (e.key === 'Enter') { handleSaveView(); } else if ( e.key === 'Escape' ) { handleCancelSave(); } }} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder={t( 'search.viewNamePlaceholder' )} autoFocus /> {saveError && (

{saveError}

)}
)}
)}
)}
{/* Search Results */}
); }; export default SearchMenu;