Refactor filtering system with improved autocomplete and event handling

- Add property type icons and improved filter categorization
- Implement virtualized filter selection with VList
- Update autocomplete to use new property-based endpoints
- Add support for event vs property filter distinction
- Improve top values fetching with proper caching
- Add cards/try endpoint routing in API client
- Various code style and formatting improvements
Refactor filtering system with improved autocomplete and event handling
This commit is contained in:
Shekar Siri 2025-06-02 13:11:36 +02:00
parent 0418fafb92
commit 4a54830cad
17 changed files with 1201 additions and 685 deletions

View file

@ -103,8 +103,8 @@ export default class APIClient {
// Always fetch the latest JWT from the store
const jwt = this.getJwt();
const headers = new Headers({
'Accept': 'application/json',
'Content-Type': 'application/json'
Accept: 'application/json',
'Content-Type': 'application/json',
});
if (reqHeaders) {
@ -121,7 +121,7 @@ export default class APIClient {
const init: RequestInit = {
method,
headers,
body: params ? JSON.stringify(params) : undefined
body: params ? JSON.stringify(params) : undefined,
};
if (method === 'GET') {
@ -185,20 +185,28 @@ export default class APIClient {
delete init.body;
}
if ((
path.includes('login')
|| path.includes('refresh')
|| path.includes('logout')
|| path.includes('reset')
) && window.env.NODE_ENV !== 'development'
if (
(path.includes('login') ||
path.includes('refresh') ||
path.includes('logout') ||
path.includes('reset')) &&
window.env.NODE_ENV !== 'development'
) {
init.credentials = 'include';
} else {
delete init.credentials;
}
const noChalice = path.includes('/kai') || path.includes('v1/integrations') || path.includes('/spot') && !path.includes('/login');
const noChalice =
path.includes('/kai') ||
path.includes('v1/integrations') ||
(path.includes('/spot') && !path.includes('/login'));
let edp = window.env.API_EDP || window.location.origin + '/api';
if (path.includes('/cards/try')) {
// TODO - Remove this condition
edp = 'http://localhost:8080/v1/analytics';
}
if (noChalice && !edp.includes('api.openreplay.com')) {
edp = edp.replace('/api', '');
}
@ -227,8 +235,7 @@ export default class APIClient {
try {
const errorData = await response.json();
errorMsg = errorData.errors?.[0] || errorMsg;
} catch {
}
} catch {}
throw new Error(errorMsg);
}

View file

@ -1,6 +1,6 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Card, Space, Button, Alert, Form, Select, Tooltip } from 'antd';
import { useStore } from 'App/mstore';
import { projectStore, useStore } from 'App/mstore';
import { eventKeys } from 'Types/filter/newFilter';
import {
HEATMAP,
@ -204,12 +204,17 @@ const FilterSection = observer(
const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
const { t } = useTranslation();
const metricValueOptions = [
{ value: 'location', label: t('Pages') },
{ value: 'click', label: t('Clicks') },
{ value: 'input', label: t('Input') },
{ value: 'custom', label: t('Custom Events') },
];
// const metricValueOptions = [
// { value: 'location', label: t('Pages') },
// { value: 'click', label: t('Clicks') },
// { value: 'input', label: t('Input') },
// { value: 'custom', label: t('Custom Events') },
// ];
//
const { filterStore } = useStore();
const metricValueOptions = useMemo(() => {
return filterStore.getEventOptions(projectStore?.activeSiteId + '');
}, []);
const onPointChange = (value: any) => {
writeOption({ name: 'startType', value: { value } });
@ -261,12 +266,12 @@ const PathAnalysisFilter = observer(({ metric, writeOption }: any) => {
<FilterItem
hideDelete
filter={metric.startPoint}
allowedFilterKeys={[
FilterKey.LOCATION,
FilterKey.CLICK,
FilterKey.INPUT,
FilterKey.CUSTOM,
]}
// allowedFilterKeys={[
// FilterKey.LOCATION,
// FilterKey.CLICK,
// FilterKey.INPUT,
// FilterKey.CUSTOM,
// ]}
onUpdate={(val) => metric.updateStartPoint(val)}
onRemoveFilter={() => {}}
/>

View file

@ -1,9 +1,19 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
} from 'react';
import { debounce } from 'App/utils';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { searchService } from 'App/services';
import { AutoCompleteContainer, AutocompleteModal, Props } from './AutocompleteModal';
import {
AutoCompleteContainer,
AutocompleteModal,
Props,
} from './AutocompleteModal';
import { TopValue } from '@/mstore/filterStore';
interface FilterParams {
@ -37,15 +47,14 @@ function processMetadataValues(input: FilterParams): FilterParams {
return result as FilterParams; // Cast back if confident, or adjust logic
}
const FilterAutoComplete = observer(
({
params, // Expect FilterParams type here
values,
onClose,
onApply,
placeholder
}: {
params, // Expect FilterParams type here
values,
onClose,
onApply,
placeholder,
}: {
params: FilterParams;
values: string[];
onClose: () => void;
@ -73,7 +82,10 @@ const FilterAutoComplete = observer(
if (projectsStore.siteId && params.id) {
setLoading(true);
try {
await filterStore.fetchTopValues(params.id, projectsStore.siteId);
await filterStore.fetchTopValues({
id: params.id,
siteId: projectsStore.siteId,
});
} catch (error) {
console.error('Failed to load top values', error);
// Handle error state if needed
@ -93,38 +105,43 @@ const FilterAutoComplete = observer(
setOptions(mappedTopValues);
}, [mappedTopValues]);
const loadOptions = useCallback(async (inputValue: string) => {
if (!inputValue.length) {
setOptions(mappedTopValues);
return;
}
setLoading(true);
try {
const searchType = params.name?.toLowerCase();
if (!searchType) {
console.warn('Search type (params.name) is missing.');
setOptions([]);
const loadOptions = useCallback(
async (inputValue: string) => {
if (!inputValue.length) {
setOptions(mappedTopValues);
return;
}
const data: { value: string }[] = await searchService.fetchAutoCompleteValues({
type: searchType,
q: inputValue
});
const _options = data.map((i) => ({ value: i.value, label: i.value })) || [];
setOptions(_options);
} catch (e) {
console.error('Failed to fetch autocomplete values:', e);
setOptions(mappedTopValues);
} finally {
setLoading(false);
}
}, [mappedTopValues, params.name, searchService.fetchAutoCompleteValues]);
setLoading(true);
try {
const searchType = params.name?.toLowerCase();
if (!searchType) {
console.warn('Search type (params.name) is missing.');
setOptions([]);
return;
}
const data: { value: string }[] =
await searchService.fetchAutoCompleteValues({
type: searchType,
q: inputValue,
});
const _options =
data.map((i) => ({ value: i.value, label: i.value })) || [];
setOptions(_options);
} catch (e) {
console.error('Failed to fetch autocomplete values:', e);
setOptions(mappedTopValues);
} finally {
setLoading(false);
}
},
[mappedTopValues, params.name, searchService.fetchAutoCompleteValues],
);
const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [loadOptions]);
const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [
loadOptions,
]);
const handleInputChange = (newValue: string) => {
debouncedLoadOptions(newValue);
@ -149,7 +166,7 @@ const FilterAutoComplete = observer(
placeholder={placeholder}
/>
);
}
},
);
function AutoCompleteController(props: Props) {

View file

@ -50,7 +50,7 @@ function FilterItem(props: Props) {
onPropertyOrderChange,
parentEventFilterOptions,
isDragging,
isFirst = false // Default to false
isFirst = false, // Default to false
} = props;
const [eventFilterOptions, setEventFilterOptions] = useState<Filter[]>([]);
@ -58,7 +58,9 @@ function FilterItem(props: Props) {
const { filterStore } = useStore();
const allFilters = filterStore.getCurrentProjectFilters();
const eventSelections = allFilters.filter((i) => i.isEvent === filter.isEvent);
const eventSelections = allFilters.filter(
(i) => i.isEvent === filter.isEvent,
);
const filterSelections = useMemo(() => {
if (isSubItem) {
@ -67,7 +69,7 @@ function FilterItem(props: Props) {
return eventSelections;
}, [isSubItem, parentEventFilterOptions, eventSelections]);
const operatorOptions = getOperatorsByType(filter.type);
const operatorOptions = getOperatorsByType(filter.dataType);
useEffect(() => {
let isMounted = true; // Mounted flag
@ -81,10 +83,18 @@ function FilterItem(props: Props) {
// Only set loading if not already loading for this specific fetch
if (isMounted) setEventFiltersLoading(true);
const options = await filterStore.getEventFilters(fetchName);
const options = await filterStore.getEventFilters(
fetchName,
filter.autoCaptured,
);
// Check mount status AND if the relevant dependencies are still the same
if (isMounted && filter.name === fetchName && !isSubItem && filter.isEvent) {
if (
isMounted &&
filter.name === fetchName &&
!isSubItem &&
filter.isEvent
) {
// Avoid setting state if options haven't actually changed (optional optimization)
// This requires comparing options, which might be complex/costly.
// Sticking to setting state is usually fine if dependencies are stable.
@ -92,11 +102,21 @@ function FilterItem(props: Props) {
}
} catch (error) {
console.error('Failed to load event filters:', error);
if (isMounted && filter.name === fetchName && !isSubItem && filter.isEvent) {
if (
isMounted &&
filter.name === fetchName &&
!isSubItem &&
filter.isEvent
) {
setEventFilterOptions([]);
}
} finally {
if (isMounted && filter.name === fetchName && !isSubItem && filter.isEvent) {
if (
isMounted &&
filter.name === fetchName &&
!isSubItem &&
filter.isEvent
) {
setEventFiltersLoading(false);
}
}
@ -124,6 +144,13 @@ function FilterItem(props: Props) {
// that determine *if* and *what* to fetch.
}, [filter.name, filter.isEvent, isSubItem, filterStore]); //
console.log('filter...', filter);
const isUserEvent = useMemo(
() => filter.isEvent && !filter.autoCaptured,
[filter.isEvent, filter.autoCaptured],
);
const canShowValues = useMemo(
() =>
!(
@ -131,10 +158,13 @@ function FilterItem(props: Props) {
filter.operator === 'onAny' ||
filter.operator === 'isUndefined'
),
[filter.operator]
[filter.operator],
);
const isReversed = useMemo(() => filter.key === FilterKey.TAGGED_ELEMENT, [filter.key]);
const isReversed = useMemo(
() => filter.key === FilterKey.TAGGED_ELEMENT,
[filter.key],
);
const replaceFilter = useCallback(
(selectedFilter: any) => {
@ -144,56 +174,59 @@ function FilterItem(props: Props) {
filters: selectedFilter.filters
? selectedFilter.filters.map((i: any) => ({ ...i, value: [''] }))
: [],
operator: selectedFilter.operator // Ensure operator is carried over or reset if needed
operator: selectedFilter.operator, // Ensure operator is carried over or reset if needed
});
},
[onUpdate]
[onUpdate],
);
const handleOperatorChange = useCallback(
(e: any, { value }: any) => {
onUpdate({ ...filter, operator: value });
},
[filter, onUpdate]
[filter, onUpdate],
);
const handleSourceOperatorChange = useCallback(
(e: any, { value }: any) => {
onUpdate({ ...filter, sourceOperator: value });
},
[filter, onUpdate]
[filter, onUpdate],
);
const handleUpdateSubFilter = useCallback(
(subFilter: any, index: number) => {
onUpdate({
...filter,
filters: filter.filters.map((i: any, idx: number) => (idx === index ? subFilter : i))
filters: filter.filters.map((i: any, idx: number) =>
idx === index ? subFilter : i,
),
});
},
[filter, onUpdate]
[filter, onUpdate],
);
const handleRemoveSubFilter = useCallback(
(index: number) => {
onUpdate({
...filter,
filters: filter.filters.filter((_: any, idx: number) => idx !== index)
filters: filter.filters.filter((_: any, idx: number) => idx !== index),
});
},
[filter, onUpdate]
[filter, onUpdate],
);
const filteredSubFilters = useMemo(
() =>
filter.filters
? filter.filters.filter(
(i: any) =>
(i.key !== FilterKey.FETCH_REQUEST_BODY && i.key !== FilterKey.FETCH_RESPONSE_BODY) ||
saveRequestPayloads
)
(i: any) =>
(i.key !== FilterKey.FETCH_REQUEST_BODY &&
i.key !== FilterKey.FETCH_RESPONSE_BODY) ||
saveRequestPayloads,
)
: [],
[filter.filters, saveRequestPayloads]
[filter.filters, saveRequestPayloads],
);
const addSubFilter = useCallback(
@ -201,21 +234,25 @@ function FilterItem(props: Props) {
const newSubFilter = {
...selectedFilter,
value: selectedFilter.value || [''],
operator: selectedFilter.operator || 'is'
operator: selectedFilter.operator || 'is',
};
onUpdate({
...filter,
filters: [...(filter.filters || []), newSubFilter]
filters: [...(filter.filters || []), newSubFilter],
});
},
[filter, onUpdate]
[filter, onUpdate],
);
const parentShowsIndex = !hideIndex;
const subFilterMarginLeftClass = parentShowsIndex ? 'ml-[1.75rem]' : 'ml-[0.75rem]';
const subFilterMarginLeftClass = parentShowsIndex
? 'ml-[1.75rem]'
: 'ml-[0.75rem]';
const subFilterPaddingLeftClass = parentShowsIndex ? 'pl-11' : 'pl-7';
const categoryPart = filter?.subCategory ? filter.subCategory : filter?.category;
const categoryPart = filter?.subCategory
? filter.subCategory
: filter?.category;
const namePart = filter?.displayName || filter?.name;
const hasCategory = Boolean(categoryPart);
const hasName = Boolean(namePart);
@ -224,30 +261,34 @@ function FilterItem(props: Props) {
return (
<div className={cn('w-full', isDragging ? 'opacity-50' : '')}>
<div className="flex items-start w-full gap-x-2"> {/* Use items-start */}
{!isSubItem && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && (
<div
className="flex-shrink-0 w-6 h-6 mt-[2px] text-xs flex items-center justify-center rounded-full bg-gray-lightest text-gray-600 font-medium"> {/* Align index top */}
<span>{filterIndex + 1}</span>
</div>
)}
<div className="flex items-start w-full gap-x-2">
{' '}
{/* Use items-start */}
{!isSubItem &&
!hideIndex &&
filterIndex !== undefined &&
filterIndex >= 0 && (
<div className="flex-shrink-0 w-6 h-6 mt-[2px] text-xs flex items-center justify-center rounded-full bg-gray-lightest text-gray-600 font-medium">
{' '}
{/* Align index top */}
<span>{filterIndex + 1}</span>
</div>
)}
{isSubItem && (
<div
className="flex-shrink-0 w-14 text-right text-neutral-500/90 pr-2">
<div className="flex-shrink-0 w-14 text-right text-neutral-500/90 pr-2">
{subFilterIndex === 0 && (
<Typography.Text className="text-inherit">
where
</Typography.Text>
<Typography.Text className="text-inherit">where</Typography.Text>
)}
{subFilterIndex !== 0 && propertyOrder && onPropertyOrderChange && (
<Typography.Text
className={cn(
'text-inherit',
!readonly && 'cursor-pointer hover:text-main transition-colors'
!readonly &&
'cursor-pointer hover:text-main transition-colors',
)}
onClick={() =>
!readonly && onPropertyOrderChange(propertyOrder === 'and' ? 'or' : 'and')
!readonly &&
onPropertyOrderChange(propertyOrder === 'and' ? 'or' : 'and')
}
>
{propertyOrder}
@ -255,10 +296,8 @@ function FilterItem(props: Props) {
)}
</div>
)}
{/* Main content area */}
<div
className="flex flex-grow flex-wrap gap-x-2 items-center">
<div className="flex flex-grow flex-wrap gap-x-2 items-center">
<FilterSelection
filters={filterSelections}
onFilterClick={replaceFilter}
@ -272,7 +311,7 @@ function FilterItem(props: Props) {
// onClick={onClick} // Pass onClick handler
style={{
maxWidth: '20rem',
flexShrink: 0
flexShrink: 0,
}}
>
<Space size={4} align="center">
@ -280,22 +319,21 @@ function FilterItem(props: Props) {
{filter && (
<span className="text-gray-600 flex-shrink-0">
{getIconForFilter(filter)}
</span>
</span>
)}
{/* Category/SubCategory */}
{hasCategory && (
<span className="text-neutral-500/90 capitalize truncate">
{categoryPart}
</span>
{categoryPart}
</span>
)}
{showSeparator && (
<span className="text-neutral-400"></span>
)}
{showSeparator && <span className="text-neutral-400"></span>}
<span className="text-black truncate">
{hasName ? namePart : (hasCategory ? '' : defaultText)} {/* Show name or placeholder */}
{hasName ? namePart : hasCategory ? '' : defaultText}{' '}
{/* Show name or placeholder */}
</span>
</Space>
</Button>
@ -320,7 +358,7 @@ function FilterItem(props: Props) {
</>
)}
{operatorOptions.length > 0 && filter.type && (
{operatorOptions.length > 0 && filter.dataType && !isUserEvent && (
<>
<FilterOperator
options={operatorOptions}
@ -336,14 +374,22 @@ function FilterItem(props: Props) {
title={filter.value.join(', ')}
>
{filter.value
.map((val: string) =>
filter.options?.find((i: any) => i.value === val)?.label ?? val
.map(
(val: string) =>
filter.options?.find((i: any) => i.value === val)
?.label ?? val,
)
.join(', ')}
</div>
) : (
<div className="inline-flex"> {/* Wrap FilterValue */}
<FilterValue isConditional={isConditional} filter={filter} onUpdate={onUpdate} />
<div className="inline-flex">
{' '}
{/* Wrap FilterValue */}
<FilterValue
isConditional={isConditional}
filter={filter}
onUpdate={onUpdate}
/>
</div>
))}
</>
@ -368,11 +414,15 @@ function FilterItem(props: Props) {
)}
{/*</div>*/}
</div>
{/* Action Buttons */}
{!readonly && !hideDelete && (
<div className="flex flex-shrink-0 gap-1 items-center self-start"> {/* Align top */}
<Tooltip title={isSubItem ? 'Remove filter condition' : 'Remove filter'} mouseEnterDelay={1}>
<div className="flex flex-shrink-0 gap-1 items-center self-start">
{' '}
{/* Align top */}
<Tooltip
title={isSubItem ? 'Remove filter condition' : 'Remove filter'}
mouseEnterDelay={1}
>
<Button
type="text"
icon={<CircleMinus size={14} />}
@ -388,17 +438,16 @@ function FilterItem(props: Props) {
{/* Sub-Filter Rendering */}
{filteredSubFilters.length > 0 && (
<div
className={cn(
'relative w-full mt-3 mb-2 flex flex-col gap-2'
)}
>
<div className={cn('relative w-full mt-3 mb-2 flex flex-col gap-2')}>
{/* Dashed line */}
<div className={cn(
'absolute top-0 bottom-0 left-1 w-px',
'border-l border-dashed border-gray-300',
subFilterMarginLeftClass
)} style={{ height: 'calc(100% - 4px)' }} />
<div
className={cn(
'absolute top-0 bottom-0 left-1 w-px',
'border-l border-dashed border-gray-300',
subFilterMarginLeftClass,
)}
style={{ height: 'calc(100% - 4px)' }}
/>
{filteredSubFilters.map((subFilter: any, index: number) => (
<div
@ -408,7 +457,9 @@ function FilterItem(props: Props) {
<FilterItem
filter={subFilter}
subFilterIndex={index}
onUpdate={(updatedSubFilter) => handleUpdateSubFilter(updatedSubFilter, index)}
onUpdate={(updatedSubFilter) =>
handleUpdateSubFilter(updatedSubFilter, index)
}
onRemoveFilter={() => handleRemoveSubFilter(index)}
saveRequestPayloads={saveRequestPayloads}
disableDelete={disableDelete}
@ -419,7 +470,9 @@ function FilterItem(props: Props) {
isSubItem={true}
propertyOrder={filter.propertyOrder || 'and'}
onPropertyOrderChange={onPropertyOrderChange}
parentEventFilterOptions={isSubItem ? parentEventFilterOptions : eventFilterOptions}
parentEventFilterOptions={
isSubItem ? parentEventFilterOptions : eventFilterOptions
}
isFirst={index === 0}
/>
</div>

View file

@ -6,7 +6,7 @@ import { Filter } from '@/mstore/types/filterConstants';
interface UnifiedFilterListProps {
title: string;
filters: any[];
filters: Filter[];
header?: React.ReactNode;
filterSelection?: React.ReactNode;
handleRemove: (key: string) => void;
@ -42,30 +42,39 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
saveRequestPayloads = false,
supportsEmpty = true,
style,
className
className,
} = props;
const [hoveredItem, setHoveredItem] = useState<{ i: number | null; position: string | null }>({
const [hoveredItem, setHoveredItem] = useState<{
i: number | null;
position: string | null;
}>({
i: null,
position: null
position: null,
});
const [draggedInd, setDraggedItem] = useState<number | null>(null);
const cannotDelete = !supportsEmpty && filters.length <= 1;
const updateFilter = useCallback((key: string, updatedFilter: any) => {
handleUpdate(key, updatedFilter);
}, [handleUpdate]);
const updateFilter = useCallback(
(key: string, updatedFilter: any) => {
handleUpdate(key, updatedFilter);
},
[handleUpdate],
);
const removeFilter = useCallback((key: string) => {
handleRemove(key);
}, [handleRemove]);
const removeFilter = useCallback(
(key: string) => {
handleRemove(key);
},
[handleRemove],
);
const calculateNewPosition = useCallback(
(hoverIndex: number, hoverPosition: string) => {
return hoverPosition === 'bottom' ? hoverIndex + 1 : hoverIndex;
},
[]
[],
);
const handleDragStart = useCallback(
@ -88,21 +97,23 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
setTimeout(() => document.body.removeChild(clone), 0);
}
},
[]
[],
);
const handleDragOver = useCallback((event: React.DragEvent, i: number) => {
event.preventDefault();
// Prevent re-calculating hover position if already hovering over the same item
if (hoveredItem.i === i) return;
const target = event.currentTarget.getBoundingClientRect();
const hoverMiddleY = (target.bottom - target.top) / 2;
const hoverClientY = event.clientY - target.top;
const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom';
setHoveredItem({ position, i });
}, [hoveredItem.i]); // Depend on hoveredItem.i to avoid unnecessary updates
const handleDragOver = useCallback(
(event: React.DragEvent, i: number) => {
event.preventDefault();
// Prevent re-calculating hover position if already hovering over the same item
if (hoveredItem.i === i) return;
const target = event.currentTarget.getBoundingClientRect();
const hoverMiddleY = (target.bottom - target.top) / 2;
const hoverClientY = event.clientY - target.top;
const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom';
setHoveredItem({ position, i });
},
[hoveredItem.i],
); // Depend on hoveredItem.i to avoid unnecessary updates
const handleDrop = useCallback(
(event: React.DragEvent) => {
@ -125,17 +136,20 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
newPosition--;
}
if (dragInd !== newPosition && !(dragInd === hoverIndex && hoverPosition === 'top') && !(dragInd === hoverIndex - 1 && hoverPosition === 'bottom')) {
if (
dragInd !== newPosition &&
!(dragInd === hoverIndex && hoverPosition === 'top') &&
!(dragInd === hoverIndex - 1 && hoverPosition === 'bottom')
) {
handleMove(dragInd, newPosition);
}
setHoveredItem({ i: null, position: null });
setDraggedItem(null);
},
[handleMove, hoveredItem.i, hoveredItem.position, calculateNewPosition]
[handleMove, hoveredItem.i, hoveredItem.position, calculateNewPosition],
);
const handleDragEnd = useCallback(() => {
setHoveredItem({ i: null, position: null });
setDraggedItem(null);
@ -147,21 +161,42 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
return filters.length ? (
<div className={cn('flex flex-col', className)} style={style}>
{filters.map((filterItem: any, filterIndex: number) => (
{filters.map((filterItem: Filter, filterIndex: number) => (
<div
key={`filter-${filterItem.key || filterIndex}`}
className={cn('flex gap-2 py-2 items-start hover:bg-active-blue -mx-5 px-5 pe-3 transition-colors duration-100 relative', { // Lighter hover, keep relative
'opacity-50': draggedInd === filterIndex,
'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',
'-mt-0.5': hoveredItem.i === filterIndex && hoveredItem.position === 'top',
'-mb-0.5': hoveredItem.i === filterIndex && hoveredItem.position === 'bottom'
})}
id={`filter-${filterItem.key || filterIndex}`}
key={`filter-${filterItem.id || filterIndex}`}
className={cn(
'flex gap-2 py-2 items-start hover:bg-active-blue -mx-5 px-5 pe-3 transition-colors duration-100 relative',
{
// Lighter hover, keep relative
'opacity-50': draggedInd === filterIndex,
'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',
'-mt-0.5':
hoveredItem.i === filterIndex && hoveredItem.position === 'top',
'-mb-0.5':
hoveredItem.i === filterIndex &&
hoveredItem.position === 'bottom',
},
)}
id={`filter-${filterItem.id || filterIndex}`}
draggable={isDraggable && filters.length > 1} // Only draggable if enabled and more than one item
onDragStart={isDraggable && filters.length > 1 ? (e) => handleDragStart(e, filterIndex, `filter-${filterItem.key || filterIndex}`) : undefined}
onDragStart={
isDraggable && filters.length > 1
? (e) =>
handleDragStart(
e,
filterIndex,
`filter-${filterItem.id || filterIndex}`,
)
: undefined
}
onDragEnd={isDraggable ? handleDragEnd : undefined}
onDragOver={isDraggable ? (e) => handleDragOver(e, filterIndex) : undefined}
onDragOver={
isDraggable ? (e) => handleDragOver(e, filterIndex) : undefined
}
onDrop={isDraggable ? handleDrop : undefined}
onDragLeave={isDraggable ? handleDragLeave : undefined} // Clear hover effect when leaving
>
@ -175,16 +210,17 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
</div>
)}
{!isDraggable && showIndices &&
<div className="w-4 flex-shrink-0" />}
{!isDraggable && !showIndices &&
<div className="w-4 flex-shrink-0" />}
{!isDraggable && showIndices && <div className="w-4 flex-shrink-0" />}
{!isDraggable && !showIndices && (
<div className="w-4 flex-shrink-0" />
)}
<FilterItem
filterIndex={showIndices ? filterIndex : undefined}
filter={filterItem}
onUpdate={(updatedFilter) => updateFilter(filterItem.id, updatedFilter)}
onUpdate={(updatedFilter) =>
updateFilter(filterItem.id, updatedFilter)
}
onRemoveFilter={() => removeFilter(filterItem.id)}
saveRequestPayloads={saveRequestPayloads}
disableDelete={cannotDelete}
@ -192,10 +228,14 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
isConditional={isConditional}
hideIndex={!showIndices}
isDragging={draggedInd === filterIndex}
onPropertyOrderChange={filterItem.isEvent ? (order: string) => {
const newFilter = { ...filterItem, propertyOrder: order };
updateFilter(filterItem.id, newFilter);
} : undefined}
onPropertyOrderChange={
filterItem.isEvent
? (order: string) => {
const newFilter = { ...filterItem, propertyOrder: order };
updateFilter(filterItem.id, newFilter);
}
: undefined
}
// Pass down if this is the first item for potential styling (e.g., no 'and'/'or' toggle)
isFirst={filterIndex === 0}
/>

View file

@ -1,164 +1,244 @@
import cn from 'classnames';
import { ChevronRight, MousePointerClick, Search } from 'lucide-react';
import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; // Assuming correct path
import {
ALargeSmall,
ChevronRight,
Hash,
MousePointerClick,
Search,
} from 'lucide-react';
import React, {
useState,
useRef,
useMemo,
useCallback,
useEffect,
} from 'react';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { Input, Space, Typography } from 'antd';
import { observer } from 'mobx-react-lite';
import { useTranslation } from 'react-i18next';
import { Filter } from '@/mstore/types/filterConstants';
import { VList } from 'virtua';
import { FilterType } from 'Types/filter/filterType';
import type { ComponentType } from 'react';
export const getIconForFilter = (filter: Filter) => {
return <MousePointerClick size={14} className="text-gray-400" />;
type IconProps = { size: number; className?: string };
type FilterIconMap = Record<FilterType, ComponentType<IconProps>>;
const iconProps: IconProps = { size: 14 };
const PropertyIconMap: FilterIconMap = {
[FilterType.NUMBER]: Hash,
[FilterType.INTEGER]: Hash,
[FilterType.STRING]: ALargeSmall,
};
const groupFiltersByCategory = (filters: Filter[]): Record<string, Filter[]> => {
export const getIconForFilter = (filter: Filter): JSX.Element => {
const Icon = filter.isEvent
? MousePointerClick
: PropertyIconMap[filter.dataType] || ALargeSmall;
const className = filter.isEvent ? 'text-gray-400' : undefined;
return <Icon {...iconProps} className={className} />;
};
const groupFiltersByCategory = (
filters: Filter[],
): Record<string, Filter[]> => {
if (!filters?.length) return {};
return filters.reduce((acc, filter) => {
const categoryKey = filter.category || 'Other';
const category = categoryKey.charAt(0).toUpperCase() + categoryKey.slice(1);
if (!acc[category]) acc[category] = [];
acc[category].push(filter);
return acc;
}, {} as Record<string, Filter[]>);
return filters.reduce(
(acc, filter) => {
const key = filter.category || 'Other';
const cat = key.charAt(0).toUpperCase() + key.slice(1);
(acc[cat] ||= []).push(filter);
return acc;
},
{} as Record<string, Filter[]>,
);
};
const getFilteredEntries = (query: string, groupedFilters: Record<string, Filter[]>) => {
const trimmedQuery = query.trim().toLowerCase();
if (!groupedFilters || Object.keys(groupedFilters).length === 0) {
const getFilteredEntries = (
query: string,
grouped: Record<string, Filter[]>,
) => {
const trimmed = query.trim().toLowerCase();
if (!Object.keys(grouped).length)
return { matchingCategories: ['All'], matchingFilters: {} };
}
const allCategories = Object.keys(groupedFilters);
if (!trimmedQuery) {
return { matchingCategories: ['All', ...allCategories], matchingFilters: groupedFilters };
}
const matchingCategories = new Set<string>(['All']);
const matchingFilters: Record<string, Filter[]> = {};
Object.entries(groupedFilters).forEach(([categoryName, categoryFilters]) => {
const categoryMatch = categoryName.toLowerCase().includes(trimmedQuery);
let categoryHasMatchingFilters = false;
const filteredItems = categoryFilters.filter(
(filter: Filter) =>
filter.displayName?.toLowerCase().includes(trimmedQuery) ||
filter.name?.toLowerCase().includes(trimmedQuery)
);
if (filteredItems.length > 0) {
matchingFilters[categoryName] = filteredItems;
categoryHasMatchingFilters = true;
}
if (categoryMatch || categoryHasMatchingFilters) {
matchingCategories.add(categoryName);
if (categoryMatch && !categoryHasMatchingFilters) {
matchingFilters[categoryName] = categoryFilters;
}
}
});
const sortedMatchingCategories = ['All', ...allCategories.filter(cat => matchingCategories.has(cat))];
return { matchingCategories: sortedMatchingCategories, matchingFilters };
};
const categories = Object.keys(grouped);
if (!trimmed)
return {
matchingCategories: ['All', ...categories],
matchingFilters: grouped,
};
const matched = new Set<string>(['All']);
const filtersMap: Record<string, Filter[]> = {};
categories.forEach((cat) => {
const catMatch = cat.toLowerCase().includes(trimmed);
const items = grouped[cat].filter(
(f) =>
f.displayName?.toLowerCase().includes(trimmed) ||
f.name?.toLowerCase().includes(trimmed),
);
if (items.length) filtersMap[cat] = items;
if (catMatch) filtersMap[cat] ||= grouped[cat];
if (catMatch || items.length) matched.add(cat);
});
return {
matchingCategories: ['All', ...categories.filter((c) => matched.has(c))],
matchingFilters: filtersMap,
};
};
const useDebounce = (value: any, delay = 300) => {
const [debouncedValue, setDebouncedValue] = useState(value);
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
const handler = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
return debounced;
};
// --- Sub-Components ---
const FilterItem = React.memo(({ filter, onClick, showCategory }: {
filter: Filter;
onClick: (filter: Filter) => void;
showCategory?: boolean;
}) => (
<div
className="flex items-center p-2 cursor-pointer gap-2 rounded-lg hover:bg-active-blue/10 text-sm"
onClick={() => onClick(filter)}
role="button" tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick(filter);
}}
>
{showCategory && filter.category && (
<div style={{ width: 110 }}
className="text-neutral-500 flex items-center justify-between flex-shrink-0 mr-1 text-xs">
<Typography.Text ellipsis={{ tooltip: true }}
className="capitalize flex-1 text-gray-600">{filter.subCategory || filter.category}</Typography.Text>
<ChevronRight size={14} className="ml-1 text-gray-400" />
</div>
)}
<Space className="flex-grow min-w-0 items-center">
<span className="text-neutral-500/90 text-xs flex items-center">{getIconForFilter(filter)}</span>
<Typography.Text ellipsis={{ tooltip: filter.displayName || filter.name }}
className="flex-1">{filter.displayName || filter.name}</Typography.Text>
</Space>
</div>
));
const FilterItem = React.memo(
({
filter,
onClick,
showCategory,
}: {
filter: Filter;
onClick: (f: Filter) => void;
showCategory?: boolean;
}) => (
<div
className="flex items-center p-2 cursor-pointer gap-2 rounded-lg hover:bg-active-blue/10 text-sm"
onClick={() => onClick(filter)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onClick(filter);
}}
>
{showCategory && filter.category && (
<div
// style={{ width: 110 }}
className="text-neutral-500 flex items-center justify-between flex-shrink-0 mr-1 text-xs"
>
<Typography.Text
ellipsis={{ tooltip: true }}
className="capitalize flex-1 text-gray-600"
>
{filter.subCategory || filter.category}
</Typography.Text>
<ChevronRight size={14} className="ml-1 text-gray-400" />
</div>
)}
<Space className="flex-grow min-w-0 items-center">
<span className="text-neutral-500/90 text-xs flex items-center">
{getIconForFilter(filter)}
</span>
<Typography.Text
ellipsis={{ tooltip: filter.displayName || filter.name }}
className="flex-1"
>
{filter.displayName || filter.name}
</Typography.Text>
</Space>
</div>
),
);
const CategoryList = React.memo(({ categories, activeCategory, onSelect }: {
categories: string[];
activeCategory: string;
onSelect: (category: string) => void;
}) => (
<div className="flex flex-col gap-1">
{categories.map((key) => (
<div
key={key}
onClick={() => onSelect(key)}
className={cn('rounded px-3 py-1.5 hover:bg-active-blue/10 capitalize cursor-pointer font-medium text-sm truncate', key === activeCategory && 'bg-active-blue/10 text-teal font-semibold')}
title={key} role="button" tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onSelect(key);
}}
>{key}</div>
))}
</div>
));
const CategoryList = React.memo(
({
categories,
activeCategory,
onSelect,
}: {
categories: string[];
activeCategory: string;
onSelect: (c: string) => void;
}) => (
<div className="flex flex-col gap-1">
{categories.map((key) => (
<div
key={key}
onClick={() => onSelect(key)}
className={cn(
'rounded px-3 py-1.5 hover:bg-active-blue/10 capitalize cursor-pointer font-medium text-sm truncate',
key === activeCategory &&
'bg-active-blue/10 text-teal font-semibold',
)}
title={key}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onSelect(key);
}}
>
{key}
</div>
))}
</div>
),
);
function FilterModal({ onFilterClick = () => null, filters = [] }: {
onFilterClick: (filter: Filter) => void;
function FilterModal({
onFilterClick = () => null,
filters = [],
}: {
onFilterClick: (f: Filter) => void;
filters: Filter[];
}) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const debouncedQuery = useDebounce(searchQuery);
const [category, setCategory] = useState('All');
const inputRef = useRef<HTMLInputElement>(null);
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(() => {
if (category === 'All') {
return matchingCategories.filter(cat => cat !== 'All').flatMap(cat => (matchingFilters[cat] || []).map(filter => ({
...filter,
category: cat
})));
return matchingCategories
.filter((cat) => cat !== 'All')
.flatMap((cat) =>
(matchingFilters[cat] || []).map((filter) => ({
...filter,
category: cat,
})),
);
}
return matchingFilters[category] || [];
}, [category, matchingFilters, matchingCategories]);
const isResultEmpty = useMemo(() => matchingCategories.length <= 1 && Object.keys(matchingFilters).length === 0, [matchingCategories, matchingFilters]);
const isResultEmpty = useMemo(
() =>
matchingCategories.length <= 1 &&
Object.keys(matchingFilters).length === 0,
[matchingCategories, matchingFilters],
);
const handleFilterClick = useCallback((filter: Filter) => {
onFilterClick(filter);
}, [onFilterClick]);
const handleFilterClick = useCallback(
(filter: Filter) => {
onFilterClick(filter);
},
[onFilterClick],
);
const handleCategoryClick = useCallback((cat: string) => {
setCategory(cat);
}, []);
return (
<div className="w-[490px] max-h-[380px] grid grid-rows-[auto_1fr] overflow-hidden bg-white">
<div className="">
<div>
<Input
ref={inputRef} placeholder={t('Search')} value={searchQuery}
placeholder={t('Search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus allowClear
autoFocus
allowClear
prefix={<Search size={16} className="text-gray-400 mr-1" />}
className="mb-3 rounded-lg"
/>
@ -168,12 +248,15 @@ function FilterModal({ onFilterClick = () => null, filters = [] }: {
{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 className="font-medium mt-4 text-neutral-600">
{t('No results found')}
</div>
<Typography.Text type="secondary" className="text-sm">
{t('Try different keywords')}
</Typography.Text>
</div>
) : (
<div className="flex gap-2 h-full">
<div className="w-36 flex-shrink-0 border-r border-gray-200 pr-2 h-full overflow-y-auto">
<CategoryList
categories={matchingCategories}
@ -181,28 +264,18 @@ function FilterModal({ onFilterClick = () => null, filters = [] }: {
onSelect={handleCategoryClick}
/>
</div>
<div className="flex-grow min-w-0 h-full overflow-y-auto">
<div className="flex flex-col gap-0.5">
{displayedFilters.length > 0 ? (
displayedFilters.map((filter: Filter) => (
<FilterItem
key={filter.id || filter.name}
filter={filter}
onClick={handleFilterClick}
showCategory={true} // TODO: Show category based condition
/>
))
) : (
category !== 'All' && (
<div className="flex items-center justify-center h-full text-neutral-500 text-sm p-4 text-center">
{t('No filters in category', { categoryName: category })}
</div>
)
)}
</div>
<div className="flex-grow min-w-0 h-full">
<VList style={{ height: 300 }}>
{displayedFilters.map((filter) => (
<FilterItem
key={filter.id || filter.name}
filter={filter}
onClick={handleFilterClick}
showCategory
/>
))}
</VList>
</div>
</div>
)}
</div>

View file

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import { Popover, Spin } from 'antd';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
@ -14,67 +14,92 @@ interface FilterSelectionProps {
loading?: boolean;
}
const FilterSelection: React.FC<FilterSelectionProps> = observer(({
filters,
onFilterClick,
children,
disabled = false,
isLive,
loading = false // <-- Initialize loading prop
}) => {
const [open, setOpen] = useState(false);
const FilterSelection: React.FC<FilterSelectionProps> = observer(
({
filters,
onFilterClick,
children,
disabled = false,
isLive,
loading = false, // <-- Initialize loading prop
}) => {
const [open, setOpen] = useState(false);
const handleFilterClick = useCallback((selectedFilter: Filter) => {
if (loading) return;
onFilterClick(selectedFilter);
setOpen(false);
}, [onFilterClick, loading]);
const handleFilterClick = useCallback(
(selectedFilter: Filter) => {
if (loading) return;
onFilterClick(selectedFilter);
setOpen(false);
},
[onFilterClick, loading],
);
const handleOpenChange = useCallback((newOpen: boolean) => {
if (!disabled && !loading) {
setOpen(newOpen);
} else if (!newOpen) {
setOpen(newOpen);
}
}, [disabled, loading]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
if (!disabled && !loading) {
setOpen(newOpen);
} else if (!newOpen) {
setOpen(newOpen);
}
},
[disabled, loading],
);
const content = (
loading
? <div className="p-4 flex justify-center items-center" style={{ minHeight: '100px', minWidth: '150px' }}>
<Spin />
</div>
: <FilterModal
onFilterClick={handleFilterClick}
filters={filters}
/>
);
// const content = loading ? (
// <div
// className="p-4 flex justify-center items-center"
// style={{ minHeight: '100px', minWidth: '150px' }}
// >
// <Spin />
// </div>
// ) : (
// <FilterModal onFilterClick={handleFilterClick} filters={filters} />
// );
const isDisabled = disabled || loading;
const content = useMemo(
() =>
loading ? (
<div
className="p-4 flex justify-center items-center"
style={{ minHeight: '100px', minWidth: '150px' }}
>
<Spin />
</div>
) : (
<FilterModal onFilterClick={handleFilterClick} filters={filters} />
),
[loading, filters, handleFilterClick],
);
const triggerElement = React.isValidElement(children)
? React.cloneElement(children as React.ReactElement<any>, {
disabled: isDisabled,
className: cn(children.props.className, { 'opacity-70 cursor-not-allowed': loading }) // Example styling
})
: children;
const isDisabled = disabled || loading;
return (
// <div className={cn('relative flex-shrink-0')}>
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={handleOpenChange}
placement="bottomLeft"
// Consistent styling class name with your original
overlayClassName="filter-selection-popover rounded-lg border border-gray-200 shadow-sm shadow-gray-200 overflow-hidden"
destroyTooltipOnHide
arrow={false}
>
{triggerElement}
</Popover>
// </div>
);
});
const triggerElement = React.isValidElement(children)
? React.cloneElement(children as React.ReactElement<any>, {
disabled: isDisabled,
className: cn(children.props.className, {
'opacity-70 cursor-not-allowed': loading,
}), // Example styling
})
: children;
return (
// <div className={cn('relative flex-shrink-0')}>
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={handleOpenChange}
placement="bottomLeft"
// Consistent styling class name with your original
overlayClassName="filter-selection-popover rounded-lg border border-gray-200 shadow-sm shadow-gray-200 overflow-hidden"
destroyOnHidden={true}
arrow={false}
>
{triggerElement}
</Popover>
// </div>
);
},
);
export default FilterSelection;

View file

@ -55,27 +55,32 @@ function BaseDropDown(props: any) {
);
}
function FilterValue(props: Props) {
const { filter, onUpdate, isConditional } = props; // Destructure props early
const isAutoOpen = filter.autoOpen; // Assume parent now controls this correctly
const [durationValues, setDurationValues] = useState(() => ({
minDuration: filter.value?.[0],
maxDuration: filter.value?.length > 1 ? filter.value[1] : filter.value?.[0]
maxDuration: filter.value?.length > 1 ? filter.value[1] : filter.value?.[0],
}));
useEffect(() => {
if (filter.type === FilterType.DURATION) {
const incomingMin = filter.value?.[0];
const incomingMax = filter.value?.length > 1 ? filter.value[1] : filter.value?.[0];
if (durationValues.minDuration !== incomingMin || durationValues.maxDuration !== incomingMax) {
setDurationValues({ minDuration: incomingMin, maxDuration: incomingMax });
const incomingMax =
filter.value?.length > 1 ? filter.value[1] : filter.value?.[0];
if (
durationValues.minDuration !== incomingMin ||
durationValues.maxDuration !== incomingMax
) {
setDurationValues({
minDuration: incomingMin,
maxDuration: incomingMax,
});
}
}
}, [filter.value, filter.type]);
const showCloseButton = filter.value.length > 1;
const showOrButton = filter.value.length > 1;
@ -84,54 +89,88 @@ function FilterValue(props: Props) {
onUpdate({ ...filter, value: newValue });
}, [filter, onUpdate]);
const onApplyValues = useCallback((values: string[]) => {
onUpdate({ ...filter, value: values });
}, [filter, onUpdate]);
const onApplyValues = useCallback(
(values: string[]) => {
onUpdate({ ...filter, value: values });
},
[filter, onUpdate],
);
const onRemoveValue = useCallback((valueIndex: any) => {
const newValue = filter.value.filter(
(_: any, index: any) => index !== valueIndex
);
onUpdate({ ...filter, value: newValue });
}, [filter, onUpdate]);
const onRemoveValue = useCallback(
(valueIndex: any) => {
const newValue = filter.value.filter(
(_: any, index: any) => index !== valueIndex,
);
onUpdate({ ...filter, value: newValue });
},
[filter, onUpdate],
);
const stableOnChange = useCallback((e: any, item: any, valueIndex: any) => {
const newValues = filter.value.map((val: any, _index: any) => {
if (_index === valueIndex) {
return item;
}
return val;
});
onUpdate({ ...filter, value: newValues });
}, [filter, onUpdate]);
const stableOnChange = useCallback(
(e: any, item: any, valueIndex: any) => {
const newValues = filter.value.map((val: any, _index: any) => {
if (_index === valueIndex) {
return item;
}
return val;
});
onUpdate({ ...filter, value: newValues });
},
[filter, onUpdate],
);
const debounceOnSelect = useCallback(debounce(stableOnChange, 500), [stableOnChange]);
const debounceOnSelect = useCallback(debounce(stableOnChange, 500), [
stableOnChange,
]);
const onDurationChange = useCallback((newValues: any) => {
setDurationValues(current => ({ ...current, ...newValues }));
setDurationValues((current) => ({ ...current, ...newValues }));
}, []);
const handleBlur = useCallback(() => {
if (filter.type === FilterType.DURATION) {
const currentMinInProp = filter.value?.[0];
const currentMaxInProp = filter.value?.length > 1 ? filter.value[1] : filter.value?.[0];
const currentMaxInProp =
filter.value?.length > 1 ? filter.value[1] : filter.value?.[0];
if (durationValues.minDuration !== currentMinInProp || durationValues.maxDuration !== currentMaxInProp) {
if (
durationValues.minDuration !== currentMinInProp ||
durationValues.maxDuration !== currentMaxInProp
) {
onUpdate({
...filter,
value: [durationValues.minDuration, durationValues.maxDuration]
value: [durationValues.minDuration, durationValues.maxDuration],
});
}
}
}, [filter, onUpdate, filter.value, durationValues.minDuration, durationValues.maxDuration]); // Add durationValues dependency
}, [
filter,
onUpdate,
filter.value,
durationValues.minDuration,
durationValues.maxDuration,
]); // Add durationValues dependency
const params = useMemo(() => {
let baseParams: any = {
type: filter.key,
name: filter.name,
isEvent: filter.isEvent,
id: filter.id
id: filter.id,
// propertyName: filter.propertyName,
// eventName: filter.name,
};
if (filter.isEvent) {
baseParams.eventName = filter.name;
}
if (!filter.isEvent) {
baseParams.propertyName = filter.name;
if (filter.eventName) {
baseParams.eventName = filter.eventName;
}
}
if (filter.category === FilterCategory.METADATA) {
baseParams = { type: FilterKey.METADATA, key: filter.key };
}
@ -143,7 +182,17 @@ function FilterValue(props: Props) {
const value = filter.value;
switch (filter.type) {
switch (filter.dataType) {
case FilterType.STRING:
return (
<ValueAutoComplete
initialValues={value}
isAutoOpen={isAutoOpen}
onApplyValues={onApplyValues}
params={params}
commaQuery={true}
/>
);
case FilterType.DOUBLE:
return (
<Input
@ -170,7 +219,7 @@ function FilterValue(props: Props) {
placeholder={filter.placeholder}
options={[
{ label: 'True', value: true },
{ label: 'False', value: false }
{ label: 'False', value: false },
]}
/>
);
@ -191,6 +240,7 @@ function FilterValue(props: Props) {
/>
);
case FilterType.NUMBER:
case FilterType.INTEGER:
return (
<BaseFilterLocalAutoComplete
value={value}
@ -207,22 +257,16 @@ function FilterValue(props: Props) {
isMultiple={false}
/>
);
case FilterType.STRING:
return <ValueAutoComplete
initialValues={value}
isAutoOpen={isAutoOpen}
onApplyValues={onApplyValues}
params={params}
commaQuery={true}
/>;
case FilterType.DROPDOWN:
return <BaseDropDown
value={value}
isAutoOpen={isAutoOpen}
placeholder={filter.placeholder}
options={filter.options}
onApplyValues={onApplyValues}
/>;
return (
<BaseDropDown
value={value}
isAutoOpen={isAutoOpen}
placeholder={filter.placeholder}
options={filter.options}
onApplyValues={onApplyValues}
/>
);
case FilterType.ISSUE:
case FilterType.MULTIPLE_DROPDOWN:
return (

View file

@ -1,9 +1,26 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
} from 'react';
import { debounce } from 'App/utils';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { searchService } from 'App/services';
import { Button, Checkbox, Input, Tooltip, Popover, Spin, Typography, List, Divider, Space } from 'antd';
import {
Button,
Checkbox,
Input,
Tooltip,
Popover,
Spin,
Typography,
List,
Divider,
Space,
} from 'antd';
import { RedoOutlined, CloseCircleFilled } from '@ant-design/icons';
import cn from 'classnames';
import { useTranslation } from 'react-i18next';
@ -20,8 +37,8 @@ interface FilterParams {
}
interface OptionType {
value: string;
label: string;
value?: string;
label?: string;
}
interface Props {
@ -37,15 +54,15 @@ interface Props {
const ValueAutoComplete = observer(
({
initialValues,
params,
onApplyValues,
placeholder = 'Select value(s)',
mapValues,
isAutoOpen = false,
commaQuery = false,
isDisabled = false
}: Props) => {
initialValues,
params,
onApplyValues,
placeholder = 'Select value(s)',
mapValues,
isAutoOpen = false,
commaQuery = false,
isDisabled = false,
}: Props) => {
const { t } = useTranslation();
const { filterStore, projectsStore } = useStore();
const [showValueModal, setShowValueModal] = useState(false);
@ -62,22 +79,32 @@ const ValueAutoComplete = observer(
return `${projectsStore.siteId}_${params.id}`;
}, [projectsStore.siteId, params.id]);
const topValues: TopValue[] = filterKey ? filterStore.topValues[filterKey] || [] : [];
const topValues: TopValue[] = filterKey
? filterStore.topValues[filterKey] || []
: [];
const mappedTopValues = useMemo(() => {
return topValues.map((i) => ({ value: i.value, label: i.value }));
const mappedTopValues: OptionType[] = useMemo(() => {
return (
topValues
// .filter((i): i is { value: string } => typeof i.value === 'string')
.map((i) => ({ value: i.value, label: i.value }))
);
}, [topValues]);
useEffect(() => {
setSelectedValues(initialValues.filter((i) => i && i.length > 0));
}, [initialValues]);
useEffect(() => {
if (filterKey && !filterStore.topValues[filterKey]) {
setLoadingTopValues(true);
filterStore.fetchTopValues(params.id, projectsStore.siteId)
.catch(error => console.error('Failed to load top values', error))
filterStore
.fetchTopValues({
id: params.id,
siteId: projectsStore.siteId + '',
isEvent: params.isEvent || false,
})
.catch((error) => console.error('Failed to load top values', error))
.finally(() => setLoadingTopValues(false));
}
}, [filterKey, params.id, projectsStore.siteId, filterStore]);
@ -88,7 +115,6 @@ const ValueAutoComplete = observer(
}
}, [isAutoOpen, isDisabled]);
useEffect(() => {
if (loadingTopValues) return;
if (showValueModal) {
@ -99,39 +125,57 @@ const ValueAutoComplete = observer(
}
}, [showValueModal, loadingTopValues]);
const loadOptions = useCallback(async (inputValue: string) => {
const trimmedQuery = inputValue.trim();
if (!trimmedQuery.length) {
setOptions(mappedTopValues);
setLoadingSearch(false);
return;
}
setLoadingSearch(true);
try {
const searchType = params.name?.toLowerCase();
if (!searchType) {
console.warn('Search type (params.name) is missing.');
setOptions([]);
const loadOptions = useCallback(
async (inputValue: string) => {
const trimmedQuery = inputValue.trim();
if (!trimmedQuery.length) {
setOptions(mappedTopValues);
setLoadingSearch(false);
return;
}
const data: { value: string }[] = await searchService.fetchAutoCompleteValues({
type: searchType,
q: trimmedQuery
});
const _options = data.map((i) => ({ value: i.value, label: i.value })) || [];
setOptions(_options);
} catch (e) {
console.error('Failed to fetch autocomplete values:', e);
setOptions(mappedTopValues);
} finally {
setLoadingSearch(false);
}
}, [mappedTopValues, params.name, searchService]);
setLoadingSearch(true);
try {
const searchType = params.name;
if (!searchType) {
console.warn('Search type (params.name) is missing.');
setOptions([]);
return;
}
const autoCompleteParams: any = {
q: trimmedQuery,
};
const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [loadOptions]);
if (params.propertyName) {
autoCompleteParams.propertyName = params.propertyName;
}
if (params.eventName) {
autoCompleteParams.eventName = params.eventName;
}
const data: { values: any[] }[] =
await searchService.fetchAutoCompleteValues(autoCompleteParams);
const _options =
data.values?.map((i: any) => ({
value: i.value,
label: i.value,
})) || [];
setOptions(_options);
} catch (e) {
console.error('Failed to fetch autocomplete values:', e);
setOptions(mappedTopValues);
} finally {
setLoadingSearch(false);
}
},
[mappedTopValues, params.name, searchService],
);
const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [
loadOptions,
]);
const handleInputChange = (value: string) => {
setQuery(value);
@ -147,7 +191,8 @@ const ValueAutoComplete = observer(
}
};
const isSelected = (item: OptionType) => selectedValues.includes(item.value);
const isSelected = (item: OptionType) =>
selectedValues.includes(item.value);
const applySelectedValues = () => {
onApplyValues(selectedValues);
@ -155,7 +200,12 @@ const ValueAutoComplete = observer(
};
const applyQuery = () => {
const vals = commaQuery ? query.split(',').map((i) => i.trim()).filter(Boolean) : [query.trim()].filter(Boolean);
const vals = commaQuery
? query
.split(',')
.map((i) => i.trim())
.filter(Boolean)
: [query.trim()].filter(Boolean);
if (vals.length > 0) {
const merged = Array.from(new Set([...selectedValues, ...vals]));
onApplyValues(merged);
@ -187,13 +237,19 @@ const ValueAutoComplete = observer(
return currentOptionsWithValue;
}, [options, selectedValues]);
const queryBlocks = commaQuery ? query.split(',').map(s => s.trim()).filter(Boolean) : [query.trim()].filter(Boolean);
const queryBlocks = commaQuery
? query
.split(',')
.map((s) => s.trim())
.filter(Boolean)
: [query.trim()].filter(Boolean);
const blocksAmount = queryBlocks.length;
const queryStr = useMemo(() => {
return queryBlocks.map(block => `"${block}"`).join(blocksAmount > 1 ? ', ' : '');
return queryBlocks
.map((block) => `"${block}"`)
.join(blocksAmount > 1 ? ', ' : '');
}, [queryBlocks]);
const onClearClick = (event: React.MouseEvent | React.KeyboardEvent) => {
event.stopPropagation(); // Prevent popover toggle
onApplyValues([]);
@ -208,10 +264,7 @@ const ValueAutoComplete = observer(
const isEmpty = initialValues[0] === '' || initialValues.length === 0;
const popoverContent = (
<div
style={{ width: 360 }}
onClick={(e) => e.stopPropagation()}
>
<div style={{ width: 360 }} onClick={(e) => e.stopPropagation()}>
<Input.Search
value={query}
loading={loadingSearch}
@ -226,18 +279,21 @@ const ValueAutoComplete = observer(
size="small"
locale={{
emptyText: t(
(loadingSearch || loadingTopValues) ? 'Loading...' :
(query.length > 0 ? 'No results found' : 'No options available')
)
loadingSearch || loadingTopValues
? 'Loading...'
: query.length > 0
? 'No results found'
: 'No options available',
),
}}
dataSource={sortedOptions}
renderItem={item => (
renderItem={(item) => (
<List.Item
key={item.value}
onClick={() => onSelectOption(item)}
className={cn(
'cursor-pointer w-full py-1 hover:bg-active-blue rounded px-2',
{ 'bg-active-blue-faded': isSelected(item) }
{ 'bg-active-blue-faded': isSelected(item) },
)}
role="option"
aria-selected={isSelected(item)}
@ -245,8 +301,16 @@ const ValueAutoComplete = observer(
>
<Space>
<Checkbox checked={isSelected(item)} readOnly tabIndex={-1} />
<Text ellipsis={{ tooltip: { title: item.label, placement: 'topLeft', mouseEnterDelay: 0.5 } }}
style={{ maxWidth: 'calc(360px - 80px)' }}>
<Text
ellipsis={{
tooltip: {
title: item.label,
placement: 'topLeft',
mouseEnterDelay: 0.5,
},
}}
style={{ maxWidth: 'calc(360px - 80px)' }}
>
{item.label}
</Text>
</Space>
@ -255,16 +319,23 @@ const ValueAutoComplete = observer(
style={{
maxHeight: 200,
overflowY: 'auto',
marginBottom: (query.trim().length > 0 && !loadingSearch) ? 8 : 0
marginBottom: query.trim().length > 0 && !loadingSearch ? 8 : 0,
}}
/>
{(query.trim().length > 0 && !loadingSearch && sortedOptions.length > 0) ? (
{query.trim().length > 0 &&
// sortedOptions.length > 0 &&
!loadingSearch ? (
<>
<Divider style={{ margin: '8px 0' }} />
<Button
type="link"
onClick={applyQuery}
style={{ paddingLeft: 8, whiteSpace: 'normal', height: 'auto', lineHeight: 'inherit' }}
style={{
paddingLeft: 8,
whiteSpace: 'normal',
height: 'auto',
lineHeight: 'inherit',
}}
>
{t('Apply search')}: {queryStr}
</Button>
@ -301,7 +372,7 @@ const ValueAutoComplete = observer(
onOpenChange={handleOpenChange}
placement="bottomLeft"
arrow={false}
getPopupContainer={triggerNode => triggerNode || document.body}
getPopupContainer={(triggerNode) => triggerNode || document.body}
>
<Button
className="pr-8"
@ -314,26 +385,40 @@ const ValueAutoComplete = observer(
<Space size={4} wrap className="w-full overflow-hidden">
{!isEmpty ? (
<>
<Text ellipsis={{
tooltip: {
title: mapValues ? mapValues(initialValues[0]) : initialValues[0],
placement: 'topLeft',
mouseEnterDelay: 0.5
}
}} style={{ maxWidth: '8rem' }}>
<Text
ellipsis={{
tooltip: {
title: mapValues
? mapValues(initialValues[0])
: initialValues[0],
placement: 'topLeft',
mouseEnterDelay: 0.5,
},
}}
style={{ maxWidth: '8rem' }}
>
{mapValues ? mapValues(initialValues[0]) : initialValues[0]}
</Text>
{initialValues.length > 1 && (
<>
<Text type="secondary" className="flex-shrink-0">{t('or')}</Text>
<Text ellipsis={{
tooltip: {
title: mapValues ? mapValues(initialValues[1]) : initialValues[1],
placement: 'topLeft',
mouseEnterDelay: 0.5
}
}} style={{ maxWidth: '8rem' }}>
{mapValues ? mapValues(initialValues[1]) : initialValues[1]}
<Text type="secondary" className="flex-shrink-0">
{t('or')}
</Text>
<Text
ellipsis={{
tooltip: {
title: mapValues
? mapValues(initialValues[1])
: initialValues[1],
placement: 'topLeft',
mouseEnterDelay: 0.5,
},
}}
style={{ maxWidth: '8rem' }}
>
{mapValues
? mapValues(initialValues[1])
: initialValues[1]}
</Text>
{initialValues.length > 2 && (
<Text type="secondary" className="flex-shrink-0">
@ -344,7 +429,10 @@ const ValueAutoComplete = observer(
)}
</>
) : (
<Text type={'secondary'} className={cn({ 'text-disabled': isDisabled })}>
<Text
type={'secondary'}
className={cn({ 'text-disabled': isDisabled })}
>
{placeholder}
</Text>
)}
@ -368,17 +456,16 @@ const ValueAutoComplete = observer(
height: '100%',
cursor: 'pointer',
zIndex: 1,
padding: '0 4px'
padding: '0 4px',
}}
>
<CloseCircleFilled />
</span>
)}
</Button>
</Popover>
);
}
},
);
export default ValueAutoComplete;

View file

@ -1,5 +1,5 @@
import { makeAutoObservable, runInAction } from 'mobx';
import { filterService } from 'App/services';
import { filterService, searchService } from 'App/services';
import { Filter, COMMON_FILTERS } from './types/filterConstants';
import { FilterKey } from 'Types/filter/filterType';
import { projectStore } from '@/mstore/index';
@ -18,6 +18,14 @@ interface ProjectFilters {
[projectId: string]: Filter[];
}
interface TopValuesParams {
id?: string;
siteId?: string;
source?: string;
isAutoCapture?: boolean;
isEvent?: boolean;
}
export default class FilterStore {
topValues: TopValues = {};
filters: ProjectFilters = {};
@ -33,6 +41,17 @@ export default class FilterStore {
this.initCommonFilters();
}
getEventOptions = (sietId: string) => {
return this.getFilters(sietId)
.filter((i: Filter) => i.isEvent)
.map((i: Filter) => {
return {
label: i.displayName || i.name,
value: i.name,
};
});
};
setTopValues = (key: string, values: Record<string, any> | TopValue[]) => {
const vals = Array.isArray(values) ? values : values.data;
this.topValues[key] = vals?.filter(
@ -44,19 +63,24 @@ export default class FilterStore {
this.topValues = {};
};
fetchTopValues = async (id: string, siteId: string, source?: string) => {
const valKey = `${siteId}_${id}${source || ''}`;
fetchTopValues = async (params: TopValuesParams) => {
const valKey = `${params.siteId}_${params.id}${params.source || ''}`;
if (this.topValues[valKey] && this.topValues[valKey].length) {
return Promise.resolve(this.topValues[valKey]);
}
const filter = this.filters[siteId]?.find((i) => i.id === id);
const filter = this.filters[params.siteId + '']?.find(
(i) => i.id === params.id,
);
if (!filter) {
console.error('Filter not found in store:', id);
console.error('Filter not found in store:', valKey);
return Promise.resolve([]);
}
return filterService
.fetchTopValues(filter.name?.toLowerCase(), source)
return searchService
.fetchTopValues({
[params.isEvent ? 'eventName' : 'propertyName']: filter.name,
})
.then((response: []) => {
this.setTopValues(valKey, response);
});
@ -84,7 +108,7 @@ export default class FilterStore {
...filter,
possibleTypes:
filter.possibleTypes?.map((type) => type.toLowerCase()) || [],
type: filter.possibleTypes?.[0].toLowerCase() || 'string',
dataType: filter.dataType || 'string',
category: category || 'custom',
subCategory:
category === 'events'
@ -157,7 +181,6 @@ export default class FilterStore {
getEventFilters = async (eventName: string): Promise<Filter[]> => {
const cacheKey = `${projectStore.activeSiteId}_${eventName}`;
console.log('cacheKey store', cacheKey);
if (this.filterCache[cacheKey]) {
return this.filterCache[cacheKey];
}
@ -170,6 +193,7 @@ export default class FilterStore {
this.pendingFetches[cacheKey] =
this.fetchAndProcessPropertyFilters(eventName);
const filters = await this.pendingFetches[cacheKey];
console.log('filters', filters);
runInAction(() => {
this.filterCache[cacheKey] = filters;
@ -185,14 +209,19 @@ export default class FilterStore {
private fetchAndProcessPropertyFilters = async (
eventName: string,
isAutoCapture?: boolean,
): Promise<Filter[]> => {
const resp = await filterService.fetchProperties(eventName);
const resp = await filterService.fetchProperties(eventName, isAutoCapture);
const names = resp.data.map((i: any) => i['name']);
const activeSiteId = projectStore.activeSiteId + '';
return (
this.filters[activeSiteId]?.filter((i: any) => names.includes(i.name)) ||
[]
this.filters[activeSiteId]
?.filter((i: any) => names.includes(i.name))
.map((f: any) => ({
...f,
eventName,
})) || []
);
};

View file

@ -10,12 +10,12 @@ type FilterData = Partial<FilterItem> & {
operator?: string;
sourceOperator?: string;
source?: any;
filters?: FilterData[]
filters?: FilterData[];
};
export const checkFilterValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.length === 0 ? [''] : value.map(val => String(val ?? ''));
return value.length === 0 ? [''] : value.map((val) => String(val ?? ''));
}
if (value === null || value === undefined) {
return [''];
@ -74,7 +74,7 @@ export interface IFilterStore {
value: unknown,
operator?: string,
sourceOperator?: string,
source?: unknown
source?: unknown,
): void;
}
@ -100,59 +100,69 @@ export default class FilterStore implements IFilterStore {
constructor(
initialFilters: FilterData[] = [],
isConditional = false,
isMobile = false
isMobile = false,
) {
this.isConditional = isConditional;
this.isMobile = isMobile;
this.filters = initialFilters.map(
(filterData) => this.createFilterItemFromData(filterData)
this.filters = initialFilters.map((filterData) =>
this.createFilterItemFromData(filterData),
);
makeAutoObservable(this, {
filters: observable.shallow,
excludes: observable.shallow,
eventsOrder: observable,
startTimestamp: observable,
endTimestamp: observable,
name: observable,
page: observable,
limit: observable,
autoOpen: observable,
filterId: observable,
eventsHeader: observable,
merge: action,
addFilter: action,
replaceFilters: action,
updateFilter: action,
removeFilter: action,
fromJson: action,
fromData: action,
addExcludeFilter: action,
updateExcludeFilter: action,
removeExcludeFilter: action,
addFunnelDefaultFilters: action,
addOrUpdateFilter: action,
addFilterByKeyAndValue: action,
isConditional: false,
isMobile: false,
eventsOrderSupport: false,
ID_KEY: false
}, { autoBind: true });
makeAutoObservable(
this,
{
filters: observable.shallow,
excludes: observable.shallow,
eventsOrder: observable,
startTimestamp: observable,
endTimestamp: observable,
name: observable,
page: observable,
limit: observable,
autoOpen: observable,
filterId: observable,
eventsHeader: observable,
merge: action,
addFilter: action,
replaceFilters: action,
updateFilter: action,
removeFilter: action,
fromJson: action,
fromData: action,
addExcludeFilter: action,
updateExcludeFilter: action,
removeExcludeFilter: action,
addFunnelDefaultFilters: action,
addOrUpdateFilter: action,
addFilterByKeyAndValue: action,
isConditional: false,
isMobile: false,
eventsOrderSupport: false,
ID_KEY: false,
},
{ autoBind: true },
);
}
merge(filterData: Partial<FilterStore>) {
runInAction(() => {
const validKeys = Object.keys(this).filter(key => typeof (this as any)[key] !== 'function' && key !== 'eventsOrderSupport' && key !== 'isConditional' && key !== 'isMobile');
const validKeys = Object.keys(this).filter(
(key) =>
typeof (this as any)[key] !== 'function' &&
key !== 'eventsOrderSupport' &&
key !== 'isConditional' &&
key !== 'isMobile',
);
for (const key in filterData) {
if (validKeys.includes(key)) {
(this as any)[key] = (filterData as any)[key];
}
}
if (filterData.filters) {
this.filters = filterData.filters.map(f => f);
this.filters = filterData.filters.map((f) => f);
}
if (filterData.excludes) {
this.excludes = filterData.excludes.map(f => f);
this.excludes = filterData.excludes.map((f) => f);
}
});
}
@ -165,10 +175,12 @@ export default class FilterStore implements IFilterStore {
private createFilterItemFromData(filterData: FilterData): FilterItem {
const dataWithValue = {
...filterData,
value: checkFilterValue(filterData.value)
value: checkFilterValue(filterData.value),
};
if (Array.isArray(dataWithValue.filters)) {
dataWithValue.filters = dataWithValue.filters.map(nestedFilter => this.createFilterItemFromData(nestedFilter));
dataWithValue.filters = dataWithValue.filters.map((nestedFilter) =>
this.createFilterItemFromData(nestedFilter),
);
}
return new FilterItem(dataWithValue);
}
@ -179,7 +191,7 @@ export default class FilterStore implements IFilterStore {
}
replaceFilters(newFilters: FilterItem[]) {
this.filters = newFilters.map(f => f);
this.filters = newFilters.map((f) => f);
}
private updateFilterByIndex(index: number, filterData: FilterData) {
@ -194,22 +206,26 @@ export default class FilterStore implements IFilterStore {
}
updateFilter(filterId: string, filterData: FilterData) {
const index = this.filters.findIndex(f => f.id === filterId);
const index = this.filters.findIndex((f) => f.id === filterId);
if (index > -1) {
const updatedFilter = this.createFilterItemFromData(filterData);
updatedFilter.id = filterId; // Ensure the ID remains the same
this.filters[index] = updatedFilter;
} else {
console.warn(`FilterStore.updateFilter: Filter with id ${filterId} not found.`);
console.warn(
`FilterStore.updateFilter: Filter with id ${filterId} not found.`,
);
}
}
removeFilter(filterId: string) {
const index = this.filters.findIndex(f => f.id === filterId);
const index = this.filters.findIndex((f) => f.id === filterId);
if (index > -1) {
this.filters.splice(index, 1);
} else {
console.warn(`FilterStore.removeFilter: Filter with id ${filterId} not found.`);
console.warn(
`FilterStore.removeFilter: Filter with id ${filterId} not found.`,
);
}
}
@ -218,20 +234,20 @@ export default class FilterStore implements IFilterStore {
this.name = json.name ?? '';
this.filters = Array.isArray(json.filters)
? json.filters.map((filterJson: JsonData) =>
new FilterItem().fromJson(filterJson)
)
new FilterItem().fromJson(filterJson),
)
: [];
this.excludes = Array.isArray(json.excludes)
? json.excludes.map((filterJson: JsonData) =>
new FilterItem().fromJson(filterJson)
)
new FilterItem().fromJson(filterJson),
)
: [];
this.eventsOrder = json.eventsOrder ?? 'then';
this.startTimestamp = json.startTimestamp ?? 0;
this.endTimestamp = json.endTimestamp ?? 0;
this.page = json.page ?? 1;
this.limit = json.limit ?? 10;
this.autoOpen = json.autoOpen ?? false;
// this.autoOpen = json.autoOpen ?? false;
this.filterId = json.filterId ?? '';
this.eventsHeader = json.eventsHeader ?? 'EVENTS';
});
@ -242,16 +258,16 @@ export default class FilterStore implements IFilterStore {
runInAction(() => {
this.name = data.name ?? '';
this.filters = Array.isArray(data.filters)
? data.filters.map((filterData: JsonData) =>
this.createFilterItemFromData(filterData)
// new FilterItem(undefined, this.isConditional, this.isMobile).fromData(filterData)
)
? data.filters.map(
(filterData: JsonData) => this.createFilterItemFromData(filterData),
// new FilterItem(undefined, this.isConditional, this.isMobile).fromData(filterData)
)
: [];
this.excludes = Array.isArray(data.excludes)
? data.excludes.map((filterData: JsonData) =>
this.createFilterItemFromData(filterData)
// new FilterItem(undefined, this.isConditional, this.isMobile).fromData(filterData)
)
? data.excludes.map(
(filterData: JsonData) => this.createFilterItemFromData(filterData),
// new FilterItem(undefined, this.isConditional, this.isMobile).fromData(filterData)
)
: [];
this.eventsOrder = data.eventsOrder ?? 'then';
this.startTimestamp = data.startTimestamp ?? 0;
@ -271,14 +287,16 @@ export default class FilterStore implements IFilterStore {
filters: this.filters.map((filterItem) => filterItem.toJson()),
eventsOrder: this.eventsOrder,
startTimestamp: this.startTimestamp,
endTimestamp: this.endTimestamp
endTimestamp: this.endTimestamp,
};
}
createFilterByKey(key: FilterKey | string): FilterItem {
const sourceMap = this.isConditional ? conditionalFiltersMap : filtersMap;
const filterTemplate = sourceMap[key as FilterKey];
const newFilterData = filterTemplate ? { ...filterTemplate, value: [''] } : { key: key, value: [''] };
const newFilterData = filterTemplate
? { ...filterTemplate, value: [''] }
: { key: key, value: [''] };
return this.createFilterItemFromData(newFilterData); // Use helper
}
@ -294,7 +312,7 @@ export default class FilterStore implements IFilterStore {
endTimestamp: this.endTimestamp,
eventsHeader: this.eventsHeader,
page: this.page,
limit: this.limit
limit: this.limit,
};
}
@ -304,22 +322,26 @@ export default class FilterStore implements IFilterStore {
}
updateExcludeFilter(filterId: string, filterData: FilterData) {
const index = this.excludes.findIndex(f => f.id === filterId);
const index = this.excludes.findIndex((f) => f.id === filterId);
if (index > -1) {
const updatedExclude = this.createFilterItemFromData(filterData);
updatedExclude.id = filterId; // Ensure the ID remains the same
this.excludes[index] = updatedExclude;
} else {
console.warn(`FilterStore.updateExcludeFilter: Exclude filter with id ${filterId} not found.`);
console.warn(
`FilterStore.updateExcludeFilter: Exclude filter with id ${filterId} not found.`,
);
}
}
removeExcludeFilter(filterId: string) {
const index = this.excludes.findIndex(f => f.id === filterId);
const index = this.excludes.findIndex((f) => f.id === filterId);
if (index > -1) {
this.excludes.splice(index, 1);
} else {
console.warn(`FilterStore.removeExcludeFilter: Exclude filter with id ${filterId} not found.`);
console.warn(
`FilterStore.removeExcludeFilter: Exclude filter with id ${filterId} not found.`,
);
}
}
@ -331,10 +353,12 @@ export default class FilterStore implements IFilterStore {
this.addFilter({
...locationFilterData,
value: [''],
operator: 'isAny'
operator: 'isAny',
});
} else {
console.warn(`FilterStore.addFunnelDefaultFilters: Default filter not found for key ${FilterKey.LOCATION}`);
console.warn(
`FilterStore.addFunnelDefaultFilters: Default filter not found for key ${FilterKey.LOCATION}`,
);
}
const clickFilterData = filtersMap[FilterKey.CLICK];
@ -342,10 +366,12 @@ export default class FilterStore implements IFilterStore {
this.addFilter({
...clickFilterData,
value: [''],
operator: 'onAny'
operator: 'onAny',
});
} else {
console.warn(`FilterStore.addFunnelDefaultFilters: Default filter not found for key ${FilterKey.CLICK}`);
console.warn(
`FilterStore.addFunnelDefaultFilters: Default filter not found for key ${FilterKey.CLICK}`,
);
}
});
}
@ -354,7 +380,7 @@ export default class FilterStore implements IFilterStore {
const index = this.filters.findIndex((f) => f.key === filterData.key);
const dataWithCheckedValue = {
...filterData,
value: checkFilterValue(filterData.value)
value: checkFilterValue(filterData.value),
};
if (index > -1) {
@ -369,7 +395,7 @@ export default class FilterStore implements IFilterStore {
value: unknown,
operator?: string,
sourceOperator?: string,
source?: unknown
source?: unknown,
) {
const sourceMap = this.isConditional ? conditionalFiltersMap : filtersMap;
const defaultFilterData = sourceMap[key as FilterKey];
@ -381,12 +407,20 @@ export default class FilterStore implements IFilterStore {
value: checkFilterValue(value),
operator: operator ?? defaultFilterData.operator,
sourceOperator: sourceOperator ?? defaultFilterData.sourceOperator,
source: source ?? defaultFilterData.source
source: source ?? defaultFilterData.source,
};
this.addOrUpdateFilter(newFilterData);
} else {
console.warn(`FilterStore.addFilterByKeyAndValue: No default filter template found for key ${key}. Adding generic filter.`);
this.addOrUpdateFilter({ key: key, value: checkFilterValue(value), operator, sourceOperator, source });
console.warn(
`FilterStore.addFilterByKeyAndValue: No default filter template found for key ${key}. Adding generic filter.`,
);
this.addOrUpdateFilter({
key: key,
value: checkFilterValue(value),
operator,
sourceOperator,
source,
});
}
}
}

View file

@ -20,6 +20,7 @@ export interface Filter {
displayName?: string;
description?: string;
possibleTypes?: string[];
dataType?: string;
autoCaptured?: boolean;
metadataName?: string;
category: string; // 'event' | 'filter' | 'action' | etc.
@ -38,125 +39,217 @@ export interface Filter {
export const OPERATORS = {
string: [
{ value: 'is', label: 'is', displayName: 'Is', description: 'Exact match' },
{ value: 'isNot', label: 'is not', displayName: 'Is not', description: 'Not an exact match' },
{ value: 'contains', label: 'contains', displayName: 'Contains', description: 'Contains the string' },
{
value: 'isNot',
label: 'is not',
displayName: 'Is not',
description: 'Not an exact match',
},
{
value: 'contains',
label: 'contains',
displayName: 'Contains',
description: 'Contains the string',
},
{
value: 'doesNotContain',
label: 'does not contain',
displayName: 'Does not contain',
description: 'Does not contain the string'
description: 'Does not contain the string',
},
{
value: 'startsWith',
label: 'starts with',
displayName: 'Starts with',
description: 'Starts with the string',
},
{
value: 'endsWith',
label: 'ends with',
displayName: 'Ends with',
description: 'Ends with the string',
},
{
value: 'isBlank',
label: 'is blank',
displayName: 'Is blank',
description: 'Is empty or null',
},
{
value: 'isNotBlank',
label: 'is not blank',
displayName: 'Is not blank',
description: 'Is not empty or null',
},
{ value: 'startsWith', label: 'starts with', displayName: 'Starts with', description: 'Starts with the string' },
{ value: 'endsWith', label: 'ends with', displayName: 'Ends with', description: 'Ends with the string' },
{ value: 'isBlank', label: 'is blank', displayName: 'Is blank', description: 'Is empty or null' },
{ value: 'isNotBlank', label: 'is not blank', displayName: 'Is not blank', description: 'Is not empty or null' }
],
number: [
{ value: 'equals', label: 'equals', displayName: 'Equals', description: 'Exactly equals the value' },
{
value: 'equals',
label: 'equals',
displayName: 'Equals',
description: 'Exactly equals the value',
},
{
value: 'doesNotEqual',
label: 'does not equal', // Fixed: added space
displayName: 'Does not equal',
description: 'Does not equal the value'
description: 'Does not equal the value',
},
{ value: 'greaterThan', label: 'greater than', displayName: 'Greater than', description: 'Greater than the value' },
{
value: 'lessThan', label: 'less than', // Fixed: added space and lowercased
displayName: 'Less than', description: 'Less than the value'
value: 'greaterThan',
label: 'greater than',
displayName: 'Greater than',
description: 'Greater than the value',
},
{
value: 'lessThan',
label: 'less than', // Fixed: added space and lowercased
displayName: 'Less than',
description: 'Less than the value',
},
{
value: 'greaterThanOrEquals',
label: 'greater than or equals', // Fixed: added spaces and lowercased
displayName: 'Greater than or equals',
description: 'Greater than or equal to the value'
description: 'Greater than or equal to the value',
},
{
value: 'lessThanOrEquals',
label: 'less than or equals', // Fixed: added spaces and lowercased
displayName: 'Less than or equals',
description: 'Less than or equal to the value'
description: 'Less than or equal to the value',
},
{
value: 'isBlank', label: 'is blank', // Fixed: added space and lowercased
displayName: 'Is blank', description: 'Is empty or null'
value: 'isBlank',
label: 'is blank', // Fixed: added space and lowercased
displayName: 'Is blank',
description: 'Is empty or null',
},
{
value: 'isNotBlank', label: 'is not blank', // Fixed: added spaces and lowercased
displayName: 'Is not blank', description: 'Is not empty or null'
}
value: 'isNotBlank',
label: 'is not blank', // Fixed: added spaces and lowercased
displayName: 'Is not blank',
description: 'Is not empty or null',
},
],
boolean: [
{
value: 'isTrue', label: 'is true', // Fixed: added space and lowercased
displayName: 'Is true', description: 'Value is true'
value: 'isTrue',
label: 'is true', // Fixed: added space and lowercased
displayName: 'Is true',
description: 'Value is true',
},
{
value: 'isFalse', label: 'is false', // Fixed: added space and lowercased
displayName: 'Is false', description: 'Value is false'
value: 'isFalse',
label: 'is false', // Fixed: added space and lowercased
displayName: 'Is false',
description: 'Value is false',
},
{
value: 'isBlank', label: 'is blank', // Fixed: added space and lowercased
displayName: 'Is blank', description: 'Is null'
value: 'isBlank',
label: 'is blank', // Fixed: added space and lowercased
displayName: 'Is blank',
description: 'Is null',
},
{
value: 'isNotBlank', label: 'is not blank', // Fixed: added spaces and lowercased
displayName: 'Is not blank', description: 'Is not null'
}
value: 'isNotBlank',
label: 'is not blank', // Fixed: added spaces and lowercased
displayName: 'Is not blank',
description: 'Is not null',
},
],
date: [
{ value: 'on', label: 'on', displayName: 'On', description: 'On the exact date' },
{
value: 'notOn', label: 'not on', // Fixed: added space and lowercased
displayName: 'Not on', description: 'Not on the exact date'
},
{ value: 'before', label: 'before', displayName: 'Before', description: 'Before the date' },
{ value: 'after', label: 'after', displayName: 'After', description: 'After the date' },
{
value: 'onOrBefore', label: 'on or before', // Fixed: added spaces and lowercased
displayName: 'On or before', description: 'On or before the date'
value: 'on',
label: 'on',
displayName: 'On',
description: 'On the exact date',
},
{
value: 'onOrAfter', label: 'on or after', // Fixed: added spaces and lowercased
displayName: 'On or after', description: 'On or after the date'
value: 'notOn',
label: 'not on', // Fixed: added space and lowercased
displayName: 'Not on',
description: 'Not on the exact date',
},
{
value: 'isBlank', label: 'is blank', // Fixed: added space and lowercased
displayName: 'Is blank', description: 'Is empty or null'
value: 'before',
label: 'before',
displayName: 'Before',
description: 'Before the date',
},
{
value: 'isNotBlank', label: 'is not blank', // Fixed: added spaces and lowercased
displayName: 'Is not blank', description: 'Is not empty or null'
}
value: 'after',
label: 'after',
displayName: 'After',
description: 'After the date',
},
{
value: 'onOrBefore',
label: 'on or before', // Fixed: added spaces and lowercased
displayName: 'On or before',
description: 'On or before the date',
},
{
value: 'onOrAfter',
label: 'on or after', // Fixed: added spaces and lowercased
displayName: 'On or after',
description: 'On or after the date',
},
{
value: 'isBlank',
label: 'is blank', // Fixed: added space and lowercased
displayName: 'Is blank',
description: 'Is empty or null',
},
{
value: 'isNotBlank',
label: 'is not blank', // Fixed: added spaces and lowercased
displayName: 'Is not blank',
description: 'Is not empty or null',
},
],
array: [
{ value: 'contains', label: 'contains', displayName: 'Contains', description: 'Array contains the value' },
{
value: 'contains',
label: 'contains',
displayName: 'Contains',
description: 'Array contains the value',
},
{
value: 'doesNotContain',
label: 'does not contain', // Fixed: added spaces and lowercased
displayName: 'Does not contain',
description: 'Array does not contain the value'
description: 'Array does not contain the value',
},
{
value: 'hasAny', label: 'has any', // Fixed: added space and lowercased
displayName: 'Has any', description: 'Array has any of the values'
value: 'hasAny',
label: 'has any', // Fixed: added space and lowercased
displayName: 'Has any',
description: 'Array has any of the values',
},
{
value: 'hasAll', label: 'has all', // Fixed: added space and lowercased
displayName: 'Has all', description: 'Array has all of the values'
value: 'hasAll',
label: 'has all', // Fixed: added space and lowercased
displayName: 'Has all',
description: 'Array has all of the values',
},
{
value: 'isEmpty', label: 'is empty', // Fixed: added space and lowercased
displayName: 'Is empty', description: 'Array is empty'
value: 'isEmpty',
label: 'is empty', // Fixed: added space and lowercased
displayName: 'Is empty',
description: 'Array is empty',
},
{
value: 'isNotEmpty', label: 'is not empty', // Fixed: added spaces and lowercased
displayName: 'Is not empty', description: 'Array is not empty'
}
]
value: 'isNotEmpty',
label: 'is not empty', // Fixed: added spaces and lowercased
displayName: 'Is not empty',
description: 'Array is not empty',
},
],
};
export const COMMON_FILTERS: Filter[] = [];

View file

@ -23,13 +23,14 @@ export default class FilterItem {
value?: string[];
propertyOrder?: string;
filters?: FilterItem[];
autoOpen?: boolean;
constructor(data: any = {}) {
makeAutoObservable(this);
if (Array.isArray(data.filters)) {
data.filters = data.filters.map(
(i: Record<string, any>) => new FilterItem(i)
(i: Record<string, any>) => new FilterItem(i),
);
}
data.operator = data.operator || 'is';
@ -82,7 +83,7 @@ export default class FilterItem {
source: this.name,
filters: Array.isArray(this.filters)
? this.filters.map((i) => i.toJson())
: []
: [],
};
const isMetadata = this.category === FilterCategory.METADATA;

View file

@ -395,12 +395,12 @@ export default class Widget {
} else if (this.metricType === FUNNEL) {
_data.funnel = new Funnel().fromJSON(data);
} else if (this.metricType === TABLE) {
const count = data[0]['count'];
const vals = data[0]['values'].map((s: any) =>
const count = data['count'];
const vals = data['values'].map((s: any) =>
new SessionsByRow().fromJson(s, count, this.metricOf),
);
_data['values'] = vals
_data['total'] = data[0]['total'];
_data['values'] = vals;
_data['total'] = data['total'];
} else {
if (data.hasOwnProperty('chart')) {
_data['value'] = data.value;

View file

@ -24,8 +24,12 @@ export default class FilterService {
return await response.json();
};
fetchProperties = async (name: string) => {
let path = `/pa/PROJECT_ID/properties/search?event_name=${name}`;
fetchProperties = async (
eventName: string,
isAutoCapture: boolean = false,
) => {
// en = eventName, ac = isAutoCapture
let path = `/pa/PROJECT_ID/properties/search?en=${eventName}&ac=${isAutoCapture}`;
const response = await this.client.get(path);
return await response.json();
};

View file

@ -46,8 +46,20 @@ export default class SearchService extends BaseService {
return j.data;
}
async fetchTopValues(params: {}): Promise<any> {
const r = await this.client.get(
'/pa/PROJECT_ID/properties/autocomplete',
params,
);
const j = await r.json();
return j.data;
}
async fetchAutoCompleteValues(params: {}): Promise<any> {
const r = await this.client.get('/PROJECT_ID/events/search', params);
const r = await this.client.get(
'/pa/PROJECT_ID/properties/autocomplete',
params,
);
const j = await r.json();
return j.data;
}

View file

@ -210,9 +210,9 @@ export enum FilterKey {
MISSING_RESOURCE = 'missingResource',
SLOW_SESSION = 'slowSession',
CLICK_RAGE = 'clickRage',
CLICK = 'click',
INPUT = 'input',
LOCATION = 'location',
CLICK = 'CLICK',
INPUT = 'INPUT',
LOCATION = 'LOCATION',
VIEW = 'view',
CONSOLE = 'console',
METADATA = 'metadata',
@ -279,11 +279,7 @@ export enum FilterKey {
AVG_REQUEST_LOADT_IME = 'avgRequestLoadTime',
AVG_RESPONSE_TIME = 'avgResponseTime',
AVG_SESSION_DURATION = 'avgSessionDuration',
AVG_TILL_FIRST_BYTE = 'avgTillFirstByte',
AVG_TIME_TO_INTERACTIVE = 'avgTimeToInteractive',
AVG_TIME_TO_RENDER = 'avgTimeToRender',
AVG_USED_JS_HEAP_SIZE = 'avgUsedJsHeapSize',
AVG_VISITED_PAGES = 'avgVisitedPages',
COUNT_REQUESTS = 'countRequests',
COUNT_SESSIONS = 'countSessions',
@ -305,10 +301,6 @@ export enum FilterKey {
PAGES_RESPONSE_TIME = 'pagesResponseTime',
PAGES_RESPONSE_TIME_DISTRIBUTION = 'pagesResponseTimeDistribution',
SESSIONS_PER_BROWSER = 'sessionsPerBrowser',
SLOWEST_DOMAINS = 'slowestDomains',
SPEED_LOCATION = 'speedLocation',
TIME_TO_RENDER = 'timeToRender',
IMPACTED_SESSIONS_BY_SLOW_PAGES = 'impactedSessionsBySlowPages',
CLICKMAP_URL = 'clickMapUrl',
FEATURE_FLAG = 'featureFlag',