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 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 { 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 cn from 'classnames';
import FilterOperator from '../FilterOperator'; import FilterOperator from '../FilterOperator';
import FilterSelection from '../FilterSelection'; import FilterSelection from '../FilterSelection';
@ -27,11 +27,13 @@ interface Props {
subFilterIndex?: number; subFilterIndex?: number;
propertyOrder?: string; propertyOrder?: string;
onToggleOperator?: (newOp: string) => void; onToggleOperator?: (newOp: string) => void;
parentEventFilterOptions?: Filter[];
isDragging?: boolean;
isFirst?: boolean;
} }
function FilterItem(props: Props) { function FilterItem(props: Props) {
const { const {
isFilter = false,
filterIndex, filterIndex,
filter, filter,
saveRequestPayloads, saveRequestPayloads,
@ -43,35 +45,52 @@ function FilterItem(props: Props) {
onRemoveFilter, onRemoveFilter,
readonly, readonly,
isSubItem = false, isSubItem = false,
subFilterIndex, subFilterIndex = 0, // Default to 0
propertyOrder, propertyOrder,
onToggleOperator onToggleOperator,
parentEventFilterOptions,
isDragging,
isFirst = false // Default to false
} = props; } = props;
const [eventFilterOptions, setEventFilterOptions] = useState<Filter[]>([]); const [eventFilterOptions, setEventFilterOptions] = useState<Filter[]>([]);
const [eventFiltersLoading, setEventFiltersLoading] = useState(false);
const { filterStore } = useStore(); const { filterStore } = useStore();
const allFilters = filterStore.getCurrentProjectFilters(); const allFilters = filterStore.getCurrentProjectFilters();
const eventSelections = allFilters.filter((i) => i.isEvent === filter.isEvent); 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); const operatorOptions = getOperatorsByType(filter.type);
useEffect(() => { useEffect(() => {
async function loadFilters() { async function loadFilters() {
try { if (!isSubItem && filter.isEvent && filter.name) {
setEventFiltersLoading(true); try {
const options = await filterStore.getEventFilters(filter.name); setEventFiltersLoading(true);
setEventFilterOptions(options); const options = await filterStore.getEventFilters(filter.name);
} finally { setEventFilterOptions(options);
setEventFiltersLoading(false); } catch (error) {
console.error('Failed to load event filters:', error);
setEventFilterOptions([]);
} finally {
setEventFiltersLoading(false);
}
} else {
if (eventFilterOptions.length > 0) {
setEventFilterOptions([]);
}
} }
} }
void loadFilters(); void loadFilters();
}, [filter.name]); // Re-fetch when filter name changes }, [filter.name, filter.isEvent, isSubItem, filterStore]);
const canShowValues = useMemo( const canShowValues = useMemo(
() => () =>
@ -89,10 +108,11 @@ function FilterItem(props: Props) {
(selectedFilter: any) => { (selectedFilter: any) => {
onUpdate({ onUpdate({
...selectedFilter, ...selectedFilter,
value: selectedFilter.value, value: selectedFilter.value || [''],
filters: selectedFilter.filters filters: selectedFilter.filters
? selectedFilter.filters.map((i: any) => ({ ...i, value: [''] })) ? selectedFilter.filters.map((i: any) => ({ ...i, value: [''] }))
: [] : [],
operator: selectedFilter.operator // Ensure operator is carried over or reset if needed
}); });
}, },
[onUpdate] [onUpdate]
@ -146,78 +166,98 @@ function FilterItem(props: Props) {
const addSubFilter = useCallback( const addSubFilter = useCallback(
(selectedFilter: any) => { (selectedFilter: any) => {
const newSubFilter = {
...selectedFilter,
value: selectedFilter.value || [''],
operator: selectedFilter.operator || getOperatorsByType(selectedFilter.type)[0]?.value // Default operator
};
onUpdate({ onUpdate({
...filter, ...filter,
filters: [...filteredSubFilters, selectedFilter] filters: [...(filter.filters || []), newSubFilter]
}); });
}, },
[filter, onUpdate] [filter, onUpdate]
); );
const parentShowsIndex = !hideIndex;
const subFilterMarginLeftClass = parentShowsIndex ? 'ml-[1.75rem]' : 'ml-[0.75rem]';
const subFilterPaddingLeftClass = parentShowsIndex ? 'pl-11' : 'pl-7';
return ( return (
<div className="w-full"> <div className={cn('w-full', isDragging ? 'opacity-50' : '')}>
<div className="flex items-center w-full"> <div className="flex items-start w-full gap-x-2"> {/* Use items-start */}
<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>
)}
{isSubItem && ( {!isSubItem && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && (
<div className="w-14 text-right"> <div
{subFilterIndex === 0 && ( 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 */}
<Typography.Text className="text-neutral-500/90 mr-2"> <span>{filterIndex + 1}</span>
where </div>
</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 && (
<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 <FilterSelection
filters={filterSelections} filters={filterSelections}
onFilterClick={replaceFilter} onFilterClick={replaceFilter}
disabled={disableDelete || readonly} disabled={disableDelete || readonly}
loading={isSubItem ? false : eventFiltersLoading}
> >
<Space <Space
className={cn( 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', 'rounded-lg px-2 cursor-pointer bg-white border border-gray-light text-ellipsis hover:border-main',
{ 'opacity-50 pointer-events-none': disableDelete || readonly } '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)} {filter && getIconForFilter(filter)}
</div> </div>
<div className="text-neutral-500/90 capitalize"> {(filter?.subCategory || filter?.category) && (
{`${filter?.subCategory ? filter.subCategory : filter?.category}`} <div className="text-neutral-500/90 capitalize text-sm truncate">
</div> {`${filter?.subCategory ? filter.subCategory : filter?.category}`}
<span className="text-neutral-500/90"></span> </div>
)}
{(filter?.subCategory || filter?.category) && (filter.displayName || filter.name) &&
<span className="text-neutral-400 mx-1"></span>
}
<div <div
className="rounded-lg overflow-hidden whitespace-nowrap text-ellipsis mr-auto truncate" className="text-sm text-black truncate"
style={{ textOverflow: 'ellipsis' }}
> >
{filter.displayName || filter.name} {filter.displayName || filter.name || 'Select Filter'}
</div> </div>
</Space> </Space>
</FilterSelection> </FilterSelection>
<div <div
className={cn( className={cn(
'flex items-center flex-wrap', 'flex items-center flex-wrap gap-x-2 gap-y-1', // Use baseline inside here
isReversed ? 'flex-row-reverse ml-2' : 'flex-row' isReversed ? 'flex-row-reverse' : 'flex-row'
)} )}
> >
{filter.hasSource && ( {filter.hasSource && (
@ -225,7 +265,6 @@ function FilterItem(props: Props) {
<FilterOperator <FilterOperator
options={filter.sourceOperatorOptions} options={filter.sourceOperatorOptions}
onChange={handleSourceOperatorChange} onChange={handleSourceOperatorChange}
className="mx-2 flex-shrink-0 btn-event-operator"
value={filter.sourceOperator} value={filter.sourceOperator}
isDisabled={filter.operatorDisabled || readonly} isDisabled={filter.operatorDisabled || readonly}
/> />
@ -233,89 +272,108 @@ function FilterItem(props: Props) {
</> </>
)} )}
{operatorOptions.length && ( {operatorOptions.length > 0 && filter.type && (
<> <>
<FilterOperator <FilterOperator
options={operatorOptions} options={operatorOptions}
onChange={handleOperatorChange} onChange={handleOperatorChange}
className="mx-2 flex-shrink-0 btn-sub-event-operator"
value={filter.operator} value={filter.operator}
isDisabled={filter.operatorDisabled || readonly} isDisabled={filter.operatorDisabled || readonly}
/> />
{canShowValues && {canShowValues &&
(readonly ? ( (readonly ? (
<div <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 {filter.value
.map((val: string) => .map((val: string) =>
filter.options && filter.options.length filter.options?.find((i: any) => i.value === val)?.label ?? val
? filter.options[
filter.options.findIndex((i: any) => i.value === val)
]?.label ?? val
: val
) )
.join(', ')} .join(', ')}
</div> </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 && ( {filter.isEvent && !isSubItem && (
<FilterSelection <FilterSelection
filters={eventFilterOptions} filters={eventFilterOptions}
onFilterClick={addSubFilter} onFilterClick={addSubFilter}
disabled={disableDelete || readonly} disabled={disableDelete || readonly || eventFiltersLoading}
loading={eventFiltersLoading}
> >
<Button <Tooltip title="Add filter condition" mouseEnterDelay={1}>
type="text" <Button
icon={<FilterIcon size={13} />} type="text"
size="small" icon={<FunnelPlus size={14} className="text-gray-600" />}
aria-label="Add filter" size="small"
title="Filter" className="h-[26px] w-[26px] flex items-center justify-center" // Fixed size button
/> />
</Tooltip>
</FilterSelection> </FilterSelection>
)} )}
</div>
</div>
<Button {/* Action Buttons */}
type="text" {!readonly && !hideDelete && (
icon={<CircleMinus size={13} />} <div className="flex flex-shrink-0 gap-1 items-center self-start mt-[1px]"> {/* Align top */}
disabled={disableDelete} <Tooltip title={isSubItem ? 'Remove filter condition' : 'Remove filter'} mouseEnterDelay={1}>
onClick={onRemoveFilter} <Button
size="small" type="text"
aria-label="Remove filter" 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>
)} )}
</div> </div>
{filter.filters?.length > 0 && ( {/* Sub-Filter Rendering */}
<div className="pl-8 w-full"> {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) => ( {filteredSubFilters.map((subFilter: any, index: number) => (
<FilterItem <div
key={`subfilter-${index}`} key={`subfilter-wrapper-${filter.id || filterIndex}-${subFilter.key || index}`}
filter={subFilter} className={cn('relative', subFilterPaddingLeftClass)} // Apply padding to the wrapper, keep relative
subFilterIndex={index} >
onUpdate={(updatedSubFilter) => handleUpdateSubFilter(updatedSubFilter, index)} <FilterItem
onRemoveFilter={() => handleRemoveSubFilter(index)} filter={subFilter}
isFilter={isFilter} subFilterIndex={index}
saveRequestPayloads={saveRequestPayloads} onUpdate={(updatedSubFilter) => handleUpdateSubFilter(updatedSubFilter, index)}
disableDelete={disableDelete} onRemoveFilter={() => handleRemoveSubFilter(index)}
readonly={readonly} saveRequestPayloads={saveRequestPayloads}
hideIndex={hideIndex} disableDelete={disableDelete}
hideDelete={hideDelete} readonly={readonly}
isConditional={isConditional} hideIndex={true} // Sub-items always hide index
isSubItem={true} hideDelete={hideDelete}
propertyOrder={filter.propertyOrder || 'and'} isConditional={isConditional}
onToggleOperator={(newOp) => isSubItem={true}
onUpdate({ ...filter, propertyOrder: newOp }) 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> </div>
)} )}

View file

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

View file

@ -2,7 +2,6 @@ import { GripVertical } from 'lucide-react';
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import cn from 'classnames'; import cn from 'classnames';
import FilterItem from '../FilterItem'; import FilterItem from '../FilterItem';
import { useTranslation } from 'react-i18next';
import { Filter } from '@/mstore/types/filterConstants'; import { Filter } from '@/mstore/types/filterConstants';
interface UnifiedFilterListProps { interface UnifiedFilterListProps {
@ -31,7 +30,6 @@ interface UnifiedFilterListProps {
} }
const UnifiedFilterList = (props: UnifiedFilterListProps) => { const UnifiedFilterList = (props: UnifiedFilterListProps) => {
const { t } = useTranslation();
const { const {
filters, filters,
handleRemove, handleRemove,
@ -41,12 +39,10 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
showIndices = true, showIndices = true,
readonly = false, readonly = false,
isConditional = false, isConditional = false,
showEventsOrder = false,
saveRequestPayloads = false, saveRequestPayloads = false,
supportsEmpty = true, supportsEmpty = true,
mergeDown = false, style,
mergeUp = false, className
style
} = props; } = props;
const [hoveredItem, setHoveredItem] = useState<{ i: number | null; position: string | null }>({ const [hoveredItem, setHoveredItem] = useState<{ i: number | null; position: string | null }>({
@ -66,8 +62,11 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
}, [handleRemove]); }, [handleRemove]);
const calculateNewPosition = useCallback( const calculateNewPosition = useCallback(
(dragInd: number, hoverIndex: number, hoverPosition: string) => { (hoverIndex: number, hoverPosition: string) => {
return hoverPosition === 'bottom' ? (dragInd < hoverIndex ? hoverIndex - 1 : hoverIndex) : hoverIndex; // 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); setDraggedItem(index);
const el = document.getElementById(elId); const el = document.getElementById(elId);
if (el) { 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) => { const handleDragOver = useCallback((event: React.DragEvent, i: number) => {
event.preventDefault(); 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 target = event.currentTarget.getBoundingClientRect();
const hoverMiddleY = (target.bottom - target.top) / 2; const hoverMiddleY = (target.bottom - target.top) / 2;
const hoverClientY = event.clientY - target.top; const hoverClientY = event.clientY - target.top;
const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom'; const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom';
setHoveredItem({ position, i }); setHoveredItem({ position, i });
}, []); }, [hoveredItem.i]); // Depend on hoveredItem.i to avoid unnecessary updates
const handleDrop = useCallback( const handleDrop = useCallback(
(event: React.DragEvent) => { (event: React.DragEvent) => {
event.preventDefault(); event.preventDefault();
if (draggedInd === null || hoveredItem.i === null) return; const draggedIndexStr = event.dataTransfer.getData('text/plain');
const newPosition = calculateNewPosition( const dragInd = parseInt(draggedIndexStr, 10);
draggedInd,
hoveredItem.i, if (isNaN(dragInd) || hoveredItem.i === null) {
hoveredItem.position || 'bottom' setHoveredItem({ i: null, position: null });
); setDraggedItem(null);
handleMove(draggedInd, newPosition); 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 }); setHoveredItem({ i: null, position: null });
setDraggedItem(null); setDraggedItem(null);
}, },
[draggedInd, calculateNewPosition, handleMove, hoveredItem.i, hoveredItem.position] [handleMove, hoveredItem.i, hoveredItem.position, calculateNewPosition]
); );
const handleDragEnd = useCallback(() => { const handleDragEnd = useCallback(() => {
setHoveredItem({ i: null, position: null }); setHoveredItem({ i: null, position: null });
setDraggedItem(null); setDraggedItem(null);
}, []); }, []);
return ( const handleDragLeave = useCallback(() => {
<div className="flex flex-col" style={style}> // 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) => ( {filters.map((filterItem: any, filterIndex: number) => (
<div <div
key={`filter-${filterIndex}`} key={`filter-${filterItem.key || filterIndex}`}
className={cn('hover:bg-active-blue px-5 pe-3 gap-2 items-center flex', { 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
'bg-[#f6f6f6]': hoveredItem.i === filterIndex '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={{ id={`filter-${filterItem.key || filterIndex}`}
marginLeft: '-1rem', draggable={isDraggable && filters.length > 1} // Only draggable if enabled and more than one item
width: 'calc(100% + 2rem)', onDragStart={isDraggable && filters.length > 1 ? (e) => handleDragStart(e, filterIndex, `filter-${filterItem.key || filterIndex}`) : undefined}
alignItems: 'start', onDragEnd={isDraggable ? handleDragEnd : undefined}
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}`}
onDragOver={isDraggable ? (e) => handleDragOver(e, filterIndex) : undefined} onDragOver={isDraggable ? (e) => handleDragOver(e, filterIndex) : undefined}
onDrop={isDraggable ? handleDrop : undefined} onDrop={isDraggable ? handleDrop : undefined}
onDragLeave={isDraggable ? handleDragLeave : undefined} // Clear hover effect when leaving
> >
{isDraggable && filters.length > 1 && ( {isDraggable && filters.length > 1 && (
<div <div
className="cursor-grab text-neutral-500/90 hover:bg-white px-1 mt-2.5 rounded-lg" className="cursor-grab text-neutral-500 hover:text-neutral-700 pt-[10px] flex-shrink-0" // Align handle visually
draggable={true} // Draggable is set on parent div
onDragStart={(e) => handleDragStart(e, filterIndex, `filter-${filterIndex}`)}
onDragEnd={handleDragEnd}
style={{ cursor: draggedInd !== null ? 'grabbing' : 'grab' }} style={{ cursor: draggedInd !== null ? 'grabbing' : 'grab' }}
title="Drag to reorder"
> >
<GripVertical size={16} /> <GripVertical size={16} />
</div> </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 <FilterItem
filterIndex={showIndices ? filterIndex : undefined} filterIndex={showIndices ? filterIndex : undefined}
filter={filterItem} filter={filterItem}
@ -155,11 +201,14 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
readonly={readonly} readonly={readonly}
isConditional={isConditional} isConditional={isConditional}
hideIndex={!showIndices} 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>
))} ))}
</div> </div>
); ) : null;
}; };
export default UnifiedFilterList; export default UnifiedFilterList;

View file

@ -1,241 +1,211 @@
import cn from 'classnames'; import cn from 'classnames';
import { Pointer, ChevronRight, MousePointerClick } from 'lucide-react'; import { ChevronRight, MousePointerClick, Search } from 'lucide-react';
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react';
import { Loader } from 'UI'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; // Assuming correct path
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { Input, Space, Typography } from 'antd'; import { Input, Space, Typography } from 'antd';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Filter } from '@/mstore/types/filterConstants'; 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[]): Record<string, Filter[]> => {
const groupFiltersByCategory = (filters: Filter[]) => {
if (!filters?.length) return {}; if (!filters?.length) return {};
return filters.reduce((acc, filter) => { return filters.reduce((acc, filter) => {
const category = filter.category const categoryKey = filter.category || 'Other';
? filter.category.charAt(0).toUpperCase() + filter.category.slice(1) const category = categoryKey.charAt(0).toUpperCase() + categoryKey.slice(1);
: 'Unknown';
if (!acc[category]) acc[category] = []; if (!acc[category]) acc[category] = [];
acc[category].push(filter); acc[category].push(filter);
return acc; return acc;
}, {}); }, {} as Record<string, Filter[]>);
}; };
// Optimized filtering function with early returns const getFilteredEntries = (query: string, groupedFilters: Record<string, Filter[]>) => {
const getFilteredEntries = (query: string, filters: Filter[]) => {
const trimmedQuery = query.trim().toLowerCase(); const trimmedQuery = query.trim().toLowerCase();
if (!groupedFilters || Object.keys(groupedFilters).length === 0) {
if (!filters || Object.keys(filters).length === 0) {
return { matchingCategories: ['All'], matchingFilters: {} }; return { matchingCategories: ['All'], matchingFilters: {} };
} }
const allCategories = Object.keys(groupedFilters);
if (!trimmedQuery) { if (!trimmedQuery) {
return { return { matchingCategories: ['All', ...allCategories], matchingFilters: groupedFilters };
matchingCategories: ['All', ...Object.keys(filters)],
matchingFilters: filters
};
} }
const matchingCategories = new Set<string>(['All']);
const matchingCategories = ['All']; const matchingFilters: Record<string, Filter[]> = {};
const matchingFilters = {}; Object.entries(groupedFilters).forEach(([categoryName, categoryFilters]) => {
const categoryMatch = categoryName.toLowerCase().includes(trimmedQuery);
// Single pass through the data with optimized conditionals let categoryHasMatchingFilters = false;
Object.entries(filters).forEach(([name, categoryFilters]) => { const filteredItems = categoryFilters.filter(
const categoryMatch = name.toLowerCase().includes(trimmedQuery);
if (categoryMatch) {
matchingCategories.push(name);
matchingFilters[name] = categoryFilters;
return;
}
const filtered = categoryFilters.filter(
(filter: Filter) => (filter: Filter) =>
filter.displayName?.toLowerCase().includes(trimmedQuery) || filter.displayName?.toLowerCase().includes(trimmedQuery) ||
filter.name?.toLowerCase().includes(trimmedQuery) filter.name?.toLowerCase().includes(trimmedQuery)
); );
if (filteredItems.length > 0) {
if (filtered.length) { matchingFilters[categoryName] = filteredItems;
matchingCategories.push(name); categoryHasMatchingFilters = true;
matchingFilters[name] = filtered; }
if (categoryMatch || categoryHasMatchingFilters) {
matchingCategories.add(categoryName);
if (categoryMatch && !categoryHasMatchingFilters) {
matchingFilters[categoryName] = categoryFilters;
}
} }
}); });
const sortedMatchingCategories = ['All', ...allCategories.filter(cat => matchingCategories.has(cat))];
return { matchingCategories, matchingFilters }; return { matchingCategories: sortedMatchingCategories, matchingFilters };
}; };
// Custom debounce hook to optimize search
const useDebounce = (value: any, delay = 300) => { const useDebounce = (value: any, delay = 300) => {
const [debouncedValue, setDebouncedValue] = useState(value); const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => { useEffect(() => {
const handler = setTimeout(() => { const handler = setTimeout(() => {
setDebouncedValue(value); setDebouncedValue(value);
}, delay); }, delay);
return () => clearTimeout(handler); return () => clearTimeout(handler);
}, [value, delay]); }, [value, delay]);
return debouncedValue; return debouncedValue;
}; };
// Memoized filter item component // --- Sub-Components ---
const FilterItem = React.memo(({ filter, onClick, showCategory }: { const FilterItem = React.memo(({ filter, onClick, showCategory }: {
filter: Filter; filter: Filter;
onClick: (filter: Filter) => void; onClick: (filter: Filter) => void;
showCategory?: boolean; showCategory?: boolean;
}) => ( }) => (
<div <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)} onClick={() => onClick(filter)}
role="button" tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick(filter);
}}
> >
{showCategory && filter.category && ( {showCategory && filter.category && (
<div style={{ width: 100 }} className="text-neutral-500/90 flex justify-between items-center"> <div style={{ width: 110 }}
<span className="capitalize">{filter.subCategory || filter.category}</span> className="text-neutral-500 flex items-center justify-between flex-shrink-0 mr-1 text-xs">
<ChevronRight size={14} /> <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> </div>
)} )}
<Space className="flex-1 min-w-0"> <Space className="flex-grow min-w-0 items-center">
<span className="text-neutral-500/90 text-xs">{getIconForFilter(filter)}</span> <span className="text-neutral-500/90 text-xs flex items-center">{getIconForFilter(filter)}</span>
<Typography.Text <Typography.Text ellipsis={{ tooltip: filter.displayName || filter.name }}
ellipsis={{ tooltip: true }} className="flex-1">{filter.displayName || filter.name}</Typography.Text>
className="max-w-full"
style={{ display: 'block' }}
>
{filter.displayName || filter.name}
</Typography.Text>
</Space> </Space>
</div> </div>
)); ));
// Memoized category list component
const CategoryList = React.memo(({ categories, activeCategory, onSelect }: { const CategoryList = React.memo(({ categories, activeCategory, onSelect }: {
categories: string[]; categories: string[];
activeCategory: string; activeCategory: string;
onSelect: (category: string) => void; onSelect: (category: string) => void;
}) => ( }) => (
<> <div className="flex flex-col gap-1">
{categories.map((key) => ( {categories.map((key) => (
<div <div
key={key} key={key}
onClick={() => onSelect(key)} onClick={() => onSelect(key)}
className={cn( 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')}
'rounded-xl px-4 py-2 hover:bg-active-blue capitalize cursor-pointer font-medium', title={key} role="button" tabIndex={0}
key === activeCategory && 'bg-active-blue text-teal' onKeyDown={(e) => {
)} if (e.key === 'Enter' || e.key === ' ') onSelect(key);
> }}
{key} >{key}</div>
</div>
))} ))}
</> </div>
)); ));
function FilterModal({ onFilterClick = () => null, filters = [], isMainSearch = false }) { function FilterModal({ onFilterClick = () => null, filters = [] }: {
onFilterClick: (filter: Filter) => void;
filters: Filter[];
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const debouncedQuery = useDebounce(searchQuery); const debouncedQuery = useDebounce(searchQuery);
const [category, setCategory] = useState('All'); const [category, setCategory] = useState('All');
const [isLoading, setIsLoading] = useState(false); const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef(null);
// Memoize expensive computations
const groupedFilters = useMemo(() =>
groupFiltersByCategory(filters),
[filters]
);
const { matchingCategories, matchingFilters } = useMemo(
() => getFilteredEntries(debouncedQuery, groupedFilters),
[debouncedQuery, groupedFilters]
);
const groupedFilters = useMemo(() => groupFiltersByCategory(filters), [filters]);
const {
matchingCategories,
matchingFilters
} = useMemo(() => getFilteredEntries(debouncedQuery, groupedFilters), [debouncedQuery, groupedFilters]);
const displayedFilters = useMemo(() => { const displayedFilters = useMemo(() => {
if (category === 'All') { if (category === 'All') {
return Object.entries(matchingFilters).flatMap(([cat, filters]) => return matchingCategories.filter(cat => cat !== 'All').flatMap(cat => (matchingFilters[cat] || []).map(filter => ({
filters.map((filter) => ({ ...filter, category: cat })) ...filter,
); category: cat
})));
} }
return matchingFilters[category] || []; return matchingFilters[category] || [];
}, [category, matchingFilters]); }, [category, matchingFilters, matchingCategories]);
const isResultEmpty = useMemo(() => matchingCategories.length <= 1 && Object.keys(matchingFilters).length === 0, [matchingCategories, matchingFilters]);
const isResultEmpty = useMemo( const handleFilterClick = useCallback((filter: Filter) => {
() => matchingCategories.length <= 1 && Object.keys(matchingFilters).length === 0, onFilterClick(filter);
[matchingCategories.length, matchingFilters] }, [onFilterClick]);
); const handleCategoryClick = useCallback((cat: string) => {
setCategory(cat);
// 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>
);
}
return ( return (
<div style={{ width: '490px', maxHeight: '380px' }}> <div className="w-[490px] max-h-[380px] grid grid-rows-[auto_1fr] overflow-hidden bg-white">
<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
/>
{isResultEmpty ? ( <div className="">
<div className="flex items-center flex-col justify-center h-60"> <Input
<AnimatedSVG name={ICONS.NO_SEARCH_RESULTS} size={30} /> ref={inputRef} placeholder={t('Search')} value={searchQuery}
<div className="font-medium px-3 mt-4">{t('No matching filters.')}</div> onChange={(e) => setSearchQuery(e.target.value)}
</div> autoFocus allowClear
) : ( prefix={<Search size={16} className="text-gray-400 mr-1" />}
<div className="flex gap-2 items-start"> className="mb-3 rounded-lg"
<div className="flex flex-col gap-1 min-w-40"> />
<CategoryList </div>
categories={matchingCategories}
activeCategory={category} <div className="overflow-hidden min-h-0">
onSelect={handleCategoryClick} {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>
<div className="flex flex-col gap-1 overflow-y-auto w-full" style={{ maxHeight: 300, flex: 2 }}> ) : (
{displayedFilters.length > 0 ? ( <div className="flex gap-2 h-full">
displayedFilters.map((filter: Filter, index: number) => (
<FilterItem <div className="w-36 flex-shrink-0 border-r border-gray-200 pr-2 h-full overflow-y-auto">
key={`${filter.name}-${index}`} <CategoryList
filter={filter} categories={matchingCategories}
onClick={handleFilterClick} activeCategory={category}
showCategory={category === 'All'} onSelect={handleCategoryClick}
/> />
)) </div>
) : (
<div className="flex items-center justify-center h-40"> <div className="flex-grow min-w-0 h-full overflow-y-auto">
<div className="text-neutral-500">{t('No filters in this category')}</div> <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> )}
)} </div>
</div> </div>
); );
} }

View file

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

View file

@ -1,5 +1,7 @@
import React, { useState, useCallback } from 'react'; 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 { observer } from 'mobx-react-lite';
import FilterModal from '../FilterModal/FilterModal'; import FilterModal from '../FilterModal/FilterModal';
import { Filter } from '@/mstore/types/filterConstants'; import { Filter } from '@/mstore/types/filterConstants';
@ -9,7 +11,8 @@ interface FilterSelectionProps {
onFilterClick: (filter: Filter) => void; onFilterClick: (filter: Filter) => void;
children?: React.ReactNode; children?: React.ReactNode;
disabled?: boolean; 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(({ const FilterSelection: React.FC<FilterSelectionProps> = observer(({
@ -17,41 +20,67 @@ const FilterSelection: React.FC<FilterSelectionProps> = observer(({
onFilterClick, onFilterClick,
children, children,
disabled = false, disabled = false,
isLive isLive,
loading = false // <-- Initialize loading prop
}) => { }) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const handleFilterClick = useCallback((selectedFilter: Filter) => { const handleFilterClick = useCallback((selectedFilter: Filter) => {
// Don't do anything if loading - though modal shouldn't be clickable then anyway
if (loading) return;
onFilterClick(selectedFilter); onFilterClick(selectedFilter);
setOpen(false); setOpen(false);
}, [onFilterClick]); }, [onFilterClick, loading]);
const handleOpenChange = useCallback((newOpen: boolean) => { 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); setOpen(newOpen);
} }
}, [disabled]); }, [disabled, loading]);
// Determine the content for the Popover
const content = ( const content = (
<FilterModal loading
onFilterClick={handleFilterClick} // Show a spinner centered in the popover content area while loading
filters={filters} ? <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) 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; : children;
return ( 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 <Popover
content={content} content={content}
trigger="click" trigger="click"
open={open} open={open}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
placement="bottomLeft" 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 destroyTooltipOnHide
arrow={false} arrow={false}
> >

View file

@ -40,12 +40,12 @@ function SessionFilters() {
<div <div
className={cn( className={cn(
'bg-white', '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 <FilterListHeader
title={'Events'} title={'Events'}
showEventsOrder={true} showEventsOrder={searchInstance.filters.filter(i => i.isEvent).length > 0}
orderProps={searchInstance} orderProps={searchInstance}
onChangeOrder={onChangeEventsOrder} onChangeOrder={onChangeEventsOrder}
filterSelection={ filterSelection={
@ -70,6 +70,7 @@ function SessionFilters() {
filters={searchInstance.filters.filter(i => i.isEvent)} filters={searchInstance.filters.filter(i => i.isEvent)}
isDraggable={true} isDraggable={true}
showIndices={true} showIndices={true}
className="mt-2"
handleRemove={function(key: string): void { handleRemove={function(key: string): void {
searchStore.removeFilter(key); searchStore.removeFilter(key);
}} }}
@ -107,6 +108,7 @@ function SessionFilters() {
<UnifiedFilterList <UnifiedFilterList
title="Filters" title="Filters"
filters={searchInstance.filters.filter(i => !i.isEvent)} filters={searchInstance.filters.filter(i => !i.isEvent)}
className="mt-2"
isDraggable={false} isDraggable={false}
showIndices={false} showIndices={false}
handleRemove={function(key: string): void { handleRemove={function(key: string): void {

View file

@ -95,7 +95,7 @@ export default class FilterStore {
possibleTypes: filter.possibleTypes?.map(type => type.toLowerCase()) || [], possibleTypes: filter.possibleTypes?.map(type => type.toLowerCase()) || [],
type: filter.possibleTypes?.[0].toLowerCase() || 'string', type: filter.possibleTypes?.[0].toLowerCase() || 'string',
category: category || 'custom', category: category || 'custom',
subCategory: category === 'events' ? (filter.autoCaptured ? 'auto' : 'user') : category, subCategory: category === 'events' ? (filter.autoCaptured ? 'autocapture' : 'user') : category,
displayName: filter.displayName || filter.name, displayName: filter.displayName || filter.name,
icon: FilterKey.LOCATION, // TODO - use actual icons icon: FilterKey.LOCATION, // TODO - use actual icons
isEvent: category === 'events', isEvent: category === 'events',

View file

@ -58,7 +58,7 @@
"js-untar": "^2.0.0", "js-untar": "^2.0.0",
"jspdf": "^3.0.1", "jspdf": "^3.0.1",
"lottie-react": "^2.4.1", "lottie-react": "^2.4.1",
"lucide-react": "0.454.0", "lucide-react": "0.487.0",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"microdiff": "^1.5.0", "microdiff": "^1.5.0",
"mobx": "^6.13.7", "mobx": "^6.13.7",