feat(ui): dynamic fitlers

This commit is contained in:
Shekar Siri 2025-04-03 15:15:30 +02:00
parent 235364b968
commit 7cf4f50b36
22 changed files with 1389 additions and 928 deletions

View file

@ -102,7 +102,7 @@ const HIGHLIGHTS_PATH = routes.highlights();
const KAI_PATH = routes.kai(); const KAI_PATH = routes.kai();
function PrivateRoutes() { function PrivateRoutes() {
const { projectsStore, userStore, integrationsStore, searchStore } = const { projectsStore, userStore, integrationsStore, searchStore, filterStore } =
useStore(); useStore();
const onboarding = userStore.onboarding; const onboarding = userStore.onboarding;
const scope = userStore.scopeState; const scope = userStore.scopeState;
@ -121,6 +121,7 @@ function PrivateRoutes() {
if (siteId && integrationsStore.integrations.siteId !== siteId) { if (siteId && integrationsStore.integrations.siteId !== siteId) {
integrationsStore.integrations.setSiteId(siteId); integrationsStore.integrations.setSiteId(siteId);
void integrationsStore.integrations.fetchIntegrations(siteId); void integrationsStore.integrations.fetchIntegrations(siteId);
void filterStore.fetchFilters(siteId)
} }
}, [siteId]); }, [siteId]);

View file

