tududi/frontend/components/UniversalSearch/SearchMenu.tsx
Chris d32b5943d1
fix(search): Handle touch events to prevent input blur on mobile when saving views (#1039)
On mobile devices, clicking "Save as Smart View" button caused the search
input to lose focus, triggering the onBlur handler that closes the entire
search menu. The existing onMouseDown preventDefault() only worked for
mouse events, not touch events on mobile.

Added onTouchStart handler alongside onMouseDown to properly prevent
input blur on mobile devices when interacting with the search menu.

Fixes #994
2026-04-17 18:43:30 +03:00

717 lines
30 KiB
TypeScript

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';
import { getCsrfToken } from '../../utils/csrfService';
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<SearchMenuProps> = ({
searchQuery,
selectedFilters,
onFilterToggle,
onClose,
}) => {
const { t } = useTranslation();
const { showSuccessToast, showErrorToast } = useToast();
const [selectedPriority, setSelectedPriority] = useState<string | null>(
null
);
const [selectedDue, setSelectedDue] = useState<string | null>(null);
const [selectedDefer, setSelectedDefer] = useState<string | null>(null);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedExtras, setSelectedExtras] = useState<string[]>([]);
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',
'x-csrf-token': await getCsrfToken(),
},
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(
<div className="flex items-center gap-2">
<span>View saved successfully!</span>
<Link
to={`/views/${savedView.uid}`}
className="underline font-semibold hover:text-green-100"
onClick={onClose}
>
View now
</Link>
</div>
);
// 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) => (
<span key={f} style={{ fontWeight: 800, fontStyle: 'normal' }}>
{f.toLowerCase()}s
</span>
));
const entitiesWithSeparators: React.ReactNode[] = [];
entities.forEach((entity, index) => {
if (index > 0) {
entitiesWithSeparators.push(
<span key={`sep-entity-${index}`}>
{' ' + t('search.and') + ' '}
</span>
);
}
entitiesWithSeparators.push(entity);
});
parts.push(...entitiesWithSeparators);
} else {
// If no specific entities selected, show "all items"
parts.push(
<span
key="all"
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
{t('search.allItems')}
</span>
);
}
// Add search query
if (searchQuery.trim()) {
parts.push(
<span key="query-label">
{t('search.containingText') + ' '}
</span>
);
parts.push(
<span
key="query"
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
&quot;{searchQuery.trim()}&quot;
</span>
);
}
// Add priority filter
if (selectedPriority) {
parts.push(
<span key="priority-label">
{t('search.withPriority') + ' '}
</span>
);
parts.push(
<span
key="priority"
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
{selectedPriority}
</span>
);
parts.push(
<span key="priority-suffix">{' ' + t('search.priority')}</span>
);
}
// Add due date filter
if (selectedDue) {
const dueOption = dueOptions.find(
(opt) => opt.value === selectedDue
);
const dueLabel = dueOption ? t(dueOption.labelKey) : selectedDue;
parts.push(<span key="due-label">{t('search.due') + ' '}</span>);
parts.push(
<span
key="due"
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
{dueLabel}
</span>
);
}
// Add defer until filter
if (selectedDefer) {
const deferOption = deferOptions.find(
(opt) => opt.value === selectedDefer
);
const deferLabel = deferOption
? t(deferOption.labelKey)
: selectedDefer;
parts.push(
<span key="defer-label">{t('search.deferUntil') + ' '}</span>
);
parts.push(
<span
key="defer"
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
{deferLabel}
</span>
);
}
// Add tags filter
if (selectedTags.length > 0) {
parts.push(
<span key="tags-label">{t('search.taggedWith') + ' '}</span>
);
const tagElements = selectedTags.map((tag) => (
<span
key={`tag-${tag}`}
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
{tag}
</span>
));
const tagsWithSeparators: React.ReactNode[] = [];
tagElements.forEach((tagEl, index) => {
if (index > 0) {
if (index === tagElements.length - 1) {
tagsWithSeparators.push(
<span key={`sep-tag-and-${index}`}>
{' ' + t('search.and') + ' '}
</span>
);
} else {
tagsWithSeparators.push(
<span key={`sep-tag-comma-${index}`}>{', '}</span>
);
}
}
tagsWithSeparators.push(tagEl);
});
parts.push(...tagsWithSeparators);
}
// Add extras filters
if (selectedExtras.length > 0) {
parts.push(
<span key="extras-label">{t('search.thatAre') + ' '}</span>
);
const extrasElements = selectedExtras.map((extra) => {
const extraOption = extrasOptions.find(
(opt) => opt.value === extra
);
const extraLabel = extraOption
? t(extraOption.labelKey)
: extra;
return (
<span
key={`extra-${extra}`}
style={{ fontWeight: 800, fontStyle: 'normal' }}
>
{extraLabel}
</span>
);
});
const extrasWithSeparators: React.ReactNode[] = [];
extrasElements.forEach((extraEl, index) => {
if (index > 0) {
if (index === extrasElements.length - 1) {
extrasWithSeparators.push(
<span key={`sep-extra-and-${index}`}>
{' ' + t('search.and') + ' '}
</span>
);
} else {
extrasWithSeparators.push(
<span key={`sep-extra-comma-${index}`}>{', '}</span>
);
}
}
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 (
<div
className="fixed left-1/2 transform -translate-x-1/2 top-32 md:top-20 w-[95vw] md:w-[90vw] max-w-full md:max-w-4xl h-[75vh] md:h-[80vh] max-h-[600px] md:max-h-[700px] bg-white dark:bg-gray-800 rounded-lg shadow-2xl border border-gray-200 dark:border-gray-700 z-50 flex flex-col"
onMouseDown={(e) => {
// Prevent input blur on mobile when clicking inside the search menu
e.preventDefault();
}}
onTouchStart={(e) => {
// Prevent input blur on mobile when touching inside the search menu
e.preventDefault();
}}
>
{/* Filter Badges Section */}
<div className="border-b border-gray-200 dark:border-gray-700 overflow-y-auto max-h-[40vh] md:max-h-none">
{/* Search Description */}
{hasActiveFilters && searchDescription && (
<>
<div className="px-4 pt-4 pb-3 flex items-center gap-3">
<InformationCircleIcon className="h-6 w-6 text-black/30 dark:text-white/30 flex-shrink-0" />
<p
className="text-xl text-black/40 dark:text-white/40 flex-1"
style={{
fontFamily: "'Lora', Georgia, serif",
fontStyle: 'italic',
}}
>
{searchDescription}
</p>
</div>
<div className="border-t border-gray-300 dark:border-gray-600"></div>
</>
)}
{/* Toggle Criteria Button */}
<div className="px-4 py-3">
<button
onClick={() => setShowCriteria(!showCriteria)}
className="flex items-center justify-between w-full text-left text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
>
<span>{t('search.criteria')}</span>
{showCriteria ? (
<ChevronUpIcon className="h-5 w-5" />
) : (
<ChevronDownIcon className="h-5 w-5" />
)}
</button>
</div>
{/* Collapsible Criteria Section */}
{showCriteria && (
<div className="px-4 pb-4">
{/* Entity Type Badges */}
<div className="flex flex-wrap gap-2">
{filterTypes.map((filter) => {
const isSelected = selectedFilters.includes(
filter.value
);
return (
<FilterBadge
key={filter.value}
name={t(filter.labelKey)}
color={filter.color}
isSelected={isSelected}
onToggle={() =>
onFilterToggle(filter.value)
}
/>
);
})}
</div>
{/* Divider */}
<div className="my-4 border-t border-gray-300 dark:border-gray-600"></div>
{/* Metadata Filters */}
<div className="space-y-3">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">
{t('search.metadataFilters')}
</div>
{/* Priority Filters */}
<div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
{t('search.priorityFilter')}
</div>
<div className="flex flex-wrap gap-2">
{priorityOptions.map((option) => (
<FilterBadge
key={option.value}
name={t(option.labelKey)}
isSelected={
selectedPriority ===
option.value
}
onToggle={() =>
handlePriorityToggle(
option.value
)
}
/>
))}
</div>
</div>
{/* Due Date Filters */}
<div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
{t('search.dueFilter')}
</div>
<div className="flex flex-wrap gap-2">
{dueOptions.map((option) => (
<FilterBadge
key={option.value}
name={t(option.labelKey)}
isSelected={
selectedDue === option.value
}
onToggle={() =>
handleDueToggle(option.value)
}
/>
))}
</div>
</div>
{/* Defer Until Filters */}
<div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
{t('search.deferUntilFilter')}
</div>
<div className="flex flex-wrap gap-2">
{deferOptions.map((option) => (
<FilterBadge
key={option.value}
name={t(option.labelKey)}
isSelected={
selectedDefer === option.value
}
onToggle={() =>
handleDeferToggle(option.value)
}
/>
))}
</div>
</div>
{/* Tags Filters */}
{availableTags.length > 0 && (
<div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1.5">
{t('search.tagsFilter')}
</div>
<div className="flex flex-wrap gap-2">
{availableTags.map((tag) => (
<FilterBadge
key={tag.id}
name={tag.name}
color="bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200"
isSelected={selectedTags.includes(
tag.name
)}
onToggle={() =>
handleTagToggle(tag.name)
}
/>
))}
</div>
</div>
)}
</div>
{/* Divider */}
<div className="my-4 border-t border-gray-300 dark:border-gray-600"></div>
{/* Extras Section */}
<div className="space-y-3">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400 mb-2">
{t('search.extras')}
</div>
{/* Extras Filters */}
<div className="flex flex-wrap gap-2">
{extrasOptions.map((option) => (
<FilterBadge
key={option.value}
name={t(option.labelKey)}
color="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
isSelected={selectedExtras.includes(
option.value
)}
onToggle={() =>
handleExtrasToggle(option.value)
}
/>
))}
</div>
</div>
{/* Save as Smart View Section */}
{hasActiveFilters && (
<div className="mt-4 pt-4 border-t border-gray-300 dark:border-gray-600">
{!showSaveForm ? (
<button
onClick={() => setShowSaveForm(true)}
className="flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
>
<BookmarkIcon className="h-4 w-4" />
<span>
{t('search.saveAsSmartView')}
</span>
</button>
) : (
<div className="space-y-3">
<div>
<label
htmlFor="viewName"
className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{t('search.viewName')}{' '}
<span className="text-red-500">
*
</span>
</label>
<input
type="text"
id="viewName"
value={viewName}
onChange={(e) => {
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 && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
{saveError}
</p>
)}
</div>
<div className="flex gap-2 justify-end">
<button
type="button"
onClick={handleCancelSave}
className="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
>
{t('search.cancel')}
</button>
<button
type="button"
onClick={handleSaveView}
disabled={isSaving}
className="px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 rounded-md transition-colors"
>
{isSaving
? t('search.saving')
: t('search.saveView')}
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Search Results */}
<SearchResults
searchQuery={searchQuery}
selectedFilters={selectedFilters}
selectedPriority={selectedPriority}
selectedDue={selectedDue}
selectedDefer={selectedDefer}
selectedTags={selectedTags}
selectedExtras={selectedExtras}
onClose={onClose}
/>
</div>
);
};
export default SearchMenu;