From a6fa45041f3a825426b1c1e39ba765214aeb94eb Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Thu, 3 Apr 2025 18:16:47 +0200 Subject: [PATCH] feat(ui): dynamic fitlers - ui aligments and other improvements --- .../shared/Filters/FilterItem/FilterItem.tsx | 280 ++++++++++------- .../Filters/FilterList/FilterListHeader.tsx | 2 +- .../Filters/FilterList/UnifiedFilterList.tsx | 121 +++++--- .../Filters/FilterModal/FilterModal.tsx | 284 ++++++++---------- .../Filters/FilterOperator/FilterOperator.tsx | 2 +- .../FilterSelection/FilterSelection.tsx | 55 +++- .../shared/SessionFilters/SessionFilters.tsx | 6 +- frontend/app/mstore/filterStore.ts | 2 +- frontend/package.json | 2 +- 9 files changed, 431 insertions(+), 323 deletions(-) diff --git a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx index 43fd5510c..4f5e48cc4 100644 --- a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx +++ b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useCallback, useState, useEffect } from 'react'; -import { Button, Space, Typography } from 'antd'; +import { Button, Space, Typography, Tooltip } from 'antd'; import { FilterKey } from 'App/types/filter/filterType'; -import { CircleMinus, Filter as FilterIcon } from 'lucide-react'; +import { CircleMinus, FunnelPlus } from 'lucide-react'; import cn from 'classnames'; import FilterOperator from '../FilterOperator'; import FilterSelection from '../FilterSelection'; @@ -27,11 +27,13 @@ interface Props { subFilterIndex?: number; propertyOrder?: string; onToggleOperator?: (newOp: string) => void; + parentEventFilterOptions?: Filter[]; + isDragging?: boolean; + isFirst?: boolean; } function FilterItem(props: Props) { const { - isFilter = false, filterIndex, filter, saveRequestPayloads, @@ -43,35 +45,52 @@ function FilterItem(props: Props) { onRemoveFilter, readonly, isSubItem = false, - subFilterIndex, + subFilterIndex = 0, // Default to 0 propertyOrder, - onToggleOperator + onToggleOperator, + parentEventFilterOptions, + isDragging, + isFirst = false // Default to false } = props; + const [eventFilterOptions, setEventFilterOptions] = useState([]); + const [eventFiltersLoading, setEventFiltersLoading] = useState(false); const { filterStore } = useStore(); const allFilters = filterStore.getCurrentProjectFilters(); const eventSelections = allFilters.filter((i) => i.isEvent === filter.isEvent); - const filterSelections = isSubItem ? eventFilterOptions : eventSelections; + const filterSelections = useMemo(() => { + if (isSubItem) { + return parentEventFilterOptions || []; + } + return eventSelections; + }, [isSubItem, parentEventFilterOptions, eventSelections]); - const [eventFiltersLoading, setEventFiltersLoading] = useState(false); const operatorOptions = getOperatorsByType(filter.type); - useEffect(() => { async function loadFilters() { - try { - setEventFiltersLoading(true); - const options = await filterStore.getEventFilters(filter.name); - setEventFilterOptions(options); - } finally { - setEventFiltersLoading(false); + if (!isSubItem && filter.isEvent && filter.name) { + try { + setEventFiltersLoading(true); + const options = await filterStore.getEventFilters(filter.name); + setEventFilterOptions(options); + } catch (error) { + console.error('Failed to load event filters:', error); + setEventFilterOptions([]); + } finally { + setEventFiltersLoading(false); + } + } else { + if (eventFilterOptions.length > 0) { + setEventFilterOptions([]); + } } } void loadFilters(); - }, [filter.name]); // Re-fetch when filter name changes + }, [filter.name, filter.isEvent, isSubItem, filterStore]); const canShowValues = useMemo( () => @@ -89,10 +108,11 @@ function FilterItem(props: Props) { (selectedFilter: any) => { onUpdate({ ...selectedFilter, - value: selectedFilter.value, + value: selectedFilter.value || [''], filters: selectedFilter.filters ? selectedFilter.filters.map((i: any) => ({ ...i, value: [''] })) - : [] + : [], + operator: selectedFilter.operator // Ensure operator is carried over or reset if needed }); }, [onUpdate] @@ -146,78 +166,98 @@ function FilterItem(props: Props) { const addSubFilter = useCallback( (selectedFilter: any) => { + const newSubFilter = { + ...selectedFilter, + value: selectedFilter.value || [''], + operator: selectedFilter.operator || getOperatorsByType(selectedFilter.type)[0]?.value // Default operator + }; onUpdate({ ...filter, - filters: [...filteredSubFilters, selectedFilter] + filters: [...(filter.filters || []), newSubFilter] }); }, [filter, onUpdate] ); + const parentShowsIndex = !hideIndex; + const subFilterMarginLeftClass = parentShowsIndex ? 'ml-[1.75rem]' : 'ml-[0.75rem]'; + const subFilterPaddingLeftClass = parentShowsIndex ? 'pl-11' : 'pl-7'; + return ( -
-
-
- {!isFilter && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && ( -
- {filterIndex + 1} -
- )} +
+
{/* Use items-start */} - {isSubItem && ( -
- {subFilterIndex === 0 && ( - - where - - )} - {subFilterIndex != 0 && propertyOrder && onToggleOperator && ( - - onToggleOperator(propertyOrder === 'and' ? 'or' : 'and') - } - > - {propertyOrder} - - )} -
- )} + {!isSubItem && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && ( +
{/* Align index top */} + {filterIndex + 1} +
+ )} + {isSubItem && ( +
{/* Align where/and top */} + {subFilterIndex === 0 && ( + + where + + )} + {subFilterIndex !== 0 && propertyOrder && onToggleOperator && ( + + !readonly && onToggleOperator(propertyOrder === 'and' ? 'or' : 'and') + } + > + {propertyOrder} + + )} +
+ )} + + {/* Main content area */} +
{/* Use baseline inside here */} -
+
{filter && getIconForFilter(filter)}
-
- {`${filter?.subCategory ? filter.subCategory : filter?.category}`} -
- + {(filter?.subCategory || filter?.category) && ( +
+ {`${filter?.subCategory ? filter.subCategory : filter?.category}`} +
+ )} + {(filter?.subCategory || filter?.category) && (filter.displayName || filter.name) && + + }
- {filter.displayName || filter.name} + {filter.displayName || filter.name || 'Select Filter'}
-
{filter.hasSource && ( @@ -225,7 +265,6 @@ function FilterItem(props: Props) { @@ -233,89 +272,108 @@ function FilterItem(props: Props) { )} - {operatorOptions.length && ( + {operatorOptions.length > 0 && filter.type && ( <> {canShowValues && (readonly ? (
+ className="rounded bg-gray-lightest text-gray-dark px-2 py-1 text-sm whitespace-nowrap overflow-hidden text-ellipsis border border-gray-light max-w-xs" + title={filter.value.join(', ')} + > {filter.value .map((val: string) => - filter.options && filter.options.length - ? filter.options[ - filter.options.findIndex((i: any) => i.value === val) - ]?.label ?? val - : val + filter.options?.find((i: any) => i.value === val)?.label ?? val ) .join(', ')}
) : ( - +
{/* Wrap FilterValue */} + +
))} )} -
-
- {!readonly && !hideDelete && ( -
{filter.isEvent && !isSubItem && ( -
+
-
)}
- {filter.filters?.length > 0 && ( -
+ {/* Sub-Filter Rendering */} + {filteredSubFilters.length > 0 && ( +
+ {/* Dashed line */} +
+ {filteredSubFilters.map((subFilter: any, index: number) => ( - handleUpdateSubFilter(updatedSubFilter, index)} - onRemoveFilter={() => handleRemoveSubFilter(index)} - isFilter={isFilter} - saveRequestPayloads={saveRequestPayloads} - disableDelete={disableDelete} - readonly={readonly} - hideIndex={hideIndex} - hideDelete={hideDelete} - isConditional={isConditional} - isSubItem={true} - propertyOrder={filter.propertyOrder || 'and'} - onToggleOperator={(newOp) => - onUpdate({ ...filter, propertyOrder: newOp }) - } - /> +
+ handleUpdateSubFilter(updatedSubFilter, index)} + onRemoveFilter={() => handleRemoveSubFilter(index)} + saveRequestPayloads={saveRequestPayloads} + disableDelete={disableDelete} + readonly={readonly} + hideIndex={true} // Sub-items always hide index + hideDelete={hideDelete} + isConditional={isConditional} + isSubItem={true} + propertyOrder={filter.propertyOrder || 'and'} // Sub-items use parent's propertyOrder + onToggleOperator={onToggleOperator} // Pass down the parent's toggle function + parentEventFilterOptions={isSubItem ? parentEventFilterOptions : eventFilterOptions} // Pass options down + isFirst={index === 0} // Mark the first sub-filter + /> +
))}
)} diff --git a/frontend/app/components/shared/Filters/FilterList/FilterListHeader.tsx b/frontend/app/components/shared/Filters/FilterList/FilterListHeader.tsx index da05fe570..4f9dcdb99 100644 --- a/frontend/app/components/shared/Filters/FilterList/FilterListHeader.tsx +++ b/frontend/app/components/shared/Filters/FilterList/FilterListHeader.tsx @@ -20,7 +20,7 @@ const FilterListHeader = ({ actions = [] }: FilterListHeaderProps) => { return ( -
+
{title}
{filterSelection} diff --git a/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx b/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx index 4fe43b640..54b16f2ea 100644 --- a/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx +++ b/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx @@ -2,7 +2,6 @@ import { GripVertical } from 'lucide-react'; import React, { useState, useCallback } from 'react'; import cn from 'classnames'; import FilterItem from '../FilterItem'; -import { useTranslation } from 'react-i18next'; import { Filter } from '@/mstore/types/filterConstants'; interface UnifiedFilterListProps { @@ -31,7 +30,6 @@ interface UnifiedFilterListProps { } const UnifiedFilterList = (props: UnifiedFilterListProps) => { - const { t } = useTranslation(); const { filters, handleRemove, @@ -41,12 +39,10 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => { showIndices = true, readonly = false, isConditional = false, - showEventsOrder = false, saveRequestPayloads = false, supportsEmpty = true, - mergeDown = false, - mergeUp = false, - style + style, + className } = props; const [hoveredItem, setHoveredItem] = useState<{ i: number | null; position: string | null }>({ @@ -66,8 +62,11 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => { }, [handleRemove]); const calculateNewPosition = useCallback( - (dragInd: number, hoverIndex: number, hoverPosition: string) => { - return hoverPosition === 'bottom' ? (dragInd < hoverIndex ? hoverIndex - 1 : hoverIndex) : hoverIndex; + (hoverIndex: number, hoverPosition: string) => { + // Calculate the target *visual* position + // If hovering top half, target index is hoverIndex. + // If bottom half, target index is hoverIndex + 1. + return hoverPosition === 'bottom' ? hoverIndex + 1 : hoverIndex; }, [] ); @@ -78,7 +77,18 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => { setDraggedItem(index); const el = document.getElementById(elId); if (el) { - ev.dataTransfer.setDragImage(el, 0, 0); + const clone = el.cloneNode(true) as HTMLElement; + clone.style.position = 'absolute'; + clone.style.left = '-9999px'; + clone.style.width = el.offsetWidth + 'px'; + clone.style.height = 'auto'; + clone.style.opacity = '0.7'; + clone.style.backgroundColor = 'white'; + clone.style.padding = '0.5rem'; + clone.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)'; // Add shadow + document.body.appendChild(clone); + ev.dataTransfer.setDragImage(clone, 20, 20); + setTimeout(() => document.body.removeChild(clone), 0); } }, [] @@ -86,65 +96,101 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => { const handleDragOver = useCallback((event: React.DragEvent, i: number) => { event.preventDefault(); + // Prevent re-calculating hover position if already hovering over the same item + if (hoveredItem.i === i) return; + const target = event.currentTarget.getBoundingClientRect(); const hoverMiddleY = (target.bottom - target.top) / 2; const hoverClientY = event.clientY - target.top; const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom'; setHoveredItem({ position, i }); - }, []); + }, [hoveredItem.i]); // Depend on hoveredItem.i to avoid unnecessary updates + const handleDrop = useCallback( (event: React.DragEvent) => { event.preventDefault(); - if (draggedInd === null || hoveredItem.i === null) return; - const newPosition = calculateNewPosition( - draggedInd, - hoveredItem.i, - hoveredItem.position || 'bottom' - ); - handleMove(draggedInd, newPosition); + const draggedIndexStr = event.dataTransfer.getData('text/plain'); + const dragInd = parseInt(draggedIndexStr, 10); + + if (isNaN(dragInd) || hoveredItem.i === null) { + setHoveredItem({ i: null, position: null }); + setDraggedItem(null); + return; + } + + const hoverIndex = hoveredItem.i; + const hoverPosition = hoveredItem.position || 'bottom'; + + let newPosition = calculateNewPosition(hoverIndex, hoverPosition); + + // Important: Adjust newPosition if dragging downwards past the original position + // because the removal shifts subsequent indices up. + if (dragInd < newPosition) { + newPosition--; + } + + // Only call move if the position actually changed + if (dragInd !== newPosition && !(dragInd === hoverIndex && hoverPosition === 'top') && !(dragInd === hoverIndex - 1 && hoverPosition === 'bottom')) { + handleMove(dragInd, newPosition); + } + setHoveredItem({ i: null, position: null }); setDraggedItem(null); }, - [draggedInd, calculateNewPosition, handleMove, hoveredItem.i, hoveredItem.position] + [handleMove, hoveredItem.i, hoveredItem.position, calculateNewPosition] ); + const handleDragEnd = useCallback(() => { setHoveredItem({ i: null, position: null }); setDraggedItem(null); }, []); - return ( -
+ const handleDragLeave = useCallback(() => { + // Only clear if leaving the specific item, not just moving within it + setHoveredItem({ i: null, position: null }); + }, []); + + return filters.length ? ( +
{filters.map((filterItem: any, filterIndex: number) => (
1} // Only draggable if enabled and more than one item + onDragStart={isDraggable && filters.length > 1 ? (e) => handleDragStart(e, filterIndex, `filter-${filterItem.key || filterIndex}`) : undefined} + onDragEnd={isDraggable ? handleDragEnd : undefined} onDragOver={isDraggable ? (e) => handleDragOver(e, filterIndex) : undefined} onDrop={isDraggable ? handleDrop : undefined} + onDragLeave={isDraggable ? handleDragLeave : undefined} // Clear hover effect when leaving > {isDraggable && filters.length > 1 && (
handleDragStart(e, filterIndex, `filter-${filterIndex}`)} - onDragEnd={handleDragEnd} + className="cursor-grab text-neutral-500 hover:text-neutral-700 pt-[10px] flex-shrink-0" // Align handle visually + // Draggable is set on parent div style={{ cursor: draggedInd !== null ? 'grabbing' : 'grab' }} + title="Drag to reorder" >
)} + {!isDraggable && showIndices && +
} {/* Placeholder for alignment if not draggable but indices shown */} + {!isDraggable && !showIndices && +
} {/* Placeholder for alignment if not draggable and no indices */} + + { readonly={readonly} isConditional={isConditional} hideIndex={!showIndices} + isDragging={draggedInd === filterIndex} + // Pass down if this is the first item for potential styling (e.g., no 'and'/'or' toggle) + isFirst={filterIndex === 0} />
))}
- ); + ) : null; }; export default UnifiedFilterList; diff --git a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx index f7cc3c1f3..d6612f45b 100644 --- a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx +++ b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx @@ -1,241 +1,211 @@ import cn from 'classnames'; -import { Pointer, ChevronRight, MousePointerClick } from 'lucide-react'; -import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; -import { Loader } from 'UI'; -import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import { ChevronRight, MousePointerClick, Search } from 'lucide-react'; +import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; // Assuming correct path import { Input, Space, Typography } from 'antd'; import { observer } from 'mobx-react-lite'; import { useTranslation } from 'react-i18next'; import { Filter } from '@/mstore/types/filterConstants'; -export const getIconForFilter = (filter: Filter) => ; +export const getIconForFilter = (filter: Filter) => { + return ; +}; -// Helper function for grouping filters -const groupFiltersByCategory = (filters: Filter[]) => { +const groupFiltersByCategory = (filters: Filter[]): Record => { if (!filters?.length) return {}; - return filters.reduce((acc, filter) => { - const category = filter.category - ? filter.category.charAt(0).toUpperCase() + filter.category.slice(1) - : 'Unknown'; - + const categoryKey = filter.category || 'Other'; + const category = categoryKey.charAt(0).toUpperCase() + categoryKey.slice(1); if (!acc[category]) acc[category] = []; acc[category].push(filter); return acc; - }, {}); + }, {} as Record); }; -// Optimized filtering function with early returns -const getFilteredEntries = (query: string, filters: Filter[]) => { +const getFilteredEntries = (query: string, groupedFilters: Record) => { const trimmedQuery = query.trim().toLowerCase(); - - if (!filters || Object.keys(filters).length === 0) { + if (!groupedFilters || Object.keys(groupedFilters).length === 0) { return { matchingCategories: ['All'], matchingFilters: {} }; } - + const allCategories = Object.keys(groupedFilters); if (!trimmedQuery) { - return { - matchingCategories: ['All', ...Object.keys(filters)], - matchingFilters: filters - }; + return { matchingCategories: ['All', ...allCategories], matchingFilters: groupedFilters }; } - - const matchingCategories = ['All']; - const matchingFilters = {}; - - // Single pass through the data with optimized conditionals - Object.entries(filters).forEach(([name, categoryFilters]) => { - const categoryMatch = name.toLowerCase().includes(trimmedQuery); - - if (categoryMatch) { - matchingCategories.push(name); - matchingFilters[name] = categoryFilters; - return; - } - - const filtered = categoryFilters.filter( + const matchingCategories = new Set(['All']); + const matchingFilters: Record = {}; + Object.entries(groupedFilters).forEach(([categoryName, categoryFilters]) => { + const categoryMatch = categoryName.toLowerCase().includes(trimmedQuery); + let categoryHasMatchingFilters = false; + const filteredItems = categoryFilters.filter( (filter: Filter) => filter.displayName?.toLowerCase().includes(trimmedQuery) || filter.name?.toLowerCase().includes(trimmedQuery) ); - - if (filtered.length) { - matchingCategories.push(name); - matchingFilters[name] = filtered; + if (filteredItems.length > 0) { + matchingFilters[categoryName] = filteredItems; + categoryHasMatchingFilters = true; + } + if (categoryMatch || categoryHasMatchingFilters) { + matchingCategories.add(categoryName); + if (categoryMatch && !categoryHasMatchingFilters) { + matchingFilters[categoryName] = categoryFilters; + } } }); - - return { matchingCategories, matchingFilters }; + const sortedMatchingCategories = ['All', ...allCategories.filter(cat => matchingCategories.has(cat))]; + return { matchingCategories: sortedMatchingCategories, matchingFilters }; }; -// Custom debounce hook to optimize search + const useDebounce = (value: any, delay = 300) => { const [debouncedValue, setDebouncedValue] = useState(value); - useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); - return () => clearTimeout(handler); }, [value, delay]); - return debouncedValue; }; -// Memoized filter item component +// --- Sub-Components --- const FilterItem = React.memo(({ filter, onClick, showCategory }: { filter: Filter; onClick: (filter: Filter) => void; showCategory?: boolean; }) => (
onClick(filter)} + role="button" tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onClick(filter); + }} > {showCategory && filter.category && ( -
- {filter.subCategory || filter.category} - +
+ {filter.subCategory || filter.category} +
)} - - {getIconForFilter(filter)} - - {filter.displayName || filter.name} - + + {getIconForFilter(filter)} + {filter.displayName || filter.name}
)); -// Memoized category list component const CategoryList = React.memo(({ categories, activeCategory, onSelect }: { categories: string[]; activeCategory: string; onSelect: (category: string) => void; }) => ( - <> +
{categories.map((key) => (
onSelect(key)} - className={cn( - 'rounded-xl px-4 py-2 hover:bg-active-blue capitalize cursor-pointer font-medium', - key === activeCategory && 'bg-active-blue text-teal' - )} - > - {key} -
+ className={cn('rounded px-3 py-1.5 hover:bg-active-blue/10 capitalize cursor-pointer font-medium text-sm truncate', key === activeCategory && 'bg-active-blue/10 text-teal font-semibold')} + title={key} role="button" tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onSelect(key); + }} + >{key}
))} - +
)); -function FilterModal({ onFilterClick = () => null, filters = [], isMainSearch = false }) { +function FilterModal({ onFilterClick = () => null, filters = [] }: { + onFilterClick: (filter: Filter) => void; + filters: Filter[]; +}) { const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(''); const debouncedQuery = useDebounce(searchQuery); const [category, setCategory] = useState('All'); - const [isLoading, setIsLoading] = useState(false); - const inputRef = useRef(null); - - // Memoize expensive computations - const groupedFilters = useMemo(() => - groupFiltersByCategory(filters), - [filters] - ); - - const { matchingCategories, matchingFilters } = useMemo( - () => getFilteredEntries(debouncedQuery, groupedFilters), - [debouncedQuery, groupedFilters] - ); + const inputRef = useRef(null); + const groupedFilters = useMemo(() => groupFiltersByCategory(filters), [filters]); + const { + matchingCategories, + matchingFilters + } = useMemo(() => getFilteredEntries(debouncedQuery, groupedFilters), [debouncedQuery, groupedFilters]); const displayedFilters = useMemo(() => { if (category === 'All') { - return Object.entries(matchingFilters).flatMap(([cat, filters]) => - filters.map((filter) => ({ ...filter, category: cat })) - ); + return matchingCategories.filter(cat => cat !== 'All').flatMap(cat => (matchingFilters[cat] || []).map(filter => ({ + ...filter, + category: cat + }))); } return matchingFilters[category] || []; - }, [category, matchingFilters]); + }, [category, matchingFilters, matchingCategories]); + const isResultEmpty = useMemo(() => matchingCategories.length <= 1 && Object.keys(matchingFilters).length === 0, [matchingCategories, matchingFilters]); - const isResultEmpty = useMemo( - () => matchingCategories.length <= 1 && Object.keys(matchingFilters).length === 0, - [matchingCategories.length, matchingFilters] - ); - - // Memoize handlers - const handleFilterClick = useCallback( - (filter: Filter) => onFilterClick({ ...filter, operator: 'is' }), - [onFilterClick] - ); - - const handleCategoryClick = useCallback( - (cat: string) => setCategory(cat), - [] - ); - - // Focus input only when necessary - useEffect(() => { - inputRef.current?.focus(); - }, [category]); - - if (isLoading) { - return ( -
-
- -
-
- ); - } + const handleFilterClick = useCallback((filter: Filter) => { + onFilterClick(filter); + }, [onFilterClick]); + const handleCategoryClick = useCallback((cat: string) => { + setCategory(cat); + }, []); return ( -
- setSearchQuery(e.target.value)} - autoFocus - /> +
- {isResultEmpty ? ( -
- -
{t('No matching filters.')}
-
- ) : ( -
-
- +
+ setSearchQuery(e.target.value)} + autoFocus allowClear + prefix={} + className="mb-3 rounded-lg" + /> +
+ +
+ {isResultEmpty ? ( +
+ +
{t('No results found')}
+ {t('Try different keywords')}
-
- {displayedFilters.length > 0 ? ( - displayedFilters.map((filter: Filter, index: number) => ( - - )) - ) : ( -
-
{t('No filters in this category')}
+ ) : ( +
+ +
+ +
+ +
+
+ {displayedFilters.length > 0 ? ( + displayedFilters.map((filter: Filter) => ( + + )) + ) : ( + category !== 'All' && ( +
+ {t('No filters in category', { categoryName: category })} +
+ ) + )}
- )} +
+
-
- )} + )} +
); } diff --git a/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx index 86aa80e6b..d588593d1 100644 --- a/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx +++ b/frontend/app/components/shared/Filters/FilterOperator/FilterOperator.tsx @@ -30,7 +30,7 @@ function FilterOperator(props: Props) { onChange={({ value }: any) => onChange(null, { name: 'operator', value: value.value }) } - className="btn-event-operator" + className="" />
); diff --git a/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx b/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx index 53e13f3bd..bad90acab 100644 --- a/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx +++ b/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx @@ -1,5 +1,7 @@ import React, { useState, useCallback } from 'react'; -import { Popover } from 'antd'; +// Import Spin and potentially classnames +import { Popover, Spin } from 'antd'; +import cn from 'classnames'; import { observer } from 'mobx-react-lite'; import FilterModal from '../FilterModal/FilterModal'; import { Filter } from '@/mstore/types/filterConstants'; @@ -9,7 +11,8 @@ interface FilterSelectionProps { onFilterClick: (filter: Filter) => void; children?: React.ReactNode; disabled?: boolean; - isLive?: boolean; + isLive?: boolean; // This prop seems unused, consider removing if not needed downstream + loading?: boolean; // <-- Add loading prop } const FilterSelection: React.FC = observer(({ @@ -17,41 +20,67 @@ const FilterSelection: React.FC = observer(({ onFilterClick, children, disabled = false, - isLive + isLive, + loading = false // <-- Initialize loading prop }) => { const [open, setOpen] = useState(false); const handleFilterClick = useCallback((selectedFilter: Filter) => { + // Don't do anything if loading - though modal shouldn't be clickable then anyway + if (loading) return; onFilterClick(selectedFilter); setOpen(false); - }, [onFilterClick]); + }, [onFilterClick, loading]); const handleOpenChange = useCallback((newOpen: boolean) => { - if (!disabled) { + // Prevent opening if disabled or loading + if (!disabled && !loading) { + setOpen(newOpen); + } else if (!newOpen) { + // Allow closing even if disabled/loading (e.g., clicking outside) setOpen(newOpen); } - }, [disabled]); + }, [disabled, loading]); + // Determine the content for the Popover const content = ( - + loading + // Show a spinner centered in the popover content area while loading + ?
+ +
+ // Otherwise, show the filter modal + : ); + // Combine disabled and loading states for the trigger element + const isDisabled = disabled || loading; + + // Clone the trigger element (children) and pass the effective disabled state + // Also add loading class for potential styling (e.g., opacity) const triggerElement = React.isValidElement(children) - ? React.cloneElement(children, { disabled }) + ? React.cloneElement(children as React.ReactElement, { + disabled: isDisabled, + className: cn(children.props.className, { 'opacity-70 cursor-not-allowed': loading }) // Example styling + }) : children; return ( -
+ // Add a class to the wrapper if needed, e.g., for opacity when loading +
diff --git a/frontend/app/components/shared/SessionFilters/SessionFilters.tsx b/frontend/app/components/shared/SessionFilters/SessionFilters.tsx index 637c9a845..9ec0e872b 100644 --- a/frontend/app/components/shared/SessionFilters/SessionFilters.tsx +++ b/frontend/app/components/shared/SessionFilters/SessionFilters.tsx @@ -40,12 +40,12 @@ function SessionFilters() {
i.isEvent).length > 0} orderProps={searchInstance} onChangeOrder={onChangeEventsOrder} filterSelection={ @@ -70,6 +70,7 @@ function SessionFilters() { filters={searchInstance.filters.filter(i => i.isEvent)} isDraggable={true} showIndices={true} + className="mt-2" handleRemove={function(key: string): void { searchStore.removeFilter(key); }} @@ -107,6 +108,7 @@ function SessionFilters() { !i.isEvent)} + className="mt-2" isDraggable={false} showIndices={false} handleRemove={function(key: string): void { diff --git a/frontend/app/mstore/filterStore.ts b/frontend/app/mstore/filterStore.ts index f4a181aa1..9606953d3 100644 --- a/frontend/app/mstore/filterStore.ts +++ b/frontend/app/mstore/filterStore.ts @@ -95,7 +95,7 @@ export default class FilterStore { possibleTypes: filter.possibleTypes?.map(type => type.toLowerCase()) || [], type: filter.possibleTypes?.[0].toLowerCase() || 'string', category: category || 'custom', - subCategory: category === 'events' ? (filter.autoCaptured ? 'auto' : 'user') : category, + subCategory: category === 'events' ? (filter.autoCaptured ? 'autocapture' : 'user') : category, displayName: filter.displayName || filter.name, icon: FilterKey.LOCATION, // TODO - use actual icons isEvent: category === 'events', diff --git a/frontend/package.json b/frontend/package.json index d7f9c3386..69b354b3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -58,7 +58,7 @@ "js-untar": "^2.0.0", "jspdf": "^3.0.1", "lottie-react": "^2.4.1", - "lucide-react": "0.454.0", + "lucide-react": "0.487.0", "luxon": "^3.6.1", "microdiff": "^1.5.0", "mobx": "^6.13.7",