@ -4,6 +4,8 @@ import { PlusIcon } from 'lucide-react';
import { Button } from 'antd'; import { Button } from 'antd';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Filter } from '@/mstore/types/filterConstants';
import { observer } from 'mobx-react-lite';
interface Props { interface Props {
series: any; series: any;
@ -12,8 +14,10 @@ interface Props {
function AddStepButton({ series, excludeFilterKeys }: Props) { function AddStepButton({ series, excludeFilterKeys }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const { metricStore } = useStore(); const { metricStore, filterStore } = useStore();
const metric: any = metricStore.instance; const metric: any = metricStore.instance;
const filters: Filter[] = filterStore.getCurrentProjectFilters();
// console.log('filters', filters)
const onAddFilter = (filter: any) => { const onAddFilter = (filter: any) => {
series.filter.addFilter(filter); series.filter.addFilter(filter);
@ -21,9 +25,9 @@ function AddStepButton({ series, excludeFilterKeys }: Props) {
}; };
return ( return (
<FilterSelection <FilterSelection
filter={undefined} filters={filters}
onFilterClick={onAddFilter} onFilterClick={onAddFilter}
excludeFilterKeys={excludeFilterKeys} mode={'filters'} // excludeFilterKeys={excludeFilterKeys}
> >
<Button <Button
type="text" type="text"
@ -37,4 +41,4 @@ function AddStepButton({ series, excludeFilterKeys }: Props) {
); );
} }
export default AddStepButton; export default observer(AddStepButton);

View file

@ -11,7 +11,7 @@ import {
newFFlag, newFFlag,
fflag, fflag,
fflagRead, fflagRead,
bookmarks, bookmarks
} from 'App/routes'; } from 'App/routes';
import { withRouter, RouteComponentProps, useLocation } from 'react-router-dom'; import { withRouter, RouteComponentProps, useLocation } from 'react-router-dom';
import FlagView from 'Components/FFlags/FlagView/FlagView'; import FlagView from 'Components/FFlags/FlagView/FlagView';
@ -29,15 +29,18 @@ interface IProps extends RouteComponentProps {
}; };
}; };
} }
// TODO should move these routes to the Routes file // TODO should move these routes to the Routes file
function Overview({ match: { params } }: IProps) { function Overview({ match: { params } }: IProps) {
const { searchStore } = useStore(); const { searchStore, filterStore, projectsStore } = useStore();
const { siteId, fflagId } = params; const { siteId, fflagId } = params;
const location = useLocation(); const location = useLocation();
const tab = location.pathname.split('/')[2]; const tab = location.pathname.split('/')[2];
const { activeSiteId } = projectsStore;
React.useEffect(() => { React.useEffect(() => {
searchStore.setActiveTab(tab); searchStore.setActiveTab(tab);
// void filterStore.fetchFilters(activeSiteId + '');
}, [tab]); }, [tab]);
return ( return (
@ -69,5 +72,5 @@ function Overview({ match: { params } }: IProps) {
} }
export default withPageTitle('Sessions - OpenReplay')( export default withPageTitle('Sessions - OpenReplay')(
withRouter(observer(Overview)), withRouter(observer(Overview))
); );

View file

@ -44,12 +44,12 @@ interface Props {
const FilterAutoComplete = observer( const FilterAutoComplete = observer(
({ ({
params = {}, params = {},
onClose, onClose,
onApply, onApply,
values, values,
placeholder, placeholder
}: { }: {
params: any; params: any;
values: string[]; values: string[];
onClose: () => void; onClose: () => void;
@ -57,32 +57,32 @@ const FilterAutoComplete = observer(
placeholder?: string; placeholder?: string;
}) => { }) => {
const [options, setOptions] = useState<{ value: string; label: string }[]>( const [options, setOptions] = useState<{ value: string; label: string }[]>(
[], []
); );
const [initialFocus, setInitialFocus] = useState(false); const [initialFocus, setInitialFocus] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { filterStore, projectsStore } = useStore(); const { filterStore, projectsStore } = useStore();
const _params = processKey(params); const _params = processKey(params);
const filterKey = `${projectsStore.siteId}_${_params.type}${_params.key || ''}`; const filterKey = `${projectsStore.siteId}_${params.id}`;
const topValues = filterStore.topValues[filterKey] || []; const topValues = filterStore.topValues[filterKey] || [];
React.useEffect(() => { React.useEffect(() => {
setOptions([]) setOptions([]);
}, [projectsStore.siteId]) }, [projectsStore.siteId]);
const loadTopValues = async () => { const loadTopValues = async () => {
setLoading(true) setLoading(true);
if (projectsStore.siteId) { if (projectsStore.siteId) {
await filterStore.fetchTopValues(_params.type, projectsStore.siteId, _params.key); await filterStore.fetchTopValues(params.id, projectsStore.siteId);
} }
setLoading(false) setLoading(false);
}; };
useEffect(() => { useEffect(() => {
if (topValues.length > 0) { if (topValues.length > 0) {
const mappedValues = topValues.map((i) => ({ const mappedValues = topValues.map((i) => ({
value: i.value, value: i.value,
label: i.value, label: i.value
})); }));
setOptions(mappedValues); setOptions(mappedValues);
} }
@ -96,7 +96,7 @@ const FilterAutoComplete = observer(
if (!inputValue.length) { if (!inputValue.length) {
const mappedValues = topValues.map((i) => ({ const mappedValues = topValues.map((i) => ({
value: i.value, value: i.value,
label: i.value, label: i.value
})); }));
setOptions(mappedValues); setOptions(mappedValues);
return; return;
@ -104,8 +104,8 @@ const FilterAutoComplete = observer(
setLoading(true); setLoading(true);
try { try {
const data = await searchService.fetchAutoCompleteValues({ const data = await searchService.fetchAutoCompleteValues({
..._params, type: params.name?.toLowerCase(),
q: inputValue, q: inputValue
}); });
const _options = const _options =
data.map((i: any) => ({ value: i.value, label: i.value })) || []; data.map((i: any) => ({ value: i.value, label: i.value })) || [];
@ -119,7 +119,7 @@ const FilterAutoComplete = observer(
const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [ const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [
params, params,
topValues, topValues
]); ]);
const handleInputChange = (newValue: string) => { const handleInputChange = (newValue: string) => {
@ -146,7 +146,7 @@ const FilterAutoComplete = observer(
placeholder={placeholder} placeholder={placeholder}
/> />
); );
}, }
); );
function AutoCompleteController(props: Props) { function AutoCompleteController(props: Props) {

View file

@ -1,29 +1,32 @@
import React from 'react'; import React, { useMemo, useCallback, useState, useEffect } from 'react';
import { Button } from 'antd'; import { Button, Space, Typography } from 'antd';
import { FilterKey, FilterType } from 'App/types/filter/filterType'; import { FilterKey } from 'App/types/filter/filterType';
import { CircleMinus } from 'lucide-react'; import { CircleMinus, Filter as FilterIcon } 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';
import FilterValue from '../FilterValue'; import FilterValue from '../FilterValue';
import FilterSource from '../FilterSource'; import FilterSource from '../FilterSource';
import SubFilterItem from '../SubFilterItem'; import { useStore } from '@/mstore';
import { getIconForFilter } from 'Shared/Filters/FilterModal/FilterModal';
import { Filter, getOperatorsByType } from '@/mstore/types/filterConstants';
interface Props { interface Props {
filterIndex?: number; filterIndex?: number;
filter: any; // event/filter filter: any;
onUpdate: (filter: any) => void; onUpdate: (filter: any) => void;
onRemoveFilter: () => void; onRemoveFilter: () => void;
isFilter?: boolean; isFilter?: boolean;
saveRequestPayloads?: boolean; saveRequestPayloads?: boolean;
disableDelete?: boolean; disableDelete?: boolean;
excludeFilterKeys?: Array<string>;
excludeCategory?: Array<string>;
allowedFilterKeys?: Array<string>;
readonly?: boolean; readonly?: boolean;
hideIndex?: boolean; hideIndex?: boolean;
hideDelete?: boolean; hideDelete?: boolean;
isConditional?: boolean; isConditional?: boolean;
isSubItem?: boolean;
subFilterIndex?: number;
propertyOrder?: string;
onToggleOperator?: (newOp: string) => void;
} }
function FilterItem(props: Props) { function FilterItem(props: Props) {
@ -34,162 +37,290 @@ function FilterItem(props: Props) {
saveRequestPayloads, saveRequestPayloads,
disableDelete = false, disableDelete = false,
hideDelete = false, hideDelete = false,
allowedFilterKeys = [],
excludeFilterKeys = [],
excludeCategory = [],
isConditional, isConditional,
hideIndex = false, hideIndex = false,
onUpdate,
onRemoveFilter,
readonly,
isSubItem = false,
subFilterIndex,
propertyOrder,
onToggleOperator
} = props; } = props;
const canShowValues = !( const [eventFilterOptions, setEventFilterOptions] = useState<Filter[]>([]);
filter.operator === 'isAny' ||
filter.operator === 'onAny' || const { filterStore } = useStore();
filter.operator === 'isUndefined' const allFilters = filterStore.getCurrentProjectFilters();
const eventSelections = allFilters.filter((i) => i.isEvent === filter.isEvent);
const filterSelections = isSubItem ? eventFilterOptions : eventSelections;
const [eventFiltersLoading, setEventFiltersLoading] = useState(false);
const operatorOptions = getOperatorsByType(filter.type);
useEffect(() => {
async function loadFilters() {
try {
setEventFiltersLoading(true);
const options = await filterStore.getEventFilters(filter.name);
setEventFilterOptions(options);
} finally {
setEventFiltersLoading(false);
}
}
void loadFilters();
}, [filter.name]); // Re-fetch when filter name changes
const canShowValues = useMemo(
() =>
!(
filter.operator === 'isAny' ||
filter.operator === 'onAny' ||
filter.operator === 'isUndefined'
),
[filter.operator]
); );
const isSubFilter = filter.type === FilterType.SUB_FILTERS;
const replaceFilter = (filter: any) => { const isReversed = useMemo(() => filter.key === FilterKey.TAGGED_ELEMENT, [filter.key]);
props.onUpdate({
...filter, const replaceFilter = useCallback(
value: filter.value, (selectedFilter: any) => {
filters: filter.filters onUpdate({
? filter.filters.map((i: any) => ({ ...i, value: [''] })) ...selectedFilter,
value: selectedFilter.value,
filters: selectedFilter.filters
? selectedFilter.filters.map((i: any) => ({ ...i, value: [''] }))
: []
});
},
[onUpdate]
);
const handleOperatorChange = useCallback(
(e: any, { value }: any) => {
onUpdate({ ...filter, operator: value });
},
[filter, onUpdate]
);
const handleSourceOperatorChange = useCallback(
(e: any, { value }: any) => {
onUpdate({ ...filter, sourceOperator: value });
},
[filter, onUpdate]
);
const handleUpdateSubFilter = useCallback(
(subFilter: any, index: number) => {
onUpdate({
...filter,
filters: filter.filters.map((i: any, idx: number) => (idx === index ? subFilter : i))
});
},
[filter, onUpdate]
);
const handleRemoveSubFilter = useCallback(
(index: number) => {
onUpdate({
...filter,
filters: filter.filters.filter((_: any, idx: number) => idx !== index)
});
},
[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
)
: [], : [],
}); [filter.filters, saveRequestPayloads]
}; );
const onOperatorChange = (e: any, { value }: any) => { const addSubFilter = useCallback(
props.onUpdate({ ...filter, operator: value }); (selectedFilter: any) => {
}; onUpdate({
...filter,
filters: [...filteredSubFilters, selectedFilter]
});
},
[filter, onUpdate]
);
const onSourceOperatorChange = (e: any, { value }: any) => {
props.onUpdate({ ...filter, sourceOperator: value });
};
const onUpdateSubFilter = (subFilter: any, subFilterIndex: any) => {
props.onUpdate({
...filter,
filters: filter.filters.map((i: any, index: any) => {
if (index === subFilterIndex) {
return subFilter;
}
return i;
}),
});
};
const isReversed = filter.key === FilterKey.TAGGED_ELEMENT;
return ( return (
<div className="flex items-center w-full"> <div className="w-full">
<div className="flex items-center w-full flex-wrap"> <div className="flex items-center w-full">
{!isFilter && !hideIndex && filterIndex >= 0 && ( <div className="flex items-center flex-grow flex-wrap">
<div className="flex-shrink-0 w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-lighter mr-2"> {!isFilter && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && (
<span>{filterIndex + 1}</span> <div
</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>
<FilterSelection </div>
filter={filter}
mode={props.isFilter ? 'filters' : 'events'}
onFilterClick={replaceFilter}
allowedFilterKeys={allowedFilterKeys}
excludeFilterKeys={excludeFilterKeys}
excludeCategory={excludeCategory}
disabled={disableDelete || props.readonly}
/>
<div
className={cn(
'flex items-center flex-wrap',
isReversed ? 'flex-row-reverse ml-2' : 'flex-row',
)}
>
{/* Filter with Source */}
{filter.hasSource && (
<>
<FilterOperator
options={filter.sourceOperatorOptions}
onChange={onSourceOperatorChange}
className="mx-2 flex-shrink-0 btn-event-operator"
value={filter.sourceOperator}
isDisabled={filter.operatorDisabled || props.readonly}
/>
<FilterSource filter={filter} onUpdate={props.onUpdate} />
</>
)} )}
{/* Filter values */} {isSubItem && (
{!isSubFilter && filter.operatorOptions && ( <div className="w-14 text-right">
<> {subFilterIndex === 0 && (
<FilterOperator <Typography.Text className="text-neutral-500/90 mr-2">
options={filter.operatorOptions} where
onChange={onOperatorChange} </Typography.Text>
className="mx-2 flex-shrink-0 btn-sub-event-operator" )}
value={filter.operator} {subFilterIndex != 0 && propertyOrder && onToggleOperator && (
isDisabled={filter.operatorDisabled || props.readonly} <Typography.Text
/> className="text-neutral-500/90 mr-2 cursor-pointer"
{canShowValues && ( onClick={() =>
<> onToggleOperator(propertyOrder === 'and' ? 'or' : 'and')
{props.readonly ? ( }
<div className="rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip hover:border-neutral-400"> >
{propertyOrder}
</Typography.Text>
)}
</div>
)}
<FilterSelection
filters={filterSelections}
onFilterClick={replaceFilter}
disabled={disableDelete || readonly}
>
<Space
className={cn(
'rounded-lg py-1 px-2 cursor-pointer bg-white border border-gray-light text-ellipsis hover:border-neutral-400 btn-select-event',
{ 'opacity-50 pointer-events-none': disableDelete || readonly }
)}
style={{ height: '26px' }}
>
<div className="text-xs">
{filter && getIconForFilter(filter)}
</div>
<div className="text-neutral-500/90 capitalize">
{`${filter?.subCategory ? filter.subCategory : filter?.category}`}
</div>
<span className="text-neutral-500/90"></span>
<div
className="rounded-lg overflow-hidden whitespace-nowrap text-ellipsis mr-auto truncate"
style={{ textOverflow: 'ellipsis' }}
>
{filter.displayName || filter.name}
</div>
</Space>
</FilterSelection>
<div
className={cn(
'flex items-center flex-wrap',
isReversed ? 'flex-row-reverse ml-2' : 'flex-row'
)}
>
{filter.hasSource && (
<>
<FilterOperator
options={filter.sourceOperatorOptions}
onChange={handleSourceOperatorChange}
className="mx-2 flex-shrink-0 btn-event-operator"
value={filter.sourceOperator}
isDisabled={filter.operatorDisabled || readonly}
/>
<FilterSource filter={filter} onUpdate={onUpdate} />
</>
)}
{operatorOptions.length && (
<>
<FilterOperator
options={operatorOptions}
onChange={handleOperatorChange}
className="mx-2 flex-shrink-0 btn-sub-event-operator"
value={filter.operator}
isDisabled={filter.operatorDisabled || readonly}
/>
{canShowValues &&
(readonly ? (
<div
className="rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip hover:border-neutral-400">
{filter.value {filter.value
.map((val: string) => .map((val: string) =>
filter.options && filter.options.length filter.options && filter.options.length
? (filter.options[ ? filter.options[
filter.options.findIndex( filter.options.findIndex((i: any) => i.value === val)
(i: any) => i.value === val, ]?.label ?? val
) : val
]?.label ?? val)
: val,
) )
.join(', ')} .join(', ')}
</div> </div>
) : ( ) : (
<FilterValue <FilterValue isConditional={isConditional} filter={filter} onUpdate={onUpdate} />
isConditional={isConditional} ))}
filter={filter} </>
onUpdate={props.onUpdate} )}
/> </div>
)}
</>
)}
</>
)}
</div> </div>
{/* filters */} {!readonly && !hideDelete && (
{isSubFilter && ( <div className="flex flex-shrink-0 gap-2">
<div className="grid grid-col ml-3 w-full"> {filter.isEvent && !isSubItem && (
{filter.filters <FilterSelection
.filter( filters={eventFilterOptions}
(i: any) => onFilterClick={addSubFilter}
(i.key !== FilterKey.FETCH_REQUEST_BODY && disabled={disableDelete || readonly}
i.key !== FilterKey.FETCH_RESPONSE_BODY) || >
saveRequestPayloads, <Button
) type="text"
.map((subFilter: any, subFilterIndex: any) => ( icon={<FilterIcon size={13} />}
<SubFilterItem size="small"
filterIndex={subFilterIndex} aria-label="Add filter"
filter={subFilter} title="Filter"
onUpdate={(f) => onUpdateSubFilter(f, subFilterIndex)}
onRemoveFilter={props.onRemoveFilter}
/> />
))} </FilterSelection>
)}
<Button
type="text"
icon={<CircleMinus size={13} />}
disabled={disableDelete}
onClick={onRemoveFilter}
size="small"
aria-label="Remove filter"
/>
</div> </div>
)} )}
</div> </div>
{props.readonly || props.hideDelete ? null : (
<div className="flex flex-shrink-0 self-start ml-auto"> {filter.filters?.length > 0 && (
<Button <div className="pl-8 w-full">
disabled={disableDelete} {filteredSubFilters.map((subFilter: any, index: number) => (
type="text" <FilterItem
onClick={props.onRemoveFilter} key={`subfilter-${index}`}
size="small" filter={subFilter}
className="btn-remove-step mt-2" subFilterIndex={index}
> onUpdate={(updatedSubFilter) => handleUpdateSubFilter(updatedSubFilter, index)}
<CircleMinus size={14} /> onRemoveFilter={() => handleRemoveSubFilter(index)}
</Button> isFilter={isFilter}
saveRequestPayloads={saveRequestPayloads}
disableDelete={disableDelete}
readonly={readonly}
hideIndex={hideIndex}
hideDelete={hideDelete}
isConditional={isConditional}
isSubItem={true}
propertyOrder={filter.propertyOrder || 'and'}
onToggleOperator={(newOp) =>
onUpdate({ ...filter, propertyOrder: newOp })
}
/>
))}
</div> </div>
)} )}
</div> </div>
); );
} }
export default FilterItem; export default React.memo(FilterItem);

View file

@ -4,34 +4,34 @@ import { Dropdown, Button, Tooltip } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const EventsOrder = observer( const EventsOrder = observer(
(props: { onChange: (e: any, v: any) => void; filter: any }) => { (props: { onChange: (e: any, v: any) => void; orderProps: any }) => {
const { filter, onChange } = props; const { onChange, orderProps: { eventsOrder, eventsOrderSupport } } = props;
const { eventsOrderSupport } = filter; // const { eventsOrderSupport } = filter;
const { t } = useTranslation(); const { t } = useTranslation();
const menuItems = [ const menuItems = [
{ {
key: 'then', key: 'then',
label: t('THEN'), label: t('THEN'),
disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'), disabled: eventsOrderSupport && !eventsOrderSupport.includes('then')
}, },
{ {
key: 'and', key: 'and',
label: t('AND'), label: t('AND'),
disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'), disabled: eventsOrderSupport && !eventsOrderSupport.includes('and')
}, },
{ {
key: 'or', key: 'or',
label: t('OR'), label: t('OR'),
disabled: eventsOrderSupport && !eventsOrderSupport.includes('or'), disabled: eventsOrderSupport && !eventsOrderSupport.includes('or')
}, }
]; ];
const onClick = ({ key }: any) => { const onClick = ({ key }: any) => {
onChange(null, { name: 'eventsOrder', value: key, key }); onChange(null, { name: 'eventsOrder', value: key, key });
}; };
const selected = menuItems.find( const selected = menuItems.find(
(item) => item.key === filter.eventsOrder, (item) => item.key === eventsOrder
)?.label; )?.label;
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -57,7 +57,7 @@ const EventsOrder = observer(
</Dropdown> </Dropdown>
</div> </div>
); );
}, }
); );
export default EventsOrder; export default EventsOrder;

View file

@ -33,14 +33,15 @@ interface Props {
export const FilterList = observer((props: Props) => { export const FilterList = observer((props: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
observeChanges = () => {}, observeChanges = () => {
},
filter, filter,
excludeFilterKeys = [], excludeFilterKeys = [],
isConditional, isConditional,
onAddFilter, onAddFilter,
readonly, readonly,
borderless, borderless,
excludeCategory, excludeCategory
} = props; } = props;
const { filters } = filter; const { filters } = filter;
@ -53,13 +54,13 @@ export const FilterList = observer((props: Props) => {
<div <div
className={cn( className={cn(
'bg-white', 'bg-white',
borderless ? '' : 'pb-2 px-4 rounded-xl border border-gray-lighter', borderless ? '' : 'pb-2 px-4 rounded-xl border border-gray-lighter'
)} )}
style={{ style={{
borderBottomLeftRadius: props.mergeDown ? 0 : undefined, borderBottomLeftRadius: props.mergeDown ? 0 : undefined,
borderBottomRightRadius: props.mergeDown ? 0 : undefined, borderBottomRightRadius: props.mergeDown ? 0 : undefined,
borderTopLeftRadius: props.mergeUp ? 0 : undefined, borderTopLeftRadius: props.mergeUp ? 0 : undefined,
borderTopRightRadius: props.mergeUp ? 0 : undefined, borderTopRightRadius: props.mergeUp ? 0 : undefined
}} }}
> >
<div className={'flex items-center pt-2'} style={{ gap: '0.65rem' }}> <div className={'flex items-center pt-2'} style={{ gap: '0.65rem' }}>
@ -91,7 +92,7 @@ export const FilterList = observer((props: Props) => {
className="hover:bg-active-blue px-5 " className="hover:bg-active-blue px-5 "
style={{ style={{
marginLeft: '-1rem', marginLeft: '-1rem',
width: 'calc(100% + 2rem)', width: 'calc(100% + 2rem)'
}} }}
> >
<FilterItem <FilterItem
@ -106,7 +107,7 @@ export const FilterList = observer((props: Props) => {
isConditional={isConditional} isConditional={isConditional}
/> />
</div> </div>
) : null, ) : null
)} )}
</div> </div>
); );
@ -115,7 +116,8 @@ export const FilterList = observer((props: Props) => {
export const EventsList = observer((props: Props) => { export const EventsList = observer((props: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
observeChanges = () => {}, observeChanges = () => {
},
filter, filter,
hideEventsOrder = false, hideEventsOrder = false,
saveRequestPayloads, saveRequestPayloads,
@ -126,7 +128,7 @@ export const EventsList = observer((props: Props) => {
onAddFilter, onAddFilter,
cannotAdd, cannotAdd,
excludeCategory, excludeCategory,
borderless, borderless
} = props; } = props;
const { filters } = filter; const { filters } = filter;
@ -143,7 +145,7 @@ export const EventsList = observer((props: Props) => {
const [hoveredItem, setHoveredItem] = React.useState<Record<string, any>>({ const [hoveredItem, setHoveredItem] = React.useState<Record<string, any>>({
i: null, i: null,
position: null, position: null
}); });
const [draggedInd, setDraggedItem] = React.useState<number | null>(null); const [draggedInd, setDraggedItem] = React.useState<number | null>(null);
@ -164,7 +166,7 @@ export const EventsList = observer((props: Props) => {
} }
return draggedInd < hoveredIndex ? hoveredIndex - 1 : hoveredIndex; return draggedInd < hoveredIndex ? hoveredIndex - 1 : hoveredIndex;
}, },
[], []
); );
const handleDragStart = React.useCallback( const handleDragStart = React.useCallback(
@ -176,7 +178,7 @@ export const EventsList = observer((props: Props) => {
ev.dataTransfer.setDragImage(el, 0, 0); ev.dataTransfer.setDragImage(el, 0, 0);
} }
}, },
[], []
); );
const handleDrop = React.useCallback( const handleDrop = React.useCallback(
@ -187,7 +189,7 @@ export const EventsList = observer((props: Props) => {
const newPosition = calculateNewPosition( const newPosition = calculateNewPosition(
draggedInd, draggedInd,
hoveredItem.i, hoveredItem.i,
hoveredItem.position, hoveredItem.position
); );
const reorderedItem = newItems.splice(draggedInd, 1)[0]; const reorderedItem = newItems.splice(draggedInd, 1)[0];
@ -205,15 +207,15 @@ export const EventsList = observer((props: Props) => {
hoveredItem.position, hoveredItem.position,
props, props,
setHoveredItem, setHoveredItem,
setDraggedItem, setDraggedItem
], ]
); );
const eventsNum = filters.filter((i: any) => i.isEvent).length; const eventsNum = filters.filter((i: any) => i.isEvent).length;
return ( return (
<div <div
className={cn( className={cn(
'bg-white', 'bg-white',
borderless ? '' : 'py-2 px-4 rounded-xl border border-gray-lighter' borderless ? '' : 'py-2 px-4 rounded-xl border border-gray-lighter'
) )
} }
@ -222,7 +224,7 @@ export const EventsList = observer((props: Props) => {
borderBottomRightRadius: props.mergeDown ? 0 : undefined, borderBottomRightRadius: props.mergeDown ? 0 : undefined,
borderTopLeftRadius: props.mergeUp ? 0 : undefined, borderTopLeftRadius: props.mergeUp ? 0 : undefined,
borderTopRightRadius: props.mergeUp ? 0 : undefined, borderTopRightRadius: props.mergeUp ? 0 : undefined,
marginBottom: props.mergeDown ? '-1px' : undefined, marginBottom: props.mergeDown ? '-1px' : undefined
}} }}
> >
<div className="flex items-center mb-2 gap-2"> <div className="flex items-center mb-2 gap-2">
@ -232,8 +234,8 @@ export const EventsList = observer((props: Props) => {
mode="events" mode="events"
filter={undefined} filter={undefined}
onFilterClick={onAddFilter} onFilterClick={onAddFilter}
excludeFilterKeys={excludeFilterKeys} // excludeFilterKeys={excludeFilterKeys}
excludeCategory={excludeCategory} // excludeCategory={excludeCategory}
> >
<Button <Button
type="default" type="default"
@ -263,8 +265,8 @@ export const EventsList = observer((props: Props) => {
className={cn( className={cn(
'hover:bg-active-blue px-5 pe-3 gap-2 items-center flex', 'hover:bg-active-blue px-5 pe-3 gap-2 items-center flex',
{ {
'bg-[#f6f6f6]': hoveredItem.i === filterIndex, 'bg-[#f6f6f6]': hoveredItem.i === filterIndex
}, }
)} )}
style={{ style={{
pointerEvents: 'unset', pointerEvents: 'unset',
@ -290,7 +292,7 @@ export const EventsList = observer((props: Props) => {
hoveredItem.i === filterIndex && hoveredItem.i === filterIndex &&
hoveredItem.position === 'bottom' hoveredItem.position === 'bottom'
? '1px dashed #888' ? '1px dashed #888'
: undefined, : undefined
}} }}
id={`${filter.key}-${filterIndex}`} id={`${filter.key}-${filterIndex}`}
onDragOver={(e) => handleDragOverEv(e, filterIndex)} onDragOver={(e) => handleDragOverEv(e, filterIndex)}
@ -305,7 +307,7 @@ export const EventsList = observer((props: Props) => {
handleDragStart( handleDragStart(
e, e,
filterIndex, filterIndex,
`${filter.key}-${filterIndex}`, `${filter.key}-${filterIndex}`
) )
} }
onDragEnd={() => { onDragEnd={() => {
@ -313,7 +315,7 @@ export const EventsList = observer((props: Props) => {
setDraggedItem(null); setDraggedItem(null);
}} }}
style={{ style={{
cursor: draggedInd !== null ? 'grabbing' : 'grab', cursor: draggedInd !== null ? 'grabbing' : 'grab'
}} }}
> >
<GripVertical size={16} /> <GripVertical size={16} />
@ -326,13 +328,13 @@ export const EventsList = observer((props: Props) => {
onRemoveFilter={() => onRemoveFilter(filterIndex)} onRemoveFilter={() => onRemoveFilter(filterIndex)}
saveRequestPayloads={saveRequestPayloads} saveRequestPayloads={saveRequestPayloads}
disableDelete={cannotDeleteFilter} disableDelete={cannotDeleteFilter}
excludeFilterKeys={excludeFilterKeys} // excludeFilterKeys={excludeFilterKeys}
readonly={props.readonly} readonly={props.readonly}
isConditional={isConditional} isConditional={isConditional}
excludeCategory={excludeCategory} // excludeCategory={excludeCategory}
/> />
</div> </div>
) : null, ) : null
)} )}
</div> </div>
</div> </div>

View file

@ -0,0 +1,43 @@
import React, { ReactNode } from 'react';
import { Space, Typography } from '.store/antd-virtual-9dbfadb7f6/package';
import EventsOrder from 'Shared/Filters/FilterList/EventsOrder';
interface FilterListHeaderProps {
title: string;
filterSelection?: ReactNode;
showEventsOrder?: boolean;
orderProps?: any;
onChangeOrder?: (e: any, data: any) => void;
actions?: ReactNode[];
}
const FilterListHeader = ({
title,
filterSelection,
showEventsOrder = false,
orderProps = {},
onChangeOrder,
actions = []
}: FilterListHeaderProps) => {
return (
<div className="flex items-center mb-2 gap-2">
<Space>
<div className="font-medium">{title}</div>
<Typography.Text>{filterSelection}</Typography.Text>
</Space>
<div className="ml-auto flex items-center gap-2">
{showEventsOrder && onChangeOrder && (
<EventsOrder
orderProps={orderProps}
onChange={onChangeOrder}
/>
)}
{actions.map((action, index) => (
<div key={index}>{action}</div>
))}
</div>
</div>
);
};
export default FilterListHeader;

View file

@ -0,0 +1,165 @@
import { GripVertical } from 'lucide-react';
import React, { useState, useCallback } from 'react';
import cn from 'classnames';
import FilterItem from '../FilterItem';
import { useTranslation } from 'react-i18next';
import { Filter } from '@/mstore/types/filterConstants';
interface UnifiedFilterListProps {
title: string;
filters: any[];
header?: React.ReactNode;
filterSelection?: React.ReactNode;
handleRemove: (key: string) => void;
handleUpdate: (key: string, updatedFilter: any) => void;
handleAdd: (newFilter: Filter) => void;
handleMove: (draggedIndex: number, newPosition: number) => void;
isDraggable?: boolean;
showIndices?: boolean;
readonly?: boolean;
isConditional?: boolean;
showEventsOrder?: boolean;
saveRequestPayloads?: boolean;
supportsEmpty?: boolean;
mergeDown?: boolean;
mergeUp?: boolean;
borderless?: boolean;
className?: string;
style?: React.CSSProperties;
actions?: React.ReactNode[];
orderProps?: any;
}
const UnifiedFilterList = (props: UnifiedFilterListProps) => {
const { t } = useTranslation();
const {
filters,
handleRemove,
handleUpdate,
handleMove,
isDraggable = false,
showIndices = true,
readonly = false,
isConditional = false,
showEventsOrder = false,
saveRequestPayloads = false,
supportsEmpty = true,
mergeDown = false,
mergeUp = false,
style
} = props;
const [hoveredItem, setHoveredItem] = useState<{ i: number | null; position: string | null }>({
i: 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 removeFilter = useCallback((key: string) => {
handleRemove(key);
}, [handleRemove]);
const calculateNewPosition = useCallback(
(dragInd: number, hoverIndex: number, hoverPosition: string) => {
return hoverPosition === 'bottom' ? (dragInd < hoverIndex ? hoverIndex - 1 : hoverIndex) : hoverIndex;
},
[]
);
const handleDragStart = useCallback(
(ev: React.DragEvent, index: number, elId: string) => {
ev.dataTransfer.setData('text/plain', index.toString());
setDraggedItem(index);
const el = document.getElementById(elId);
if (el) {
ev.dataTransfer.setDragImage(el, 0, 0);
}
},
[]
);
const handleDragOver = useCallback((event: React.DragEvent, i: number) => {
event.preventDefault();
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 });
}, []);
const handleDrop = useCallback(
(event: React.DragEvent) => {
event.preventDefault();
if (draggedInd === null || hoveredItem.i === null) return;
const newPosition = calculateNewPosition(
draggedInd,
hoveredItem.i,
hoveredItem.position || 'bottom'
);
handleMove(draggedInd, newPosition);
setHoveredItem({ i: null, position: null });
setDraggedItem(null);
},
[draggedInd, calculateNewPosition, handleMove, hoveredItem.i, hoveredItem.position]
);
const handleDragEnd = useCallback(() => {
setHoveredItem({ i: null, position: null });
setDraggedItem(null);
}, []);
return (
<div className="flex flex-col" style={style}>
{filters.map((filterItem: any, filterIndex: number) => (
<div
key={`filter-${filterIndex}`}
className={cn('hover:bg-active-blue px-5 pe-3 gap-2 items-center flex', {
'bg-[#f6f6f6]': hoveredItem.i === filterIndex
})}
style={{
marginLeft: '-1rem',
width: 'calc(100% + 2rem)',
alignItems: 'start',
borderTop: hoveredItem.i === filterIndex && hoveredItem.position === 'top' ? '1px dashed #888' : undefined,
borderBottom: hoveredItem.i === filterIndex && hoveredItem.position === 'bottom' ? '1px dashed #888' : undefined
}}
id={`filter-${filterItem.key}`}
onDragOver={isDraggable ? (e) => handleDragOver(e, filterIndex) : undefined}
onDrop={isDraggable ? handleDrop : undefined}
>
{isDraggable && filters.length > 1 && (
<div
className="cursor-grab text-neutral-500/90 hover:bg-white px-1 mt-2.5 rounded-lg"
draggable={true}
onDragStart={(e) => handleDragStart(e, filterIndex, `filter-${filterIndex}`)}
onDragEnd={handleDragEnd}
style={{ cursor: draggedInd !== null ? 'grabbing' : 'grab' }}
>
<GripVertical size={16} />
</div>
)}
<FilterItem
filterIndex={showIndices ? filterIndex : undefined}
filter={filterItem}
onUpdate={(updatedFilter) => updateFilter(filterItem.key, updatedFilter)}
onRemoveFilter={() => removeFilter(filterItem.key)}
saveRequestPayloads={saveRequestPayloads}
disableDelete={cannotDelete}
readonly={readonly}
isConditional={isConditional}
hideIndex={!showIndices}
/>
</div>
))}
</div>
);
};
export default UnifiedFilterList;

View file

@ -1,279 +1,200 @@
import { filtersMap } from 'Types/filter/newFilter';
import cn from 'classnames'; import cn from 'classnames';
import { import { Pointer, ChevronRight, MousePointerClick } from 'lucide-react';
AppWindow, import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
ArrowUpDown, import { Loader } from 'UI';
Chrome,
CircleAlert,
Clock2,
Code,
ContactRound,
CornerDownRight,
Cpu,
Earth,
FileStack,
Layers,
MapPin,
Megaphone,
MemoryStick,
MonitorSmartphone,
Navigation,
Network,
OctagonAlert,
Pin,
Pointer,
RectangleEllipsis,
SquareMousePointer,
SquareUser,
Timer,
VenetianMask,
Workflow,
Flag,
ChevronRight,
Info,
SquareArrowOutUpRight,
} from 'lucide-react';
import React, { useEffect, useRef } from 'react';
import { Icon, Loader } from 'UI';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { Input, Button } from 'antd'; import { Input, Space, Typography } from 'antd';
import { FilterCategory, FilterKey, FilterType } from 'Types/filter/filterType';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import stl from './FilterModal.module.css';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Filter } from '@/mstore/types/filterConstants';
export const IconMap = { export const getIconForFilter = (filter: Filter) => <MousePointerClick size={14} />;
[FilterKey.CLICK]: <Pointer size={14} />,
[FilterKey.LOCATION]: <Navigation size={14} />, // Helper function for grouping filters
[FilterKey.INPUT]: <RectangleEllipsis size={14} />, const groupFiltersByCategory = (filters: Filter[]) => {
[FilterKey.CUSTOM]: <Code size={14} />, if (!filters?.length) return {};
[FilterKey.FETCH]: <ArrowUpDown size={14} />,
[FilterKey.GRAPHQL]: <Network size={14} />, return filters.reduce((acc, filter) => {
[FilterKey.STATEACTION]: <RectangleEllipsis size={14} />, const category = filter.category
[FilterKey.ERROR]: <OctagonAlert size={14} />, ? filter.category.charAt(0).toUpperCase() + filter.category.slice(1)
[FilterKey.ISSUE]: <CircleAlert size={14} />, : 'Unknown';
[FilterKey.FETCH_FAILED]: <Code size={14} />,
[FilterKey.DOM_COMPLETE]: <ArrowUpDown size={14} />, if (!acc[category]) acc[category] = [];
[FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={14} />, acc[category].push(filter);
[FilterKey.TTFB]: <Timer size={14} />, return acc;
[FilterKey.AVG_CPU_LOAD]: <Cpu size={14} />, }, {});
[FilterKey.AVG_MEMORY_USAGE]: <MemoryStick size={14} />,
[FilterKey.USERID]: <SquareUser size={14} />,
[FilterKey.USERANONYMOUSID]: <VenetianMask size={14} />,
[FilterKey.USER_CITY]: <Pin size={14} />,
[FilterKey.USER_STATE]: <MapPin size={14} />,
[FilterKey.USER_COUNTRY]: <Earth size={14} />,
[FilterKey.USER_DEVICE]: <Code size={14} />,
[FilterKey.USER_OS]: <AppWindow size={14} />,
[FilterKey.USER_BROWSER]: <Chrome size={14} />,
[FilterKey.PLATFORM]: <MonitorSmartphone size={14} />,
[FilterKey.REVID]: <FileStack size={14} />,
[FilterKey.REFERRER]: <Workflow size={14} />,
[FilterKey.DURATION]: <Clock2 size={14} />,
[FilterKey.TAGGED_ELEMENT]: <SquareMousePointer size={14} />,
[FilterKey.METADATA]: <ContactRound size={14} />,
[FilterKey.UTM_SOURCE]: <CornerDownRight size={14} />,
[FilterKey.UTM_MEDIUM]: <Layers size={14} />,
[FilterKey.UTM_CAMPAIGN]: <Megaphone size={14} />,
[FilterKey.FEATURE_FLAG]: <Flag size={14} />,
}; };
function filterJson( // Optimized filtering function with early returns
jsonObj: Record<string, any>, const getFilteredEntries = (query: string, filters: Filter[]) => {
excludeKeys: string[] = [], const trimmedQuery = query.trim().toLowerCase();
excludeCategory: string[] = [],
allowedFilterKeys: string[] = [],
mode: 'filters' | 'events',
): Record<string, any> {
return Object.fromEntries(
Object.entries(jsonObj)
.map(([key, value]) => {
const arr = value.filter(
(i: { key: string; isEvent: boolean; category: string }) => {
if (excludeCategory.includes(i.category)) return false;
if (excludeKeys.includes(i.key)) return false;
if (mode === 'events' && !i.isEvent) return false;
if (mode === 'filters' && i.isEvent) return false;
return !(
allowedFilterKeys.length > 0 && !allowedFilterKeys.includes(i.key)
);
},
);
return [key, arr];
})
.filter(([_, arr]) => arr.length > 0),
);
}
export const getMatchingEntries = ( if (!filters || Object.keys(filters).length === 0) {
searchQuery: string, return { matchingCategories: ['All'], matchingFilters: {} };
filters: Record<string, any>, }
) => {
const matchingCategories: string[] = [];
const matchingFilters: Record<string, any> = {};
const lowerCaseQuery = searchQuery.toLowerCase();
if (lowerCaseQuery.length === 0) { if (!trimmedQuery) {
return { return {
matchingCategories: ['All', ...Object.keys(filters)], matchingCategories: ['All', ...Object.keys(filters)],
matchingFilters: filters, matchingFilters: filters
}; };
} }
Object.keys(filters).forEach((name) => { const matchingCategories = ['All'];
if (name.toLocaleLowerCase().includes(lowerCaseQuery)) { const matchingFilters = {};
matchingCategories.push(name);
matchingFilters[name] = filters[name];
} else {
const filtersQuery = filters[name].filter((filterOption: any) =>
filterOption.label.toLocaleLowerCase().includes(lowerCaseQuery),
);
if (filtersQuery.length > 0) matchingFilters[name] = filtersQuery; // Single pass through the data with optimized conditionals
filtersQuery.length > 0 && matchingCategories.push(name); Object.entries(filters).forEach(([name, categoryFilters]) => {
const categoryMatch = name.toLowerCase().includes(trimmedQuery);
if (categoryMatch) {
matchingCategories.push(name);
matchingFilters[name] = categoryFilters;
return;
}
const filtered = categoryFilters.filter(
(filter: Filter) =>
filter.displayName?.toLowerCase().includes(trimmedQuery) ||
filter.name?.toLowerCase().includes(trimmedQuery)
);
if (filtered.length) {
matchingCategories.push(name);
matchingFilters[name] = filtered;
} }
}); });
return { return { matchingCategories, matchingFilters };
matchingCategories: ['All', ...matchingCategories],
matchingFilters,
};
}; };
interface Props { // Custom debounce hook to optimize search
isLive?: boolean; const useDebounce = (value: any, delay = 300) => {
conditionalFilters: any; const [debouncedValue, setDebouncedValue] = useState(value);
mobileConditionalFilters: any;
onFilterClick?: (filter: any) => void;
isMainSearch?: boolean;
searchQuery?: string;
excludeFilterKeys?: Array<string>;
excludeCategory?: Array<string>;
allowedFilterKeys?: Array<string>;
isConditional?: boolean;
isMobile?: boolean;
mode: 'filters' | 'events';
}
export const getNewIcon = (filter: Record<string, any>) => {
if (filter.icon?.includes('metadata')) {
return IconMap[FilterKey.METADATA];
}
// @ts-ignore
if (IconMap[filter.key]) {
// @ts-ignore
return IconMap[filter.key];
}
return <Icon name={filter.icon} size={16} />;
};
function FilterModal(props: Props) {
const { t } = useTranslation();
const {
isLive,
onFilterClick = () => null,
isMainSearch = false,
excludeFilterKeys = [],
excludeCategory = [],
allowedFilterKeys = [],
isConditional,
mode,
} = props;
const [searchQuery, setSearchQuery] = React.useState('');
const [category, setCategory] = React.useState('All');
const { searchStore, searchStoreLive, projectsStore } = useStore();
const isMobile = projectsStore.active?.platform === 'ios'; // TODO - should be using mobile once the app is changed
const filters = isLive
? searchStoreLive.filterListLive
: isMobile
? searchStore.filterListMobile
: searchStoreLive.filterList;
const conditionalFilters = searchStore.filterListConditional;
const mobileConditionalFilters = searchStore.filterListMobileConditional;
const showSearchList = isMainSearch && searchQuery.length > 0;
const filterSearchList = isLive
? searchStoreLive.filterSearchList
: searchStore.filterSearchList;
const fetchingFilterSearchList = isLive
? searchStoreLive.loadingFilterSearch
: searchStore.loadingFilterSearch;
const parseAndAdd = (filter) => {
if (
filter.category === FilterCategory.EVENTS &&
filter.key.startsWith('_')
) {
filter.value = [filter.key.substring(1)];
filter.key = FilterKey.CUSTOM;
filter.label = 'Custom Events';
}
if (
filter.type === FilterType.ISSUE &&
filter.key.startsWith(`${FilterKey.ISSUE}_`)
) {
filter.key = FilterKey.ISSUE;
}
onFilterClick(filter);
};
const onFilterSearchClick = (filter: any) => {
const _filter = { ...filtersMap[filter.type] };
_filter.value = [filter.value];
parseAndAdd(_filter);
};
const filterJsonObj = isConditional
? isMobile
? mobileConditionalFilters
: conditionalFilters
: filters;
const filterObj = filterJson(
filterJsonObj,
excludeFilterKeys,
excludeCategory,
allowedFilterKeys,
mode,
);
const showMetaCTA =
mode === 'filters' &&
!filterObj.Metadata &&
(allowedFilterKeys?.length
? allowedFilterKeys.includes(FilterKey.METADATA)
: true) &&
(excludeCategory?.length
? !excludeCategory.includes(FilterCategory.METADATA)
: true) &&
(excludeFilterKeys?.length
? !excludeFilterKeys.includes(FilterKey.METADATA)
: true);
const { matchingCategories, matchingFilters } = getMatchingEntries(
searchQuery,
filterObj,
);
const isResultEmpty =
(!filterSearchList || Object.keys(filterSearchList).length === 0) &&
matchingCategories.length === 0 &&
Object.keys(matchingFilters).length === 0;
const inputRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
if (inputRef.current) { const handler = setTimeout(() => {
inputRef.current.focus(); setDebouncedValue(value);
}, delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};
// Memoized filter item component
const FilterItem = React.memo(({ filter, onClick, showCategory }: {
filter: Filter;
onClick: (filter: Filter) => void;
showCategory?: boolean;
}) => (
<div
className="flex items-center flex-shrink-0 p-2 cursor-pointer gap-1 rounded-lg hover:bg-active-blue"
onClick={() => onClick(filter)}
>
{showCategory && filter.category && (
<div style={{ width: 100 }} className="text-neutral-500/90 flex justify-between items-center">
<span className="capitalize">{filter.subCategory || filter.category}</span>
<ChevronRight size={14} />
</div>
)}
<Space className="flex-1 min-w-0">
<span className="text-neutral-500/90 text-xs">{getIconForFilter(filter)}</span>
<Typography.Text
ellipsis={{ tooltip: true }}
className="max-w-full"
style={{ display: 'block' }}
>
{filter.displayName || filter.name}
</Typography.Text>
</Space>
</div>
));
// Memoized category list component
const CategoryList = React.memo(({ categories, activeCategory, onSelect }: {
categories: string[];
activeCategory: string;
onSelect: (category: string) => void;
}) => (
<>
{categories.map((key) => (
<div
key={key}
onClick={() => onSelect(key)}
className={cn(
'rounded-xl px-4 py-2 hover:bg-active-blue capitalize cursor-pointer font-medium',
key === activeCategory && 'bg-active-blue text-teal'
)}
>
{key}
</div>
))}
</>
));
function FilterModal({ onFilterClick = () => null, filters = [], isMainSearch = false }) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const debouncedQuery = useDebounce(searchQuery);
const [category, setCategory] = useState('All');
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef(null);
// Memoize expensive computations
const groupedFilters = useMemo(() =>
groupFiltersByCategory(filters),
[filters]
);
const { matchingCategories, matchingFilters } = useMemo(
() => getFilteredEntries(debouncedQuery, groupedFilters),
[debouncedQuery, groupedFilters]
);
const displayedFilters = useMemo(() => {
if (category === 'All') {
return Object.entries(matchingFilters).flatMap(([cat, filters]) =>
filters.map((filter) => ({ ...filter, category: cat }))
);
} }
return matchingFilters[category] || [];
}, [category, matchingFilters]);
const isResultEmpty = useMemo(
() => matchingCategories.length <= 1 && Object.keys(matchingFilters).length === 0,
[matchingCategories.length, matchingFilters]
);
// Memoize handlers
const handleFilterClick = useCallback(
(filter: Filter) => onFilterClick({ ...filter, operator: 'is' }),
[onFilterClick]
);
const handleCategoryClick = useCallback(
(cat: string) => setCategory(cat),
[]
);
// Focus input only when necessary
useEffect(() => {
inputRef.current?.focus();
}, [category]); }, [category]);
const displayedFilters = if (isLoading) {
category === 'All' return (
? Object.entries(matchingFilters).flatMap(([category, filters]) => <div style={{ width: '490px', maxHeight: '380px' }}>
filters.map((f: any) => ({ ...f, category })), <div className="flex items-center justify-center h-60">
) <Loader loading />
: matchingFilters[category]; </div>
</div>
);
}
return ( return (
<div className={stl.wrapper} style={{ width: '460px', maxHeight: '380px' }}> <div style={{ width: '490px', maxHeight: '380px' }}>
<Input <Input
ref={inputRef} ref={inputRef}
className="mb-4 rounded-xl text-lg font-medium placeholder:text-lg placeholder:font-medium placeholder:text-neutral-300" className="mb-4 rounded-xl text-lg font-medium placeholder:text-lg placeholder:font-medium placeholder:text-neutral-300"
@ -282,149 +203,41 @@ function FilterModal(props: Props) {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
autoFocus autoFocus
/> />
<div className="flex gap-2 items-start">
<div className="flex flex-col gap-1"> {isResultEmpty ? (
{matchingCategories.map((key) => ( <div className="flex items-center flex-col justify-center h-60">
<div <AnimatedSVG name={ICONS.NO_SEARCH_RESULTS} size={30} />
key={key} <div className="font-medium px-3 mt-4">{t('No matching filters.')}</div>
onClick={() => setCategory(key)}
className={cn(
'rounded-xl px-4 py-2 hover:bg-active-blue capitalize cursor-pointer font-medium',
key === category ? 'bg-active-blue text-teal' : '',
)}
>
{key}
</div>
))}
{showMetaCTA ? (
<div
key="META_CTA"
onClick={() => setCategory('META_CTA')}
className={cn(
'rounded-xl px-4 py-2 hover:bg-active-blue capitalize cursor-pointer font-medium',
category === 'META_CTA' ? 'bg-active-blue text-teal' : '',
)}
>
{t('Metadata')}
</div>
) : null}
</div> </div>
<div ) : (
className="flex flex-col gap-1 overflow-y-auto w-full h-full" <div className="flex gap-2 items-start">
style={{ maxHeight: 300, flex: 2 }} <div className="flex flex-col gap-1 min-w-40">
> <CategoryList
{displayedFilters && displayedFilters.length categories={matchingCategories}
? displayedFilters.map((filter: Record<string, any>) => ( activeCategory={category}
<div onSelect={handleCategoryClick}
key={filter.label} />
className={cn( </div>
'flex items-center p-2 cursor-pointer gap-1 rounded-lg hover:bg-active-blue', <div className="flex flex-col gap-1 overflow-y-auto w-full" style={{ maxHeight: 300, flex: 2 }}>
)} {displayedFilters.length > 0 ? (
onClick={() => parseAndAdd({ ...filter })} displayedFilters.map((filter: Filter, index: number) => (
> <FilterItem
{filter.category ? ( key={`${filter.name}-${index}`}
<div filter={filter}
style={{ width: 100 }} onClick={handleFilterClick}
className="text-neutral-500/90 w-full flex justify-between items-center" showCategory={category === 'All'}
> />
<span>
{filter.subCategory
? filter.subCategory
: filter.category}
</span>
<ChevronRight size={14} />
</div>
) : null}
<div className="flex items-center gap-2">
<span className="text-neutral-500/90 text-xs">
{getNewIcon(filter)}
</span>
<span>{filter.label}</span>
</div>
</div>
)) ))
: null}
{category === 'META_CTA' && showMetaCTA ? (
<div
style={{
height: 300,
}}
className="mx-auto flex flex-col items-center justify-center gap-3 w-2/3 text-center"
>
<div className="font-semibold flex gap-2 items-center">
<Info size={16} />
<span>{t('No Metadata Available')}</span>
</div>
<div className="text-secondary">
{t('Identify sessions & data easily by linking user-specific metadata.')}
</div>
<Button
type="text"
className="text-teal"
onClick={() => {
const docs = 'https://docs.openreplay.com/en/en/session-replay/metadata/';
window.open(docs, '_blank');
}}
>
<div className="flex items-center gap-2">
<span className="">{t('Learn how')}</span>
<SquareArrowOutUpRight size={14} />
</div>
</Button>
</div>
) : null}
</div>
</div>
{showSearchList && (
<Loader loading={fetchingFilterSearchList}>
<div className="-mx-6 px-6">
{isResultEmpty && !fetchingFilterSearchList ? (
<div className="flex items-center flex-col">
<AnimatedSVG name={ICONS.NO_SEARCH_RESULTS} size={30} />
<div className="font-medium px-3 mt-4">
{' '}
{t('No matching filters.')}
</div>
</div>
) : ( ) : (
Object.keys(filterSearchList).map((key, index) => { <div className="flex items-center justify-center h-40">
const filter = filterSearchList[key]; <div className="text-neutral-500">{t('No filters in this category')}</div>
const option = filtersMap[key]; </div>
return option ? (
<div key={index} className={cn('mb-3')}>
<div className="font-medium uppercase color-gray-medium mb-2">
{option.label}
</div>
<div>
{filter.map((f, i) => (
<div
key={i}
className={cn(
stl.filterSearchItem,
'cursor-pointer px-3 py-1 flex items-center gap-2',
)}
onClick={() =>
onFilterSearchClick({ type: key, value: f.value })
}
>
{getNewIcon(option)}
<div className="whitespace-nowrap text-ellipsis overflow-hidden">
{f.value}
</div>
</div>
))}
</div>
</div>
) : (
<></>
);
})
)} )}
</div> </div>
</Loader> </div>
)} )}
</div> </div>
); );
} }
export default observer(FilterModal); export default React.memo(observer(FilterModal));

View file

@ -1,121 +1,64 @@
import React, { useState } from 'react'; import React, { useState, useCallback } from 'react';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv'; import { Popover } from 'antd';
import { assist as assistRoute, isRoute } from 'App/routes';
import cn from 'classnames';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import FilterModal from '../FilterModal'; import FilterModal from '../FilterModal/FilterModal';
import { getNewIcon } from '../FilterModal/FilterModal'; import { Filter } from '@/mstore/types/filterConstants';
const ASSIST_ROUTE = assistRoute(); interface FilterSelectionProps {
filters: Filter[];
interface Props { onFilterClick: (filter: Filter) => void;
filter?: any; children?: React.ReactNode;
onFilterClick: (filter: any) => void;
children?: any;
excludeFilterKeys?: Array<string>;
excludeCategory?: Array<string>;
allowedFilterKeys?: Array<string>;
disabled?: boolean; disabled?: boolean;
isConditional?: boolean;
isMobile?: boolean;
mode: 'filters' | 'events';
isLive?: boolean; isLive?: boolean;
} }
function FilterSelection(props: Props) { const FilterSelection: React.FC<FilterSelectionProps> = observer(({
const { filters,
filter, onFilterClick,
onFilterClick, children,
children, disabled = false,
excludeFilterKeys = [], isLive
excludeCategory = [], }) => {
allowedFilterKeys = [], const [open, setOpen] = useState(false);
disabled = false,
isConditional,
isMobile,
mode,
isLive,
} = props;
const [showModal, setShowModal] = useState(false);
const modalRef = React.useRef<HTMLDivElement>(null);
const onAddFilter = (filter: any) => { const handleFilterClick = useCallback((selectedFilter: Filter) => {
onFilterClick(filter); onFilterClick(selectedFilter);
setShowModal(false); setOpen(false);
}; }, [onFilterClick]);
React.useEffect(() => { const handleOpenChange = useCallback((newOpen: boolean) => {
if (showModal && modalRef.current) { if (!disabled) {
const modalRect = modalRef.current.getBoundingClientRect(); setOpen(newOpen);
const viewportWidth = window.innerWidth;
if (modalRect.right > viewportWidth) {
modalRef.current.style.left = 'unset';
modalRef.current.style.right = '-280px';
}
} }
}, [showModal]); }, [disabled]);
const content = (
<FilterModal
onFilterClick={handleFilterClick}
filters={filters}
/>
);
const triggerElement = React.isValidElement(children)
? React.cloneElement(children, { disabled })
: children;
const label = filter?.category === 'Issue' ? 'Issue' : filter?.label;
return ( return (
<div className="relative flex-shrink-0 my-1.5"> <div className="relative flex-shrink-0">
<OutsideClickDetectingDiv <Popover
className="relative" content={content}
onClickOutside={() => { trigger="click"
setTimeout(() => { open={open}
setShowModal(false); onOpenChange={handleOpenChange}
}, 0); placement="bottomLeft"
}} overlayClassName="filter-selection-popover rounded-lg border border-gray-200 shadow-sm shadow-gray-200"
destroyTooltipOnHide
arrow={false}
> >
{children ? ( {triggerElement}
React.cloneElement(children, { </Popover>
onClick: (e) => {
setShowModal(true);
},
disabled,
})
) : (
<div
className={cn(
'rounded-lg py-1 px-2 flex items-center gap-1 cursor-pointer bg-white border border-gray-light text-ellipsis hover:border-neutral-400 btn-select-event',
{ 'opacity-50 pointer-events-none': disabled },
)}
style={{
height: '26px',
}}
onClick={() => setShowModal(true)}
>
<div className="text-xs text-neutral-500/90 hover:border-neutral-400">
{getNewIcon(filter)}
</div>
<div className="text-neutral-500/90 flex gap-2 hover:border-neutral-400 ">{`${filter.subCategory ? filter.subCategory : filter.category}`}</div>
<div
className="rounded-lg overflow-hidden whitespace-nowrap text-ellipsis mr-auto truncate "
style={{ textOverflow: 'ellipsis' }}
>
{label}
</div>
</div>
)}
{showModal && (
<div
ref={modalRef}
className="absolute mt-2 left-0 rounded-2xl shadow-lg bg-white z-50"
>
<FilterModal
isLive={isRoute(ASSIST_ROUTE, window.location.pathname) || isLive}
onFilterClick={onAddFilter}
excludeFilterKeys={excludeFilterKeys}
allowedFilterKeys={allowedFilterKeys}
excludeCategory={excludeCategory}
isConditional={isConditional}
isMobile={isMobile}
mode={mode}
/>
</div>
)}
</OutsideClickDetectingDiv>
</div> </div>
); );
} });
export default observer(FilterSelection); export default FilterSelection;

View file

@ -16,6 +16,7 @@ interface Props {
onUpdate: (filter: any) => void; onUpdate: (filter: any) => void;
isConditional?: boolean; isConditional?: boolean;
} }
function FilterValue(props: Props) { function FilterValue(props: Props) {
const { filter } = props; const { filter } = props;
const isAutoOpen = filter.autoOpen; const isAutoOpen = filter.autoOpen;
@ -29,7 +30,7 @@ function FilterValue(props: Props) {
}, [isAutoOpen]); }, [isAutoOpen]);
const [durationValues, setDurationValues] = useState({ const [durationValues, setDurationValues] = useState({
minDuration: filter.value?.[0], 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]
}); });
const showCloseButton = filter.value.length > 1; const showCloseButton = filter.value.length > 1;
@ -44,7 +45,7 @@ function FilterValue(props: Props) {
const onRemoveValue = (valueIndex: any) => { const onRemoveValue = (valueIndex: any) => {
const newValue = filter.value.filter( const newValue = filter.value.filter(
(_: any, index: any) => index !== valueIndex, (_: any, index: any) => index !== valueIndex
); );
props.onUpdate({ ...filter, value: newValue }); props.onUpdate({ ...filter, value: newValue });
}; };
@ -60,7 +61,7 @@ function FilterValue(props: Props) {
}; };
const debounceOnSelect = React.useCallback(debounce(onChange, 500), [ const debounceOnSelect = React.useCallback(debounce(onChange, 500), [
onChange, onChange
]); ]);
const onDurationChange = (newValues: any) => { const onDurationChange = (newValues: any) => {
@ -77,14 +78,19 @@ function FilterValue(props: Props) {
) { ) {
props.onUpdate({ props.onUpdate({
...filter, ...filter,
value: [durationValues.minDuration, durationValues.maxDuration], value: [durationValues.minDuration, durationValues.maxDuration]
}); });
} }
} }
}; };
const getParms = (key: any) => { const getParams = (key: any) => {
let params: any = { type: filter.key }; let params: any = {
type: filter.key,
name: filter.name,
isEvent: filter.isEvent,
id: filter.id
};
switch (filter.category) { switch (filter.category) {
case FilterCategory.METADATA: case FilterCategory.METADATA:
params = { type: FilterKey.METADATA, key }; params = { type: FilterKey.METADATA, key };
@ -99,6 +105,7 @@ function FilterValue(props: Props) {
const renderValueFiled = (value: any[]) => { const renderValueFiled = (value: any[]) => {
const showOrButton = filter.value.length > 1; const showOrButton = filter.value.length > 1;
function BaseFilterLocalAutoComplete(props) { function BaseFilterLocalAutoComplete(props) {
return ( return (
<FilterAutoCompleteLocal <FilterAutoCompleteLocal
@ -115,6 +122,7 @@ function FilterValue(props: Props) {
/> />
); );
} }
function BaseDropDown(props) { function BaseDropDown(props) {
return ( return (
<FilterValueDropdown <FilterValueDropdown
@ -127,6 +135,7 @@ function FilterValue(props: Props) {
/> />
); );
} }
switch (filter.type) { switch (filter.type) {
case FilterType.NUMBER_MULTIPLE: case FilterType.NUMBER_MULTIPLE:
return ( return (
@ -145,7 +154,23 @@ function FilterValue(props: Props) {
/> />
); );
case FilterType.STRING: case FilterType.STRING:
return <BaseFilterLocalAutoComplete placeholder={filter.placeholder} />; // return <BaseFilterLocalAutoComplete placeholder={filter.placeholder} />;
return <FilterAutoComplete
value={value}
isAutoOpen={isAutoOpen}
showCloseButton={showCloseButton}
showOrButton={showOrButton}
onApplyValues={onApplyValues}
onRemoveValue={(index) => onRemoveValue(index)}
method="GET"
endpoint="/PROJECT_ID/events/search"
params={getParams(filter.key)}
headerText=""
placeholder={filter.placeholder}
onSelect={(e, item, index) => onChange(e, item, index)}
icon={filter.icon}
modalProps={{ placeholder: 'Search' }}
/>;
case FilterType.DROPDOWN: case FilterType.DROPDOWN:
return <BaseDropDown />; return <BaseDropDown />;
case FilterType.ISSUE: case FilterType.ISSUE:
@ -181,7 +206,7 @@ function FilterValue(props: Props) {
onRemoveValue={(index) => onRemoveValue(index)} onRemoveValue={(index) => onRemoveValue(index)}
method="GET" method="GET"
endpoint="/PROJECT_ID/events/search" endpoint="/PROJECT_ID/events/search"
params={getParms(filter.key)} params={getParams(filter.key)}
headerText="" headerText=""
placeholder={filter.placeholder} placeholder={filter.placeholder}
onSelect={(e, item, index) => onChange(e, item, index)} onSelect={(e, item, index) => onChange(e, item, index)}
@ -196,7 +221,7 @@ function FilterValue(props: Props) {
<div <div
id="ignore-outside" id="ignore-outside"
className={cn('grid gap-3 w-fit flex-wrap my-1.5', { className={cn('grid gap-3 w-fit flex-wrap my-1.5', {
'grid-cols-2': filter.hasSource, 'grid-cols-2': filter.hasSource
})} })}
> >
{renderValueFiled(filter.value)} {renderValueFiled(filter.value)}

View file

@ -9,6 +9,7 @@ interface Props {
onRemoveFilter: () => void; onRemoveFilter: () => void;
isFilter?: boolean; isFilter?: boolean;
} }
export default function SubFilterItem(props: Props) { export default function SubFilterItem(props: Props) {
const { isFilter = false, filterIndex, filter } = props; const { isFilter = false, filterIndex, filter } = props;
const canShowValues = !( const canShowValues = !(

View file

@ -1,110 +1,128 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { debounce } from 'App/utils';
import { FilterList, EventsList } from 'Shared/Filters/FilterList';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore'; import { useStore } from 'App/mstore';
import useSessionSearchQueryHandler from 'App/hooks/useSessionSearchQueryHandler'; import UnifiedFilterList from 'Shared/Filters/FilterList/UnifiedFilterList';
import { FilterKey } from 'App/types/filter/filterType'; import FilterSelection from 'Shared/Filters/FilterSelection';
import { addOptionsToFilter } from 'App/types/filter/newFilter'; import { Button, Divider } from 'antd';
import { Plus } from 'lucide-react';
import cn from 'classnames';
import { Filter } from '@/mstore/types/filterConstants';
import FilterListHeader from 'Shared/Filters/FilterList/FilterListHeader';
let debounceFetch: any = () => {
};
let debounceFetch: any = () => {};
function SessionFilters() { function SessionFilters() {
const { searchStore, projectsStore, customFieldStore, tagWatchStore } = const { searchStore, projectsStore, filterStore } =
useStore(); useStore();
const appliedFilter = searchStore.instance; const searchInstance = searchStore.instance;
const metaLoading = customFieldStore.isLoading;
const saveRequestPayloads = const saveRequestPayloads =
projectsStore.instance?.saveRequestPayloads ?? false; projectsStore.instance?.saveRequestPayloads ?? false;
const activeProject = projectsStore.active;
const reloadTags = async () => { const allFilterOptions: Filter[] = filterStore.getCurrentProjectFilters();
const tags = await tagWatchStore.getTags(); const eventOptions = allFilterOptions.filter(i => i.isEvent);
if (tags) { const propertyOptions = allFilterOptions.filter(i => !i.isEvent);
addOptionsToFilter(
FilterKey.TAGGED_ELEMENT,
tags.map((tag) => ({
label: tag.name,
value: tag.tagId.toString(),
})),
);
searchStore.refreshFilterOptions();
}
};
useEffect(() => {
// Add default location/screen filter if no filters are present
if (searchStore.instance.filters.length === 0) {
searchStore.addFilterByKeyAndValue(
activeProject?.platform === 'web'
? FilterKey.LOCATION
: FilterKey.VIEW_MOBILE,
'',
'isAny',
);
}
void reloadTags();
}, [projectsStore.activeSiteId, activeProject]);
useSessionSearchQueryHandler({
appliedFilter,
loading: metaLoading,
onBeforeLoad: async () => {
await reloadTags();
},
});
const onAddFilter = (filter: any) => { const onAddFilter = (filter: any) => {
filter.autoOpen = true; filter.autoOpen = true;
searchStore.addFilter(filter); searchStore.addFilter(filter);
}; };
const onUpdateFilter = (filterIndex: any, filter: any) => {
searchStore.updateFilter(filterIndex, filter);
};
const onFilterMove = (newFilters: any) => {
searchStore.updateSearch({ ...appliedFilter, filters: newFilters});
// debounceFetch();
};
const onRemoveFilter = (filterIndex: any) => {
searchStore.removeFilter(filterIndex);
// debounceFetch();
};
const onChangeEventsOrder = (e: any, { value }: any) => { const onChangeEventsOrder = (e: any, { value }: any) => {
searchStore.edit({ searchStore.edit({
eventsOrder: value, eventsOrder: value
}); });
// debounceFetch();
}; };
return ( return (
<div className="relative"> <div className="relative">
<EventsList <div
filter={appliedFilter} className={cn(
onAddFilter={onAddFilter} 'bg-white',
onUpdateFilter={onUpdateFilter} 'py-2 px-4 rounded-xl border border-gray-lighter'
onRemoveFilter={onRemoveFilter} )}
onChangeEventsOrder={onChangeEventsOrder} >
saveRequestPayloads={saveRequestPayloads} <FilterListHeader
onFilterMove={onFilterMove} title={'Events'}
mergeDown showEventsOrder={true}
/> orderProps={searchInstance}
<FilterList onChangeOrder={onChangeEventsOrder}
mergeUp filterSelection={
filter={appliedFilter} <FilterSelection
onAddFilter={onAddFilter} filters={eventOptions}
onUpdateFilter={onUpdateFilter} onFilterClick={(newFilter) => {
onRemoveFilter={onRemoveFilter} console.log('newFilter', newFilter);
onChangeEventsOrder={onChangeEventsOrder} onAddFilter(newFilter);
saveRequestPayloads={saveRequestPayloads} }}>
onFilterMove={onFilterMove} <Button type="default" size="small">
/> <div className="flex items-center gap-1">
<Plus size={16} strokeWidth={1} />
<span>Add Event</span>
</div>
</Button>
</FilterSelection>
}
/>
<UnifiedFilterList
title="Events"
filters={searchInstance.filters.filter(i => i.isEvent)}
isDraggable={true}
showIndices={true}
handleRemove={function(key: string): void {
searchStore.removeFilter(key);
}}
handleUpdate={function(key: string, updatedFilter: any): void {
searchStore.updateFilter(key, updatedFilter);
}}
handleAdd={function(newFilter: Filter): void {
searchStore.addFilter(newFilter);
}}
handleMove={function(draggedIndex: number, newPosition: number): void {
searchStore.moveFilter(draggedIndex, newPosition);
}}
/>
<Divider className="my-3" />
<FilterListHeader
title={'Filters'}
filterSelection={
<FilterSelection
filters={propertyOptions}
onFilterClick={(newFilter) => {
onAddFilter(newFilter);
}}
>
<Button type="default" size="small">
<div className="flex items-center gap-1">
<Plus size={16} strokeWidth={1} />
<span>Filter</span>
</div>
</Button>
</FilterSelection>
} />
<UnifiedFilterList
title="Filters"
filters={searchInstance.filters.filter(i => !i.isEvent)}
isDraggable={false}
showIndices={false}
handleRemove={function(key: string): void {
searchStore.removeFilter(key);
}}
handleUpdate={function(key: string, updatedFilter: any): void {
searchStore.updateFilter(key, updatedFilter);
}}
handleAdd={function(newFilter: Filter): void {
searchStore.addFilter(newFilter);
}}
handleMove={function(draggedIndex: number, newPosition: number): void {
searchStore.moveFilter(draggedIndex, newPosition);
}}
/>
</div>
</div> </div>
); );
} }

View file

@ -1,5 +1,9 @@
import { makeAutoObservable } from 'mobx'; import { makeAutoObservable, runInAction } from 'mobx';
import { makePersistable } from 'mobx-persist-store';
import { filterService } from 'App/services'; import { filterService } from 'App/services';
import { Filter, Operator, COMMON_FILTERS, getOperatorsByType } from './types/filterConstants';
import { FilterKey } from 'Types/filter/filterType';
import { projectStore } from '@/mstore/index';
interface TopValue { interface TopValue {
rowCount?: number; rowCount?: number;
@ -11,17 +15,40 @@ interface TopValues {
[key: string]: TopValue[]; [key: string]: TopValue[];
} }
interface ProjectFilters {
[projectId: string]: Filter[];
}
export default class FilterStore { export default class FilterStore {
topValues: TopValues = {}; topValues: TopValues = {};
filters: ProjectFilters = {};
commonFilters: Filter[] = [];
isLoadingFilters: boolean = true;
filterCache: Record<string, Filter[]> = {};
private pendingFetches: Record<string, Promise<Filter[]>> = {};
constructor() { constructor() {
makeAutoObservable(this); makeAutoObservable(this);
// Set up persistence with 10-minute expiration
/*void makePersistable(this, {
name: 'FilterStore',
// properties: ['filters', 'commonFilters'],
properties: ['filters'],
storage: window.localStorage,
expireIn: 10 * 60 * 1000, // 10 minutes in milliseconds
removeOnExpiration: true
});*/
// Initialize common static filters
this.initCommonFilters();
} }
setTopValues = (key: string, values: Record<string, any> | TopValue[]) => { setTopValues = (key: string, values: Record<string, any> | TopValue[]) => {
const vals = Array.isArray(values) ? values : values.data; const vals = Array.isArray(values) ? values : values.data;
this.topValues[key] = vals?.filter( this.topValues[key] = vals?.filter(
(value) => value !== null && value.value !== '', (value: any) => value !== null && value.value !== ''
); );
}; };
@ -29,13 +56,147 @@ export default class FilterStore {
this.topValues = {}; this.topValues = {};
}; };
fetchTopValues = async (key: string, siteId: string, source?: string) => { fetchTopValues = async (id: string, siteId: string, source?: string) => {
const valKey = `${siteId}_${key}${source || ''}` const valKey = `${siteId}_${id}${source || ''}`;
if (this.topValues[valKey] && this.topValues[valKey].length) { if (this.topValues[valKey] && this.topValues[valKey].length) {
return Promise.resolve(this.topValues[valKey]); return Promise.resolve(this.topValues[valKey]);
} }
return filterService.fetchTopValues(key, source).then((response: []) => { const filter = this.filters[siteId]?.find(i => i.id === id);
if (!filter) {
console.error('Filter not found in store:', id);
return Promise.resolve([]);
}
return filterService.fetchTopValues(filter.name?.toLowerCase(), source).then((response: []) => {
this.setTopValues(valKey, response); this.setTopValues(valKey, response);
}); });
}; };
setFilters = (projectId: string, filters: Filter[]) => {
this.filters[projectId] = filters;
};
getFilters = (projectId: string): Filter[] => {
const filters = this.filters[projectId] || [];
return this.addOperatorsToFilters(filters);
};
setIsLoadingFilters = (loading: boolean) => {
this.isLoadingFilters = loading;
};
resetFilters = () => {
this.filters = {};
};
processFilters = (filters: Filter[], category?: string): Filter[] => {
return filters.map(filter => ({
...filter,
possibleTypes: filter.possibleTypes?.map(type => type.toLowerCase()) || [],
type: filter.possibleTypes?.[0].toLowerCase() || 'string',
category: category || 'custom',
subCategory: category === 'events' ? (filter.autoCaptured ? 'auto' : 'user') : category,
displayName: filter.displayName || filter.name,
icon: FilterKey.LOCATION, // TODO - use actual icons
isEvent: category === 'events',
value: filter.value || [],
propertyOrder: 'and'
}));
};
addOperatorsToFilters = (filters: Filter[]): Filter[] => {
return filters.map(filter => ({
...filter
// operators: filter.operators?.length ? filter.operators : getOperatorsByType(filter.possibleTypes || [])
}));
};
// Modified to not add operators in cache
fetchFilters = async (projectId: string): Promise<Filter[]> => {
// Return cached filters with operators if available
if (this.filters[projectId] && this.filters[projectId].length) {
return Promise.resolve(this.getFilters(projectId));
}
this.setIsLoadingFilters(true);
try {
const response = await filterService.fetchFilters(projectId);
const processedFilters: Filter[] = [];
Object.keys(response.data).forEach((category: string) => {
const { list, total } = response.data[category] || { list: [], total: 0 };
const filters = this.processFilters(list, category);
processedFilters.push(...filters);
});
this.setFilters(projectId, processedFilters);
return this.getFilters(projectId);
} catch (error) {
console.error('Failed to fetch filters:', error);
throw error;
} finally {
this.setIsLoadingFilters(false);
}
};
initCommonFilters = () => {
this.commonFilters = [...COMMON_FILTERS];
};
getAllFilters = (projectId: string): Filter[] => {
const projectFilters = this.filters[projectId] || [];
// return this.addOperatorsToFilters([...this.commonFilters, ...projectFilters]);
return this.addOperatorsToFilters([...projectFilters]);
};
getCurrentProjectFilters = (): Filter[] => {
return this.getAllFilters(projectStore.activeSiteId + '');
};
// getEventFilters = (eventName: string): Filter[] => {
// const filters = await filterService.fetchProperties(eventName)
// return filters;
// // const filters = this.getAllFilters(projectStore.activeSiteId + '');
// // return filters.filter(i => !i.isEvent); // TODO fetch from the API for this event and cache them
// };
getEventFilters = async (eventName: string): Promise<Filter[]> => {
if (this.filterCache[eventName]) {
return this.filterCache[eventName];
}
if (await this.pendingFetches[eventName]) {
return this.pendingFetches[eventName];
}
try {
this.pendingFetches[eventName] = this.fetchAndProcessPropertyFilters(eventName);
const filters = await this.pendingFetches[eventName];
runInAction(() => {
this.filterCache[eventName] = filters;
});
delete this.pendingFetches[eventName];
return filters;
} catch (error) {
delete this.pendingFetches[eventName];
throw error;
}
};
private fetchAndProcessPropertyFilters = async (eventName: string): Promise<Filter[]> => {
const resp = await filterService.fetchProperties(eventName);
const names = resp.data.map((i: any) => i['allProperties.PropertyName']);
const activeSiteId = projectStore.activeSiteId + '';
return this.filters[activeSiteId]?.filter((i: any) => names.includes(i.name)) || [];
};
setCommonFilters = (filters: Filter[]) => {
this.commonFilters = filters;
};
} }

View file

@ -28,18 +28,18 @@ export const checkValues = (key: any, value: any) => {
}; };
export const filterMap = ({ export const filterMap = ({
category, category,
value, value,
key, key,
operator, operator,
sourceOperator, sourceOperator,
source, source,
custom, custom,
isEvent, isEvent,
filters, filters,
sort, sort,
order order
}: any) => ({ }: any) => ({
value: checkValues(key, value), value: checkValues(key, value),
custom, custom,
type: category === FilterCategory.METADATA ? FilterKey.METADATA : key, type: category === FilterCategory.METADATA ? FilterKey.METADATA : key,
@ -60,37 +60,22 @@ export const TAB_MAP: any = {
class SearchStore { class SearchStore {
list: SavedSearch[] = []; list: SavedSearch[] = [];
latestRequestTime: number | null = null; latestRequestTime: number | null = null;
latestList = List(); latestList = List();
alertMetricId: number | null = null; alertMetricId: number | null = null;
instance = new Search(); instance = new Search();
savedSearch: ISavedSearch = new SavedSearch(); savedSearch: ISavedSearch = new SavedSearch();
filterSearchList: any = {}; filterSearchList: any = {};
currentPage = 1; currentPage = 1;
pageSize = PER_PAGE; pageSize = PER_PAGE;
activeTab = { name: 'All', type: 'all' }; activeTab = { name: 'All', type: 'all' };
scrollY = 0; scrollY = 0;
sessions = List(); sessions = List();
total: number = 0; total: number = 0;
latestSessionCount: number = 0; latestSessionCount: number = 0;
loadingFilterSearch = false; loadingFilterSearch = false;
isSaving: boolean = false; isSaving: boolean = false;
activeTags: any[] = []; activeTags: any[] = [];
urlParsed: boolean = false; urlParsed: boolean = false;
searchInProgress = false; searchInProgress = false;
@ -146,7 +131,7 @@ class SearchStore {
editSavedSearch(instance: Partial<SavedSearch>) { editSavedSearch(instance: Partial<SavedSearch>) {
this.savedSearch = new SavedSearch( this.savedSearch = new SavedSearch(
Object.assign(this.savedSearch.toData(), instance), Object.assign(this.savedSearch.toData(), instance)
); );
} }
@ -172,14 +157,14 @@ class SearchStore {
this.filterSearchList = response.reduce( this.filterSearchList = response.reduce(
( (
acc: Record<string, { projectId: number; value: string }[]>, acc: Record<string, { projectId: number; value: string }[]>,
item: any, item: any
) => { ) => {
const { projectId, type, value } = item; const { projectId, type, value } = item;
if (!acc[type]) acc[type] = []; if (!acc[type]) acc[type] = [];
acc[type].push({ projectId, value }); acc[type].push({ projectId, value });
return acc; return acc;
}, },
{}, {}
); );
}) })
.catch((error: any) => { .catch((error: any) => {
@ -207,7 +192,7 @@ class SearchStore {
resetTags = () => { resetTags = () => {
this.activeTags = ['all']; this.activeTags = ['all'];
} };
toggleTag(tag?: iTag) { toggleTag(tag?: iTag) {
if (!tag) { if (!tag) {
@ -302,6 +287,7 @@ class SearchStore {
(i: FilterItem) => i.key === filter.key (i: FilterItem) => i.key === filter.key
); );
// new random key
filter.value = checkFilterValue(filter.value); filter.value = checkFilterValue(filter.value);
filter.filters = filter.filters filter.filters = filter.filters
? filter.filters.map((subFilter: any) => ({ ? filter.filters.map((subFilter: any) => ({
@ -319,6 +305,7 @@ class SearchStore {
oldFilter.merge(updatedFilter); oldFilter.merge(updatedFilter);
this.updateFilter(index, updatedFilter); this.updateFilter(index, updatedFilter);
} else { } else {
filter.key = Math.random().toString(36).substring(7);
this.instance.filters.push(filter); this.instance.filters.push(filter);
this.instance = new Search({ this.instance = new Search({
...this.instance.toData() ...this.instance.toData()
@ -332,12 +319,23 @@ class SearchStore {
} }
} }
moveFilter(draggedIndex: number, newPosition: number) {
const newFilters = this.instance.filters.slice();
const [removed] = newFilters.splice(draggedIndex, 1);
newFilters.splice(newPosition, 0, removed);
this.instance = new Search({
...this.instance.toData(),
filters: newFilters
});
}
addFilterByKeyAndValue( addFilterByKeyAndValue(
key: any, key: any,
value: any, value: any,
operator?: string, operator?: string,
sourceOperator?: string, sourceOperator?: string,
source?: string, source?: string
) { ) {
const defaultFilter = { ...filtersMap[key] }; const defaultFilter = { ...filtersMap[key] };
defaultFilter.value = value; defaultFilter.value = value;
@ -353,20 +351,19 @@ class SearchStore {
this.addFilter(defaultFilter); this.addFilter(defaultFilter);
} }
refreshFilterOptions() {
// TODO
}
updateSearch = (search: Partial<Search>) => { updateSearch = (search: Partial<Search>) => {
this.instance = Object.assign(this.instance, search); this.instance = Object.assign(this.instance, search);
}; };
updateFilter = (index: number, search: Partial<FilterItem>) => { updateFilter = (key: string, search: Partial<FilterItem>) => {
const newFilters = this.instance.filters.map((_filter: any, i: any) => { const newFilters = this.instance.filters.map((f: any) => {
if (i === index) { if (f.key === key) {
return search; return {
...f,
...search
};
} }
return _filter; return f;
}); });
this.instance = new Search({ this.instance = new Search({
@ -375,9 +372,9 @@ class SearchStore {
}); });
}; };
removeFilter = (index: number) => { removeFilter = (key: string) => {
const newFilters = this.instance.filters.filter( const newFilters = this.instance.filters.filter(
(_filter: any, i: any) => i !== index, (f: any) => f.key !== key
); );
this.instance = new Search({ this.instance = new Search({
@ -390,13 +387,9 @@ class SearchStore {
this.scrollY = y; this.scrollY = y;
}; };
async fetchAutoplaySessions(page: number): Promise<void> { async fetchSessions(
// TODO
}
fetchSessions = async (
force: boolean = false, force: boolean = false,
bookmarked: boolean = false, bookmarked: boolean = false
): Promise<void> => { ): Promise<void> => {
if (this.searchInProgress) return; if (this.searchInProgress) return;
const filter = this.instance.toSearch(); const filter = this.instance.toSearch();

View file

@ -61,33 +61,22 @@ export default class Filter implements IFilter {
} }
filterId: string = ''; filterId: string = '';
name: string = ''; name: string = '';
autoOpen = false; autoOpen = false;
filters: FilterItem[] = []; filters: FilterItem[] = [];
excludes: FilterItem[] = []; excludes: FilterItem[] = [];
eventsOrder: string = 'then'; eventsOrder: string = 'then';
eventsOrderSupport: string[] = ['then', 'or', 'and']; eventsOrderSupport: string[] = ['then', 'or', 'and'];
startTimestamp: number = 0; startTimestamp: number = 0;
endTimestamp: number = 0; endTimestamp: number = 0;
eventsHeader: string = 'EVENTS'; eventsHeader: string = 'EVENTS';
page: number = 1; page: number = 1;
limit: number = 10; limit: number = 10;
constructor( constructor(
filters: any[] = [], filters: any[] = [],
private readonly isConditional = false, private readonly isConditional = false,
private readonly isMobile = false, private readonly isMobile = false
) { ) {
makeAutoObservable(this, { makeAutoObservable(this, {
filters: observable, filters: observable,
@ -101,7 +90,7 @@ export default class Filter implements IFilter {
merge: action, merge: action,
addExcludeFilter: action, addExcludeFilter: action,
updateFilter: action, updateFilter: action,
replaceFilters: action, replaceFilters: action
}); });
this.filters = filters.map((i) => new FilterItem(i)); this.filters = filters.map((i) => new FilterItem(i));
} }
@ -146,8 +135,8 @@ export default class Filter implements IFilter {
new FilterItem(undefined, this.isConditional, this.isMobile).fromJson( new FilterItem(undefined, this.isConditional, this.isMobile).fromJson(
i, i,
undefined, undefined,
isHeatmap, isHeatmap
), )
); );
this.eventsOrder = json.eventsOrder; this.eventsOrder = json.eventsOrder;
return this; return this;
@ -156,7 +145,7 @@ export default class Filter implements IFilter {
fromData(data: any) { fromData(data: any) {
this.name = data.name; this.name = data.name;
this.filters = data.filters.map((i: Record<string, any>) => this.filters = data.filters.map((i: Record<string, any>) =>
new FilterItem(undefined, this.isConditional, this.isMobile).fromData(i), new FilterItem(undefined, this.isConditional, this.isMobile).fromData(i)
); );
this.eventsOrder = data.eventsOrder; this.eventsOrder = data.eventsOrder;
return this; return this;
@ -168,7 +157,7 @@ export default class Filter implements IFilter {
filters: this.filters.map((i) => i.toJson()), filters: this.filters.map((i) => i.toJson()),
eventsOrder: this.eventsOrder, eventsOrder: this.eventsOrder,
startTimestamp: this.startTimestamp, startTimestamp: this.startTimestamp,
endTimestamp: this.endTimestamp, endTimestamp: this.endTimestamp
}; };
return json; return json;
} }
@ -182,7 +171,7 @@ export default class Filter implements IFilter {
const json = { const json = {
name: this.name, name: this.name,
filters: this.filters.map((i) => i.toJson()), filters: this.filters.map((i) => i.toJson()),
eventsOrder: this.eventsOrder, eventsOrder: this.eventsOrder
}; };
return json; return json;
} }
@ -204,12 +193,12 @@ export default class Filter implements IFilter {
this.addFilter({ this.addFilter({
...filtersMap[FilterKey.LOCATION], ...filtersMap[FilterKey.LOCATION],
value: [''], value: [''],
operator: 'isAny', operator: 'isAny'
}); });
this.addFilter({ this.addFilter({
...filtersMap[FilterKey.CLICK], ...filtersMap[FilterKey.CLICK],
value: [''], value: [''],
operator: 'onAny', operator: 'onAny'
}); });
} }
@ -217,7 +206,7 @@ export default class Filter implements IFilter {
return { return {
name: this.name, name: this.name,
filters: this.filters.map((i) => i.toJson()), filters: this.filters.map((i) => i.toJson()),
eventsOrder: this.eventsOrder, eventsOrder: this.eventsOrder
}; };
} }
@ -237,7 +226,7 @@ export default class Filter implements IFilter {
value: any, value: any,
operator: undefined, operator: undefined,
sourceOperator: undefined, sourceOperator: undefined,
source: undefined, source: undefined
) { ) {
let defaultFilter = { ...filtersMap[key] }; let defaultFilter = { ...filtersMap[key] };
if (defaultFilter) { if (defaultFilter) {

View file

@ -0,0 +1,194 @@
export interface Operator {
value: string;
label: string;
displayName: string;
description?: string;
}
export interface FilterProperty {
name: string;
displayName: string;
description: string;
type: string; // 'number' | 'string' | 'boolean' | etc.
}
export interface Filter {
id?: string;
name: string;
displayName?: string;
description?: string;
possibleTypes?: string[];
autoCaptured: boolean;
metadataName: string;
category: string; // 'event' | 'filter' | 'action' | etc.
subCategory?: string;
type?: string; // 'number' | 'string' | 'boolean' | etc.
icon?: string;
properties?: FilterProperty[];
operator?: string;
operators?: Operator[];
isEvent?: boolean;
value?: string[];
propertyOrder?: string;
}
export const OPERATORS = {
string: [
{ value: 'is', label: 'is', displayName: 'Is', description: 'Exact match' },
{ value: 'isNot', label: 'isNot', displayName: 'Is not', description: 'Not an exact match' },
{ value: 'contains', label: 'contains', displayName: 'Contains', description: 'Contains the string' },
{
value: 'doesNotContain',
label: 'doesNotContain',
displayName: 'Does not contain',
description: 'Does not contain the string'
},
{ value: 'startsWith', label: 'startsWith', displayName: 'Starts with', description: 'Starts with the string' },
{ value: 'endsWith', label: 'endsWith', displayName: 'Ends with', description: 'Ends with the string' },
{ value: 'isBlank', label: 'isBlank', displayName: 'Is blank', description: 'Is empty or null' },
{ value: 'isNotBlank', label: 'isNotBlank', displayName: 'Is not blank', description: 'Is not empty or null' }
],
number: [
{ value: 'equals', label: 'equals', displayName: 'Equals', description: 'Exactly equals the value' },
{
value: 'doesNotEqual',
label: 'doesNotEqual',
displayName: 'Does not equal',
description: 'Does not equal the value'
},
{ value: 'greaterThan', label: 'greaterThan', displayName: 'Greater than', description: 'Greater than the value' },
{ value: 'lessThan', label: 'lessThan', displayName: 'Less than', description: 'Less than the value' },
{
value: 'greaterThanOrEquals',
label: 'greaterThanOrEquals',
displayName: 'Greater than or equals',
description: 'Greater than or equal to the value'
},
{
value: 'lessThanOrEquals',
label: 'lessThanOrEquals',
displayName: 'Less than or equals',
description: 'Less than or equal to the value'
},
{ value: 'isBlank', label: 'isBlank', displayName: 'Is blank', description: 'Is empty or null' },
{ value: 'isNotBlank', label: 'isNotBlank', displayName: 'Is not blank', description: 'Is not empty or null' }
],
boolean: [
{ value: 'isTrue', label: 'isTrue', displayName: 'Is true', description: 'Value is true' },
{ value: 'isFalse', label: 'isFalse', displayName: 'Is false', description: 'Value is false' },
{ value: 'isBlank', label: 'isBlank', displayName: 'Is blank', description: 'Is null' },
{ value: 'isNotBlank', label: 'isNotBlank', displayName: 'Is not blank', description: 'Is not null' }
],
date: [
{ value: 'on', label: 'on', displayName: 'On', description: 'On the exact date' },
{ value: 'notOn', label: 'notOn', 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: 'onOrBefore', displayName: 'On or before', description: 'On or before the date' },
{ value: 'onOrAfter', label: 'onOrAfter', displayName: 'On or after', description: 'On or after the date' },
{ value: 'isBlank', label: 'isBlank', displayName: 'Is blank', description: 'Is empty or null' },
{ value: 'isNotBlank', label: 'isNotBlank', displayName: 'Is not blank', description: 'Is not empty or null' }
],
array: [
{ value: 'contains', label: 'contains', displayName: 'Contains', description: 'Array contains the value' },
{
value: 'doesNotContain',
label: 'doesNotContain',
displayName: 'Does not contain',
description: 'Array does not contain the value'
},
{ value: 'hasAny', label: 'hasAny', displayName: 'Has any', description: 'Array has any of the values' },
{ value: 'hasAll', label: 'hasAll', displayName: 'Has all', description: 'Array has all of the values' },
{ value: 'isEmpty', label: 'isEmpty', displayName: 'Is empty', description: 'Array is empty' },
{ value: 'isNotEmpty', label: 'isNotEmpty', displayName: 'Is not empty', description: 'Array is not empty' }
]
};
export const COMMON_FILTERS: Filter[] = [];
export const getOperatorsByType = (type: string): Operator[] => {
let operators: Operator[] = [];
switch (type.toLowerCase()) {
case 'string':
operators = OPERATORS.string;
break;
case 'number':
case 'integer':
case 'float':
case 'decimal':
operators = OPERATORS.number;
break;
case 'boolean':
operators = OPERATORS.boolean;
break;
case 'date':
case 'datetime':
case 'timestamp':
operators = OPERATORS.date;
break;
case 'array':
case 'list':
operators = OPERATORS.array;
break;
default:
// Default to string operators if type is unknown
operators = OPERATORS.string;
break;
}
return operators;
};
// export const getOperatorsByType = (types: string[]): Operator[] => {
// const operatorSet = new Set<Operator>();
//
// if (!types || types.length === 0) {
// return [...OPERATORS.string];
// }
//
// // Process each type in the array
// types.forEach(type => {
// let operators: Operator[] = [];
//
// switch (type.toLowerCase()) {
// case 'string':
// operators = OPERATORS.string;
// break;
// case 'number':
// case 'integer':
// case 'float':
// case 'decimal':
// operators = OPERATORS.number;
// break;
// case 'boolean':
// operators = OPERATORS.boolean;
// break;
// case 'date':
// case 'datetime':
// case 'timestamp':
// operators = OPERATORS.date;
// break;
// case 'array':
// case 'list':
// operators = OPERATORS.array;
// break;
// default:
// // Default to string operators if type is unknown
// operators = OPERATORS.string;
// break;
// }
//
// // Add operators to the set
// operators.forEach(operator => {
// operatorSet.add(operator);
// });
// });
//
// // Convert Set back to Array and return
// return Array.from(operatorSet);
// };

View file

@ -2,59 +2,42 @@ import { FilterCategory, FilterKey, FilterType } from 'Types/filter/filterType';
import { import {
conditionalFiltersMap, conditionalFiltersMap,
filtersMap, filtersMap,
mobileConditionalFiltersMap, mobileConditionalFiltersMap
} from 'Types/filter/newFilter'; } from 'Types/filter/newFilter';
import { makeAutoObservable } from 'mobx'; import { makeAutoObservable } from 'mobx';
import { pageUrlOperators } from '../../constants/filterOptions'; import { pageUrlOperators } from '@/constants/filterOptions';
export default class FilterItem { export default class FilterItem {
type: string = ''; type: string = '';
category: FilterCategory = FilterCategory.METADATA; category: FilterCategory = FilterCategory.METADATA;
subCategory: string = ''; subCategory: string = '';
key: string = ''; key: string = '';
label: string = ''; label: string = '';
value: any = ['']; value: any = [''];
isEvent: boolean = false; isEvent: boolean = false;
operator: string = ''; operator: string = '';
hasSource: boolean = false; hasSource: boolean = false;
source: string = ''; source: string = '';
sourceOperator: string = ''; sourceOperator: string = '';
sourceOperatorOptions: any = []; sourceOperatorOptions: any = [];
filters: FilterItem[] = []; filters: FilterItem[] = [];
operatorOptions: any[] = []; operatorOptions: any[] = [];
options: any[] = []; options: any[] = [];
isActive: boolean = true; isActive: boolean = true;
completed: number = 0; completed: number = 0;
dropped: number = 0; dropped: number = 0;
constructor( constructor(
data: any = {}, data: any = {},
private readonly isConditional?: boolean, private readonly isConditional?: boolean,
private readonly isMobile?: boolean, private readonly isMobile?: boolean
) { ) {
makeAutoObservable(this); makeAutoObservable(this);
if (Array.isArray(data.filters)) { if (Array.isArray(data.filters)) {
data.filters = data.filters.map( data.filters = data.filters.map(
(i: Record<string, any>) => new FilterItem(i), (i: Record<string, any>) => new FilterItem(i)
); );
} }
@ -163,7 +146,7 @@ export default class FilterItem {
sourceOperator: this.sourceOperator, sourceOperator: this.sourceOperator,
filters: Array.isArray(this.filters) filters: Array.isArray(this.filters)
? this.filters.map((i) => i.toJson()) ? this.filters.map((i) => i.toJson())
: [], : []
}; };
if (this.type === FilterKey.DURATION) { if (this.type === FilterKey.DURATION) {
json.value = this.value.map((i: any) => (!i ? 0 : i)); json.value = this.value.map((i: any) => (!i ? 0 : i));

View file

@ -1,7 +1,7 @@
import { import {
CUSTOM_RANGE, CUSTOM_RANGE,
DATE_RANGE_VALUES, DATE_RANGE_VALUES,
getDateRangeFromValue, getDateRangeFromValue
} from 'App/dateRange'; } from 'App/dateRange';
import Filter, { IFilter } from 'App/mstore/types/filter'; import Filter, { IFilter } from 'App/mstore/types/filter';
import FilterItem from 'App/mstore/types/filterItem'; import FilterItem from 'App/mstore/types/filterItem';
@ -25,7 +25,7 @@ interface ISearch {
userDevice?: string; userDevice?: string;
fid0?: string; fid0?: string;
events: Event[]; events: Event[];
filters: IFilter[]; filters: FilterItem[];
minDuration?: number; minDuration?: number;
maxDuration?: number; maxDuration?: number;
custom: Record<string, any>; custom: Record<string, any>;
@ -46,62 +46,36 @@ interface ISearch {
export default class Search { export default class Search {
name: string; name: string;
searchId?: number; searchId?: number;
referrer?: string; referrer?: string;
userBrowser?: string; userBrowser?: string;
userOs?: string; userOs?: string;
userCountry?: string; userCountry?: string;
userDevice?: string; userDevice?: string;
fid0?: string; fid0?: string;
events: Event[]; events: Event[];
filters: FilterItem[]; filters: FilterItem[];
minDuration?: number; minDuration?: number;
maxDuration?: number; maxDuration?: number;
custom: Record<string, any>; custom: Record<string, any>;
rangeValue: string; rangeValue: string;
startDate: number; startDate: number;
endDate: number; endDate: number;
groupByUser: boolean; groupByUser: boolean;
sort: string; sort: string;
order: string; order: string;
viewed?: boolean; viewed?: boolean;
consoleLogCount?: number; consoleLogCount?: number;
eventsCount?: number; eventsCount?: number;
suspicious?: boolean; suspicious?: boolean;
consoleLevel?: string; consoleLevel?: string;
strict: boolean; strict: boolean;
eventsOrder: string; eventsOrder: string;
limit: number; limit: number;
constructor(initialData?: Partial<ISearch>) { constructor(initialData?: Partial<ISearch>) {
makeAutoObservable(this, { makeAutoObservable(this, {
filters: observable, filters: observable
}); });
Object.assign(this, { Object.assign(this, {
name: '', name: '',
@ -131,7 +105,7 @@ export default class Search {
strict: false, strict: false,
eventsOrder: 'then', eventsOrder: 'then',
limit: 10, limit: 10,
...initialData, ...initialData
}); });
} }
@ -171,7 +145,7 @@ export default class Search {
toSearch() { toSearch() {
const js: any = { ...this }; const js: any = { ...this };
js.filters = this.filters.map((filter: any) => js.filters = this.filters.map((filter: any) =>
new FilterItem(filter).toJson(), new FilterItem(filter).toJson()
); );
const { startDate, endDate } = this.getDateRange( const { startDate, endDate } = this.getDateRange(
@ -191,7 +165,7 @@ export default class Search {
private getDateRange( private getDateRange(
rangeName: string, rangeName: string,
customStartDate: number, customStartDate: number,
customEndDate: number, customEndDate: number
roundMinutes?: number, roundMinutes?: number,
): { startDate: number; endDate: number } { ): { startDate: number; endDate: number } {
let endDate = new Date().getTime(); let endDate = new Date().getTime();
@ -207,7 +181,9 @@ export default class Search {
break; break;
case CUSTOM_RANGE: case CUSTOM_RANGE:
if (!customStartDate || !customEndDate) { if (!customStartDate || !customEndDate) {
throw new Error('Start date and end date must be provided for CUSTOM_RANGE.'); throw new Error(
'Start date and end date must be provided for CUSTOM_RANGE.'
);
} }
startDate = customStartDate; startDate = customStartDate;
endDate = customEndDate; endDate = customEndDate;
@ -244,16 +220,17 @@ export default class Search {
eventsOrder, eventsOrder,
startDate, startDate,
endDate, endDate,
filters,
// events: events.map((event: any) => new Event(event)), // events: events.map((event: any) => new Event(event)),
filters: filters.map((i: any) => { // filters: filters.map((i: any) => {
const filter = new Filter(i).toData(); // const filter = new Filter(i).toData();
if (Array.isArray(i.filters)) { // if (Array.isArray(i.filters)) {
filter.filters = i.filters.map((f: any) => // filter.filters = i.filters.map((f: any) =>
new Filter({ ...f, subFilter: i.type }).toData(), // new Filter({ ...f, subFilter: i.type }).toData()
); // );
} // }
return filter; // return filter;
}), // })
}); });
} }
} }

View file

@ -11,12 +11,27 @@ export default class FilterService {
this.client = client || new APIClient(); this.client = client || new APIClient();
} }
fetchTopValues = async (key: string, source?: string) => { fetchTopValues = async (name: string, source?: string) => {
let path = `/PROJECT_ID/events/search?type=${key}`; // const r = await this.client.get('/PROJECT_ID/events/search', params);
// https://foss.openreplay.com/api/65/events/search?name=user_device_type&isEvent=false&q=sd
// let path = `/PROJECT_ID/events/search?type=${key}`;
let path = `/PROJECT_ID/events/search?type=${name}`;
if (source) { if (source) {
path += `&source=${source}`; path += `&source=${source}`;
} }
const response = await this.client.get(path); const response = await this.client.get(path);
return await response.json(); return await response.json();
}; };
fetchProperties = async (name: string) => {
let path = `/pa/PROJECT_ID/properties/search?event_name=${name}`;
const response = await this.client.get(path);
return await response.json();
};
fetchFilters = async (projectId: string) => {
const response = await this.client.get(`/pa/${projectId}/filters`);
return await response.json();
};
} }

View file

@ -181,7 +181,7 @@ export enum IssueCategory {
} }
export enum FilterType { export enum FilterType {
STRING = 'STRING', STRING = 'string',
ISSUE = 'ISSUE', ISSUE = 'ISSUE',
BOOLEAN = 'BOOLEAN', BOOLEAN = 'BOOLEAN',
NUMBER = 'NUMBER', NUMBER = 'NUMBER',