feat(ui): dynamic fitlers - ui aligments and other improvements

This commit is contained in:
Shekar Siri 2025-04-03 18:16:47 +02:00
parent 7cf4f50b36
commit a6fa45041f
9 changed files with 431 additions and 323 deletions

View file

@ -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<Filter[]>([]);
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 (
<div className="w-full">
<div className="flex items-center w-full">
<div className="flex items-center flex-grow flex-wrap">
{!isFilter && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && (
<div
className="flex-shrink-0 w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-lighter mr-2">
<span>{filterIndex + 1}</span>
</div>
)}
<div className={cn('w-full', isDragging ? 'opacity-50' : '')}>
<div className="flex items-start w-full gap-x-2"> {/* Use items-start */}
{isSubItem && (
<div className="w-14 text-right">
{subFilterIndex === 0 && (
<Typography.Text className="text-neutral-500/90 mr-2">
where
</Typography.Text>
)}
{subFilterIndex != 0 && propertyOrder && onToggleOperator && (
<Typography.Text
className="text-neutral-500/90 mr-2 cursor-pointer"
onClick={() =>
onToggleOperator(propertyOrder === 'and' ? 'or' : 'and')
}
>
{propertyOrder}
</Typography.Text>
)}
</div>
)}
{!isSubItem && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && (
<div
className="flex-shrink-0 w-6 h-6 mt-[7px] text-xs flex items-center justify-center rounded-full bg-gray-lightest text-gray-600 font-medium"> {/* Align index top */}
<span>{filterIndex + 1}</span>
</div>
)}
{isSubItem && (
<div
className="flex-shrink-0 w-14 text-right text-sm text-neutral-500/90 pr-2 pt-[5px]"> {/* Align where/and top */}
{subFilterIndex === 0 && (
<Typography.Text className="text-inherit">
where
</Typography.Text>
)}
{subFilterIndex !== 0 && propertyOrder && onToggleOperator && (
<Typography.Text
className={cn(
'text-inherit',
!readonly && 'cursor-pointer hover:text-main transition-colors'
)}
onClick={() =>
!readonly && onToggleOperator(propertyOrder === 'and' ? 'or' : 'and')
}
>
{propertyOrder}
</Typography.Text>
)}
</div>
)}
{/* Main content area */}
<div
className="flex flex-grow flex-wrap gap-x-1 items-center"> {/* Use baseline inside here */}
<FilterSelection
filters={filterSelections}
onFilterClick={replaceFilter}
disabled={disableDelete || readonly}
loading={isSubItem ? false : eventFiltersLoading}
>
<Space
className={cn(
'rounded-lg py-1 px-2 cursor-pointer bg-white border border-gray-light text-ellipsis hover:border-neutral-400 btn-select-event',
{ 'opacity-50 pointer-events-none': disableDelete || readonly }
'rounded-lg px-2 cursor-pointer bg-white border border-gray-light text-ellipsis hover:border-main',
'transition-colors duration-100 flex-shrink-0 max-w-xs h-[26px] items-center gap-1', // Fixed height, ensure items-center
{ 'opacity-70 pointer-events-none': disableDelete || readonly }
)}
style={{ height: '26px' }}
// style={{ lineHeight: '1rem' }}
>
<div className="text-xs">
<div className="text-gray-600 flex-shrink-0">
{filter && getIconForFilter(filter)}
</div>
<div className="text-neutral-500/90 capitalize">
{`${filter?.subCategory ? filter.subCategory : filter?.category}`}
</div>
<span className="text-neutral-500/90"></span>
{(filter?.subCategory || filter?.category) && (
<div className="text-neutral-500/90 capitalize text-sm truncate">
{`${filter?.subCategory ? filter.subCategory : filter?.category}`}
</div>
)}
{(filter?.subCategory || filter?.category) && (filter.displayName || filter.name) &&
<span className="text-neutral-400 mx-1"></span>
}
<div
className="rounded-lg overflow-hidden whitespace-nowrap text-ellipsis mr-auto truncate"
style={{ textOverflow: 'ellipsis' }}
className="text-sm text-black truncate"
>
{filter.displayName || filter.name}
{filter.displayName || filter.name || 'Select Filter'}
</div>
</Space>
</FilterSelection>
<div
className={cn(
'flex items-center flex-wrap',
isReversed ? 'flex-row-reverse ml-2' : 'flex-row'
'flex items-center flex-wrap gap-x-2 gap-y-1', // Use baseline inside here
isReversed ? 'flex-row-reverse' : 'flex-row'
)}
>
{filter.hasSource && (
@ -225,7 +265,6 @@ function FilterItem(props: Props) {
<FilterOperator
options={filter.sourceOperatorOptions}
onChange={handleSourceOperatorChange}
className="mx-2 flex-shrink-0 btn-event-operator"
value={filter.sourceOperator}
isDisabled={filter.operatorDisabled || readonly}
/>
@ -233,89 +272,108 @@ function FilterItem(props: Props) {
</>
)}
{operatorOptions.length && (
{operatorOptions.length > 0 && filter.type && (
<>
<FilterOperator
options={operatorOptions}
onChange={handleOperatorChange}
className="mx-2 flex-shrink-0 btn-sub-event-operator"
value={filter.operator}
isDisabled={filter.operatorDisabled || readonly}
/>
{canShowValues &&
(readonly ? (
<div
className="rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip hover:border-neutral-400">
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(', ')}
</div>
) : (
<FilterValue isConditional={isConditional} filter={filter} onUpdate={onUpdate} />
<div className="inline-flex"> {/* Wrap FilterValue */}
<FilterValue isConditional={isConditional} filter={filter} onUpdate={onUpdate} />
</div>
))}
</>
)}
</div>
</div>
{!readonly && !hideDelete && (
<div className="flex flex-shrink-0 gap-2">
{filter.isEvent && !isSubItem && (
<FilterSelection
filters={eventFilterOptions}
onFilterClick={addSubFilter}
disabled={disableDelete || readonly}
disabled={disableDelete || readonly || eventFiltersLoading}
loading={eventFiltersLoading}
>
<Button
type="text"
icon={<FilterIcon size={13} />}
size="small"
aria-label="Add filter"
title="Filter"
/>
<Tooltip title="Add filter condition" mouseEnterDelay={1}>
<Button
type="text"
icon={<FunnelPlus size={14} className="text-gray-600" />}
size="small"
className="h-[26px] w-[26px] flex items-center justify-center" // Fixed size button
/>
</Tooltip>
</FilterSelection>
)}
</div>
</div>
<Button
type="text"
icon={<CircleMinus size={13} />}
disabled={disableDelete}
onClick={onRemoveFilter}
size="small"
aria-label="Remove filter"
/>
{/* Action Buttons */}
{!readonly && !hideDelete && (
<div className="flex flex-shrink-0 gap-1 items-center self-start mt-[1px]"> {/* Align top */}
<Tooltip title={isSubItem ? 'Remove filter condition' : 'Remove filter'} mouseEnterDelay={1}>
<Button
type="text"
icon={<CircleMinus size={14} />}
disabled={disableDelete}
onClick={onRemoveFilter}
size="small"
className="h-[26px] w-[26px] flex items-center justify-center" // Fixed size button
/>
</Tooltip>
</div>
)}
</div>
{filter.filters?.length > 0 && (
<div className="pl-8 w-full">
{/* Sub-Filter Rendering */}
{filteredSubFilters.length > 0 && (
<div
className={cn(
'relative w-full mt-1' // Relative parent for border
)}
>
{/* Dashed line */}
<div className={cn(
'absolute top-0 bottom-0 left-1 w-px',
'border-l border-dashed border-gray-300',
subFilterMarginLeftClass // Dynamic margin based on parent index visibility
)} style={{ height: 'calc(100% - 4px)' }} />
{filteredSubFilters.map((subFilter: any, index: number) => (
<FilterItem
key={`subfilter-${index}`}
filter={subFilter}
subFilterIndex={index}
onUpdate={(updatedSubFilter) => 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 })
}
/>
<div
key={`subfilter-wrapper-${filter.id || filterIndex}-${subFilter.key || index}`}
className={cn('relative', subFilterPaddingLeftClass)} // Apply padding to the wrapper, keep relative
>
<FilterItem
filter={subFilter}
subFilterIndex={index}
onUpdate={(updatedSubFilter) => 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
/>
</div>
))}
</div>
)}

View file

@ -20,7 +20,7 @@ const FilterListHeader = ({
actions = []
}: FilterListHeaderProps) => {
return (
<div className="flex items-center mb-2 gap-2">
<div className="flex items-center gap-2">
<Space>
<div className="font-medium">{title}</div>
<Typography.Text>{filterSelection}</Typography.Text>

View file

@ -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 (
<div className="flex flex-col" style={style}>
const handleDragLeave = useCallback(() => {
// Only clear if leaving the specific item, not just moving within it
setHoveredItem({ i: null, position: null });
}, []);
return filters.length ? (
<div className={cn('flex flex-col', className)} style={style}>
{filters.map((filterItem: any, filterIndex: number) => (
<div
key={`filter-${filterIndex}`}
className={cn('hover:bg-active-blue px-5 pe-3 gap-2 items-center flex', {
'bg-[#f6f6f6]': hoveredItem.i === filterIndex
key={`filter-${filterItem.key || filterIndex}`}
className={cn('flex gap-2 items-start hover:bg-active-blue/5 -mx-5 px-5 pe-3 transition-colors duration-100 relative', { // Lighter hover, keep relative
'opacity-50': draggedInd === filterIndex,
// Add top/bottom borders based on hover state for visual feedback
'border-t-2 border-dashed border-teal': hoveredItem.i === filterIndex && hoveredItem.position === 'top',
'border-b-2 border-dashed border-teal': hoveredItem.i === filterIndex && hoveredItem.position === 'bottom',
// Add negative margin to compensate for border height only when border is visible
'-mt-0.5': hoveredItem.i === filterIndex && hoveredItem.position === 'top',
'-mb-0.5': hoveredItem.i === filterIndex && hoveredItem.position === 'bottom'
})}
style={{
marginLeft: '-1rem',
width: 'calc(100% + 2rem)',
alignItems: 'start',
borderTop: hoveredItem.i === filterIndex && hoveredItem.position === 'top' ? '1px dashed #888' : undefined,
borderBottom: hoveredItem.i === filterIndex && hoveredItem.position === 'bottom' ? '1px dashed #888' : undefined
}}
id={`filter-${filterItem.key}`}
id={`filter-${filterItem.key || filterIndex}`}
draggable={isDraggable && filters.length > 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 && (
<div
className="cursor-grab text-neutral-500/90 hover:bg-white px-1 mt-2.5 rounded-lg"
draggable={true}
onDragStart={(e) => 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"
>
<GripVertical size={16} />
</div>
)}
{!isDraggable && showIndices &&
<div className="w-4 flex-shrink-0" />} {/* Placeholder for alignment if not draggable but indices shown */}
{!isDraggable && !showIndices &&
<div className="w-4 flex-shrink-0" />} {/* Placeholder for alignment if not draggable and no indices */}
<FilterItem
filterIndex={showIndices ? filterIndex : undefined}
filter={filterItem}
@ -155,11 +201,14 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
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}
/>
</div>
))}
</div>
);
) : null;
};
export default UnifiedFilterList;

View file

@ -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) => <MousePointerClick size={14} />;
export const getIconForFilter = (filter: Filter) => {
return <MousePointerClick size={14} className="text-gray-400" />;
};
// Helper function for grouping filters
const groupFiltersByCategory = (filters: Filter[]) => {
const groupFiltersByCategory = (filters: Filter[]): Record<string, Filter[]> => {
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<string, Filter[]>);
};
// Optimized filtering function with early returns
const getFilteredEntries = (query: string, filters: Filter[]) => {
const getFilteredEntries = (query: string, groupedFilters: Record<string, Filter[]>) => {
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<string>(['All']);
const matchingFilters: Record<string, Filter[]> = {};
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;
}) => (
<div
className="flex items-center flex-shrink-0 p-2 cursor-pointer gap-1 rounded-lg hover:bg-active-blue"
className="flex items-center p-2 cursor-pointer gap-2 rounded-lg hover:bg-active-blue/10 text-sm"
onClick={() => onClick(filter)}
role="button" tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick(filter);
}}
>
{showCategory && filter.category && (
<div style={{ width: 100 }} className="text-neutral-500/90 flex justify-between items-center">
<span className="capitalize">{filter.subCategory || filter.category}</span>
<ChevronRight size={14} />
<div style={{ width: 110 }}
className="text-neutral-500 flex items-center justify-between flex-shrink-0 mr-1 text-xs">
<Typography.Text ellipsis={{ tooltip: true }}
className="capitalize flex-1 text-gray-600">{filter.subCategory || filter.category}</Typography.Text>
<ChevronRight size={14} className="ml-1 text-gray-400" />
</div>
)}
<Space className="flex-1 min-w-0">
<span className="text-neutral-500/90 text-xs">{getIconForFilter(filter)}</span>
<Typography.Text
ellipsis={{ tooltip: true }}
className="max-w-full"
style={{ display: 'block' }}
>
{filter.displayName || filter.name}
</Typography.Text>
<Space className="flex-grow min-w-0 items-center">
<span className="text-neutral-500/90 text-xs flex items-center">{getIconForFilter(filter)}</span>
<Typography.Text ellipsis={{ tooltip: filter.displayName || filter.name }}
className="flex-1">{filter.displayName || filter.name}</Typography.Text>
</Space>
</div>
));
// Memoized category list component
const CategoryList = React.memo(({ categories, activeCategory, onSelect }: {
categories: string[];
activeCategory: string;
onSelect: (category: string) => void;
}) => (
<>
<div className="flex flex-col gap-1">
{categories.map((key) => (
<div
key={key}
onClick={() => 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}
</div>
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}</div>
))}
</>
</div>
));
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<HTMLInputElement>(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 (
<div style={{ width: '490px', maxHeight: '380px' }}>
<div className="flex items-center justify-center h-60">
<Loader loading />
</div>
</div>
);
}
const handleFilterClick = useCallback((filter: Filter) => {
onFilterClick(filter);
}, [onFilterClick]);
const handleCategoryClick = useCallback((cat: string) => {
setCategory(cat);
}, []);
return (
<div style={{ width: '490px', maxHeight: '380px' }}>
<Input
ref={inputRef}
className="mb-4 rounded-xl text-lg font-medium placeholder:text-lg placeholder:font-medium placeholder:text-neutral-300"
placeholder="Search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
<div className="w-[490px] max-h-[380px] grid grid-rows-[auto_1fr] overflow-hidden bg-white">
{isResultEmpty ? (
<div className="flex items-center flex-col justify-center h-60">
<AnimatedSVG name={ICONS.NO_SEARCH_RESULTS} size={30} />
<div className="font-medium px-3 mt-4">{t('No matching filters.')}</div>
</div>
) : (
<div className="flex gap-2 items-start">
<div className="flex flex-col gap-1 min-w-40">
<CategoryList
categories={matchingCategories}
activeCategory={category}
onSelect={handleCategoryClick}
/>
<div className="">
<Input
ref={inputRef} placeholder={t('Search')} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus allowClear
prefix={<Search size={16} className="text-gray-400 mr-1" />}
className="mb-3 rounded-lg"
/>
</div>
<div className="overflow-hidden min-h-0">
{isResultEmpty ? (
<div className="h-full flex items-center flex-col justify-center text-center">
<AnimatedSVG name={ICONS.NO_SEARCH_RESULTS} size={30} />
<div className="font-medium mt-4 text-neutral-600">{t('No results found')}</div>
<Typography.Text type="secondary" className="text-sm">{t('Try different keywords')}</Typography.Text>
</div>
<div className="flex flex-col gap-1 overflow-y-auto w-full" style={{ maxHeight: 300, flex: 2 }}>
{displayedFilters.length > 0 ? (
displayedFilters.map((filter: Filter, index: number) => (
<FilterItem
key={`${filter.name}-${index}`}
filter={filter}
onClick={handleFilterClick}
showCategory={category === 'All'}
/>
))
) : (
<div className="flex items-center justify-center h-40">
<div className="text-neutral-500">{t('No filters in this category')}</div>
) : (
<div className="flex gap-2 h-full">
<div className="w-36 flex-shrink-0 border-r border-gray-200 pr-2 h-full overflow-y-auto">
<CategoryList
categories={matchingCategories}
activeCategory={category}
onSelect={handleCategoryClick}
/>
</div>
<div className="flex-grow min-w-0 h-full overflow-y-auto">
<div className="flex flex-col gap-0.5">
{displayedFilters.length > 0 ? (
displayedFilters.map((filter: Filter) => (
<FilterItem
key={filter.id || filter.name}
filter={filter}
onClick={handleFilterClick}
showCategory={true} // TODO: Show category based condition
/>
))
) : (
category !== 'All' && (
<div className="flex items-center justify-center h-full text-neutral-500 text-sm p-4 text-center">
{t('No filters in category', { categoryName: category })}
</div>
)
)}
</div>
)}
</div>
</div>
</div>
)}
)}
</div>
</div>
);
}

View file

@ -30,7 +30,7 @@ function FilterOperator(props: Props) {
onChange={({ value }: any) =>
onChange(null, { name: 'operator', value: value.value })
}
className="btn-event-operator"
className=""
/>
</div>
);

View file

@ -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<FilterSelectionProps> = observer(({
@ -17,41 +20,67 @@ const FilterSelection: React.FC<FilterSelectionProps> = 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 = (
<FilterModal
onFilterClick={handleFilterClick}
filters={filters}
/>
loading
// Show a spinner centered in the popover content area while loading
? <div className="p-4 flex justify-center items-center" style={{ minHeight: '100px', minWidth: '150px' }}>
<Spin />
</div>
// Otherwise, show the filter modal
: <FilterModal
onFilterClick={handleFilterClick}
filters={filters}
// If FilterModal needs to know about the live status, pass it down:
// isLive={isLive}
/>
);
// 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<any>, {
disabled: isDisabled,
className: cn(children.props.className, { 'opacity-70 cursor-not-allowed': loading }) // Example styling
})
: children;
return (
<div className="relative flex-shrink-0">
// Add a class to the wrapper if needed, e.g., for opacity when loading
<div className={cn('relative flex-shrink-0')}>
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={handleOpenChange}
placement="bottomLeft"
overlayClassName="filter-selection-popover rounded-lg border border-gray-200 shadow-sm shadow-gray-200"
// Consistent styling class name with your original
overlayClassName="filter-selection-popover rounded-lg border border-gray-200 shadow-sm shadow-gray-200 overflow-hidden"
destroyTooltipOnHide
arrow={false}
>

View file

@ -40,12 +40,12 @@ function SessionFilters() {
<div
className={cn(
'bg-white',
'py-2 px-4 rounded-xl border border-gray-lighter'
'py-2 px-4 rounded-xl border border-gray-lighter pt-4'
)}
>
<FilterListHeader
title={'Events'}
showEventsOrder={true}
showEventsOrder={searchInstance.filters.filter(i => 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() {
<UnifiedFilterList
title="Filters"
filters={searchInstance.filters.filter(i => !i.isEvent)}
className="mt-2"
isDraggable={false}
showIndices={false}
handleRemove={function(key: string): void {

View file

@ -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',

View file

@ -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",