feat(ui): dynamic fitlers - ui aligments and other improvements
This commit is contained in:
parent
7cf4f50b36
commit
a6fa45041f
9 changed files with 431 additions and 323 deletions
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue