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();
function PrivateRoutes() {
const { projectsStore, userStore, integrationsStore, searchStore } =
const { projectsStore, userStore, integrationsStore, searchStore, filterStore } =
useStore();
const onboarding = userStore.onboarding;
const scope = userStore.scopeState;
@ -121,6 +121,7 @@ function PrivateRoutes() {
if (siteId && integrationsStore.integrations.siteId !== siteId) {
integrationsStore.integrations.setSiteId(siteId);
void integrationsStore.integrations.fetchIntegrations(siteId);
void filterStore.fetchFilters(siteId)
}
}, [siteId]);

View file

@ -4,6 +4,8 @@ import { PlusIcon } from 'lucide-react';
import { Button } from 'antd';
import { useStore } from 'App/mstore';
import { useTranslation } from 'react-i18next';
import { Filter } from '@/mstore/types/filterConstants';
import { observer } from 'mobx-react-lite';
interface Props {
series: any;
@ -12,8 +14,10 @@ interface Props {
function AddStepButton({ series, excludeFilterKeys }: Props) {
const { t } = useTranslation();
const { metricStore } = useStore();
const { metricStore, filterStore } = useStore();
const metric: any = metricStore.instance;
const filters: Filter[] = filterStore.getCurrentProjectFilters();
// console.log('filters', filters)
const onAddFilter = (filter: any) => {
series.filter.addFilter(filter);
@ -21,9 +25,9 @@ function AddStepButton({ series, excludeFilterKeys }: Props) {
};
return (
<FilterSelection
filter={undefined}
filters={filters}
onFilterClick={onAddFilter}
excludeFilterKeys={excludeFilterKeys}
mode={'filters'} // excludeFilterKeys={excludeFilterKeys}
>
<Button
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,
fflag,
fflagRead,
bookmarks,
bookmarks
} from 'App/routes';
import { withRouter, RouteComponentProps, useLocation } from 'react-router-dom';
import FlagView from 'Components/FFlags/FlagView/FlagView';
@ -29,15 +29,18 @@ interface IProps extends RouteComponentProps {
};
};
}
// TODO should move these routes to the Routes file
function Overview({ match: { params } }: IProps) {
const { searchStore } = useStore();
const { searchStore, filterStore, projectsStore } = useStore();
const { siteId, fflagId } = params;
const location = useLocation();
const tab = location.pathname.split('/')[2];
const { activeSiteId } = projectsStore;
React.useEffect(() => {
searchStore.setActiveTab(tab);
// void filterStore.fetchFilters(activeSiteId + '');
}, [tab]);
return (
@ -69,5 +72,5 @@ function Overview({ match: { params } }: IProps) {
}
export default withPageTitle('Sessions - OpenReplay')(
withRouter(observer(Overview)),
withRouter(observer(Overview))
);

View file

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

View file

@ -1,29 +1,32 @@
import React from 'react';
import { Button } from 'antd';
import { FilterKey, FilterType } from 'App/types/filter/filterType';
import { CircleMinus } from 'lucide-react';
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import { Button, Space, Typography } from 'antd';
import { FilterKey } from 'App/types/filter/filterType';
import { CircleMinus, Filter as FilterIcon } from 'lucide-react';
import cn from 'classnames';
import FilterOperator from '../FilterOperator';
import FilterSelection from '../FilterSelection';
import FilterValue from '../FilterValue';
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 {
filterIndex?: number;
filter: any; // event/filter
filter: any;
onUpdate: (filter: any) => void;
onRemoveFilter: () => void;
isFilter?: boolean;
saveRequestPayloads?: boolean;
disableDelete?: boolean;
excludeFilterKeys?: Array<string>;
excludeCategory?: Array<string>;
allowedFilterKeys?: Array<string>;
readonly?: boolean;
hideIndex?: boolean;
hideDelete?: boolean;
isConditional?: boolean;
isSubItem?: boolean;
subFilterIndex?: number;
propertyOrder?: string;
onToggleOperator?: (newOp: string) => void;
}
function FilterItem(props: Props) {
@ -34,162 +37,290 @@ function FilterItem(props: Props) {
saveRequestPayloads,
disableDelete = false,
hideDelete = false,
allowedFilterKeys = [],
excludeFilterKeys = [],
excludeCategory = [],
isConditional,
hideIndex = false,
onUpdate,
onRemoveFilter,
readonly,
isSubItem = false,
subFilterIndex,
propertyOrder,
onToggleOperator
} = props;
const canShowValues = !(
const [eventFilterOptions, setEventFilterOptions] = useState<Filter[]>([]);
const { filterStore } = useStore();
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) => {
props.onUpdate({
const isReversed = useMemo(() => filter.key === FilterKey.TAGGED_ELEMENT, [filter.key]);
const replaceFilter = useCallback(
(selectedFilter: any) => {
onUpdate({
...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,
value: filter.value,
filters: filter.filters
? filter.filters.map((i: any) => ({ ...i, value: [''] }))
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) => {
props.onUpdate({ ...filter, operator: value });
};
const onSourceOperatorChange = (e: any, { value }: any) => {
props.onUpdate({ ...filter, sourceOperator: value });
};
const onUpdateSubFilter = (subFilter: any, subFilterIndex: any) => {
props.onUpdate({
const addSubFilter = useCallback(
(selectedFilter: any) => {
onUpdate({
...filter,
filters: filter.filters.map((i: any, index: any) => {
if (index === subFilterIndex) {
return subFilter;
}
return i;
}),
filters: [...filteredSubFilters, selectedFilter]
});
};
},
[filter, onUpdate]
);
const isReversed = filter.key === FilterKey.TAGGED_ELEMENT;
return (
<div className="w-full">
<div className="flex items-center w-full">
<div className="flex items-center w-full flex-wrap">
{!isFilter && !hideIndex && filterIndex >= 0 && (
<div className="flex-shrink-0 w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-lighter mr-2">
<div className="flex items-center flex-grow flex-wrap">
{!isFilter && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && (
<div
className="flex-shrink-0 w-6 h-6 text-xs flex items-center justify-center rounded-full bg-gray-lighter mr-2">
<span>{filterIndex + 1}</span>
</div>
)}
{isSubItem && (
<div className="w-14 text-right">
{subFilterIndex === 0 && (
<Typography.Text className="text-neutral-500/90 mr-2">
where
</Typography.Text>
)}
{subFilterIndex != 0 && propertyOrder && onToggleOperator && (
<Typography.Text
className="text-neutral-500/90 mr-2 cursor-pointer"
onClick={() =>
onToggleOperator(propertyOrder === 'and' ? 'or' : 'and')
}
>
{propertyOrder}
</Typography.Text>
)}
</div>
)}
<FilterSelection
filter={filter}
mode={props.isFilter ? 'filters' : 'events'}
filters={filterSelections}
onFilterClick={replaceFilter}
allowedFilterKeys={allowedFilterKeys}
excludeFilterKeys={excludeFilterKeys}
excludeCategory={excludeCategory}
disabled={disableDelete || props.readonly}
/>
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',
isReversed ? 'flex-row-reverse ml-2' : 'flex-row'
)}
>
{/* Filter with Source */}
{filter.hasSource && (
<>
<FilterOperator
options={filter.sourceOperatorOptions}
onChange={onSourceOperatorChange}
onChange={handleSourceOperatorChange}
className="mx-2 flex-shrink-0 btn-event-operator"
value={filter.sourceOperator}
isDisabled={filter.operatorDisabled || props.readonly}
isDisabled={filter.operatorDisabled || readonly}
/>
<FilterSource filter={filter} onUpdate={props.onUpdate} />
<FilterSource filter={filter} onUpdate={onUpdate} />
</>
)}
{/* Filter values */}
{!isSubFilter && filter.operatorOptions && (
{operatorOptions.length && (
<>
<FilterOperator
options={filter.operatorOptions}
onChange={onOperatorChange}
options={operatorOptions}
onChange={handleOperatorChange}
className="mx-2 flex-shrink-0 btn-sub-event-operator"
value={filter.operator}
isDisabled={filter.operatorDisabled || props.readonly}
isDisabled={filter.operatorDisabled || readonly}
/>
{canShowValues && (
<>
{props.readonly ? (
<div className="rounded bg-active-blue px-2 py-1 ml-2 whitespace-nowrap overflow-hidden text-clip hover:border-neutral-400">
{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
.map((val: string) =>
filter.options && filter.options.length
? (filter.options[
filter.options.findIndex(
(i: any) => i.value === val,
)
]?.label ?? val)
: val,
? filter.options[
filter.options.findIndex((i: any) => i.value === val)
]?.label ?? val
: val
)
.join(', ')}
</div>
) : (
<FilterValue
isConditional={isConditional}
filter={filter}
onUpdate={props.onUpdate}
<FilterValue isConditional={isConditional} filter={filter} onUpdate={onUpdate} />
))}
</>
)}
</div>
</div>
{!readonly && !hideDelete && (
<div className="flex flex-shrink-0 gap-2">
{filter.isEvent && !isSubItem && (
<FilterSelection
filters={eventFilterOptions}
onFilterClick={addSubFilter}
disabled={disableDelete || readonly}
>
<Button
type="text"
icon={<FilterIcon size={13} />}
size="small"
aria-label="Add filter"
title="Filter"
/>
</FilterSelection>
)}
</>
)}
</>
<Button
type="text"
icon={<CircleMinus size={13} />}
disabled={disableDelete}
onClick={onRemoveFilter}
size="small"
aria-label="Remove filter"
/>
</div>
)}
</div>
{/* filters */}
{isSubFilter && (
<div className="grid grid-col ml-3 w-full">
{filter.filters
.filter(
(i: any) =>
(i.key !== FilterKey.FETCH_REQUEST_BODY &&
i.key !== FilterKey.FETCH_RESPONSE_BODY) ||
saveRequestPayloads,
)
.map((subFilter: any, subFilterIndex: any) => (
<SubFilterItem
filterIndex={subFilterIndex}
{filter.filters?.length > 0 && (
<div className="pl-8 w-full">
{filteredSubFilters.map((subFilter: any, index: number) => (
<FilterItem
key={`subfilter-${index}`}
filter={subFilter}
onUpdate={(f) => onUpdateSubFilter(f, subFilterIndex)}
onRemoveFilter={props.onRemoveFilter}
subFilterIndex={index}
onUpdate={(updatedSubFilter) => handleUpdateSubFilter(updatedSubFilter, index)}
onRemoveFilter={() => handleRemoveSubFilter(index)}
isFilter={isFilter}
saveRequestPayloads={saveRequestPayloads}
disableDelete={disableDelete}
readonly={readonly}
hideIndex={hideIndex}
hideDelete={hideDelete}
isConditional={isConditional}
isSubItem={true}
propertyOrder={filter.propertyOrder || 'and'}
onToggleOperator={(newOp) =>
onUpdate({ ...filter, propertyOrder: newOp })
}
/>
))}
</div>
)}
</div>
{props.readonly || props.hideDelete ? null : (
<div className="flex flex-shrink-0 self-start ml-auto">
<Button
disabled={disableDelete}
type="text"
onClick={props.onRemoveFilter}
size="small"
className="btn-remove-step mt-2"
>
<CircleMinus size={14} />
</Button>
</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';
const EventsOrder = observer(
(props: { onChange: (e: any, v: any) => void; filter: any }) => {
const { filter, onChange } = props;
const { eventsOrderSupport } = filter;
(props: { onChange: (e: any, v: any) => void; orderProps: any }) => {
const { onChange, orderProps: { eventsOrder, eventsOrderSupport } } = props;
// const { eventsOrderSupport } = filter;
const { t } = useTranslation();
const menuItems = [
{
key: 'then',
label: t('THEN'),
disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'),
disabled: eventsOrderSupport && !eventsOrderSupport.includes('then')
},
{
key: 'and',
label: t('AND'),
disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'),
disabled: eventsOrderSupport && !eventsOrderSupport.includes('and')
},
{
key: 'or',
label: t('OR'),
disabled: eventsOrderSupport && !eventsOrderSupport.includes('or'),
},
disabled: eventsOrderSupport && !eventsOrderSupport.includes('or')
}
];
const onClick = ({ key }: any) => {
onChange(null, { name: 'eventsOrder', value: key, key });
};
const selected = menuItems.find(
(item) => item.key === filter.eventsOrder,
(item) => item.key === eventsOrder
)?.label;
return (
<div className="flex items-center gap-2">
@ -57,7 +57,7 @@ const EventsOrder = observer(
</Dropdown>
</div>
);
},
}
);
export default EventsOrder;

View file

@ -33,14 +33,15 @@ interface Props {
export const FilterList = observer((props: Props) => {
const { t } = useTranslation();
const {
observeChanges = () => {},
observeChanges = () => {
},
filter,
excludeFilterKeys = [],
isConditional,
onAddFilter,
readonly,
borderless,
excludeCategory,
excludeCategory
} = props;
const { filters } = filter;
@ -53,13 +54,13 @@ export const FilterList = observer((props: Props) => {
<div
className={cn(
'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={{
borderBottomLeftRadius: props.mergeDown ? 0 : undefined,
borderBottomRightRadius: props.mergeDown ? 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' }}>
@ -91,7 +92,7 @@ export const FilterList = observer((props: Props) => {
className="hover:bg-active-blue px-5 "
style={{
marginLeft: '-1rem',
width: 'calc(100% + 2rem)',
width: 'calc(100% + 2rem)'
}}
>
<FilterItem
@ -106,7 +107,7 @@ export const FilterList = observer((props: Props) => {
isConditional={isConditional}
/>
</div>
) : null,
) : null
)}
</div>
);
@ -115,7 +116,8 @@ export const FilterList = observer((props: Props) => {
export const EventsList = observer((props: Props) => {
const { t } = useTranslation();
const {
observeChanges = () => {},
observeChanges = () => {
},
filter,
hideEventsOrder = false,
saveRequestPayloads,
@ -126,7 +128,7 @@ export const EventsList = observer((props: Props) => {
onAddFilter,
cannotAdd,
excludeCategory,
borderless,
borderless
} = props;
const { filters } = filter;
@ -143,7 +145,7 @@ export const EventsList = observer((props: Props) => {
const [hoveredItem, setHoveredItem] = React.useState<Record<string, any>>({
i: null,
position: null,
position: 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;
},
[],
[]
);
const handleDragStart = React.useCallback(
@ -176,7 +178,7 @@ export const EventsList = observer((props: Props) => {
ev.dataTransfer.setDragImage(el, 0, 0);
}
},
[],
[]
);
const handleDrop = React.useCallback(
@ -187,7 +189,7 @@ export const EventsList = observer((props: Props) => {
const newPosition = calculateNewPosition(
draggedInd,
hoveredItem.i,
hoveredItem.position,
hoveredItem.position
);
const reorderedItem = newItems.splice(draggedInd, 1)[0];
@ -205,8 +207,8 @@ export const EventsList = observer((props: Props) => {
hoveredItem.position,
props,
setHoveredItem,
setDraggedItem,
],
setDraggedItem
]
);
const eventsNum = filters.filter((i: any) => i.isEvent).length;
@ -222,7 +224,7 @@ export const EventsList = observer((props: Props) => {
borderBottomRightRadius: props.mergeDown ? 0 : undefined,
borderTopLeftRadius: 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">
@ -232,8 +234,8 @@ export const EventsList = observer((props: Props) => {
mode="events"
filter={undefined}
onFilterClick={onAddFilter}
excludeFilterKeys={excludeFilterKeys}
excludeCategory={excludeCategory}
// excludeFilterKeys={excludeFilterKeys}
// excludeCategory={excludeCategory}
>
<Button
type="default"
@ -263,8 +265,8 @@ export const EventsList = observer((props: Props) => {
className={cn(
'hover:bg-active-blue px-5 pe-3 gap-2 items-center flex',
{
'bg-[#f6f6f6]': hoveredItem.i === filterIndex,
},
'bg-[#f6f6f6]': hoveredItem.i === filterIndex
}
)}
style={{
pointerEvents: 'unset',
@ -290,7 +292,7 @@ export const EventsList = observer((props: Props) => {
hoveredItem.i === filterIndex &&
hoveredItem.position === 'bottom'
? '1px dashed #888'
: undefined,
: undefined
}}
id={`${filter.key}-${filterIndex}`}
onDragOver={(e) => handleDragOverEv(e, filterIndex)}
@ -305,7 +307,7 @@ export const EventsList = observer((props: Props) => {
handleDragStart(
e,
filterIndex,
`${filter.key}-${filterIndex}`,
`${filter.key}-${filterIndex}`
)
}
onDragEnd={() => {
@ -313,7 +315,7 @@ export const EventsList = observer((props: Props) => {
setDraggedItem(null);
}}
style={{
cursor: draggedInd !== null ? 'grabbing' : 'grab',
cursor: draggedInd !== null ? 'grabbing' : 'grab'
}}
>
<GripVertical size={16} />
@ -326,13 +328,13 @@ export const EventsList = observer((props: Props) => {
onRemoveFilter={() => onRemoveFilter(filterIndex)}
saveRequestPayloads={saveRequestPayloads}
disableDelete={cannotDeleteFilter}
excludeFilterKeys={excludeFilterKeys}
// excludeFilterKeys={excludeFilterKeys}
readonly={props.readonly}
isConditional={isConditional}
excludeCategory={excludeCategory}
// excludeCategory={excludeCategory}
/>
</div>
) : null,
) : null
)}
</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 {
AppWindow,
ArrowUpDown,
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 { Pointer, ChevronRight, MousePointerClick } from 'lucide-react';
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { Loader } from 'UI';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import { Input, Button } from 'antd';
import { FilterCategory, FilterKey, FilterType } from 'Types/filter/filterType';
import { Input, Space, Typography } from 'antd';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import stl from './FilterModal.module.css';
import { useTranslation } from 'react-i18next';
import { Filter } from '@/mstore/types/filterConstants';
export const IconMap = {
[FilterKey.CLICK]: <Pointer size={14} />,
[FilterKey.LOCATION]: <Navigation size={14} />,
[FilterKey.INPUT]: <RectangleEllipsis size={14} />,
[FilterKey.CUSTOM]: <Code size={14} />,
[FilterKey.FETCH]: <ArrowUpDown size={14} />,
[FilterKey.GRAPHQL]: <Network size={14} />,
[FilterKey.STATEACTION]: <RectangleEllipsis size={14} />,
[FilterKey.ERROR]: <OctagonAlert size={14} />,
[FilterKey.ISSUE]: <CircleAlert size={14} />,
[FilterKey.FETCH_FAILED]: <Code size={14} />,
[FilterKey.DOM_COMPLETE]: <ArrowUpDown size={14} />,
[FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: <Network size={14} />,
[FilterKey.TTFB]: <Timer size={14} />,
[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} />,
export const getIconForFilter = (filter: Filter) => <MousePointerClick size={14} />;
// Helper function for grouping filters
const groupFiltersByCategory = (filters: Filter[]) => {
if (!filters?.length) return {};
return filters.reduce((acc, filter) => {
const category = filter.category
? filter.category.charAt(0).toUpperCase() + filter.category.slice(1)
: 'Unknown';
if (!acc[category]) acc[category] = [];
acc[category].push(filter);
return acc;
}, {});
};
function filterJson(
jsonObj: Record<string, any>,
excludeKeys: string[] = [],
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),
);
}
// Optimized filtering function with early returns
const getFilteredEntries = (query: string, filters: Filter[]) => {
const trimmedQuery = query.trim().toLowerCase();
export const getMatchingEntries = (
searchQuery: string,
filters: Record<string, any>,
) => {
const matchingCategories: string[] = [];
const matchingFilters: Record<string, any> = {};
const lowerCaseQuery = searchQuery.toLowerCase();
if (!filters || Object.keys(filters).length === 0) {
return { matchingCategories: ['All'], matchingFilters: {} };
}
if (lowerCaseQuery.length === 0) {
if (!trimmedQuery) {
return {
matchingCategories: ['All', ...Object.keys(filters)],
matchingFilters: filters,
matchingFilters: filters
};
}
Object.keys(filters).forEach((name) => {
if (name.toLocaleLowerCase().includes(lowerCaseQuery)) {
const matchingCategories = ['All'];
const matchingFilters = {};
// Single pass through the data with optimized conditionals
Object.entries(filters).forEach(([name, categoryFilters]) => {
const categoryMatch = name.toLowerCase().includes(trimmedQuery);
if (categoryMatch) {
matchingCategories.push(name);
matchingFilters[name] = filters[name];
} else {
const filtersQuery = filters[name].filter((filterOption: any) =>
filterOption.label.toLocaleLowerCase().includes(lowerCaseQuery),
matchingFilters[name] = categoryFilters;
return;
}
const filtered = categoryFilters.filter(
(filter: Filter) =>
filter.displayName?.toLowerCase().includes(trimmedQuery) ||
filter.name?.toLowerCase().includes(trimmedQuery)
);
if (filtersQuery.length > 0) matchingFilters[name] = filtersQuery;
filtersQuery.length > 0 && matchingCategories.push(name);
if (filtered.length) {
matchingCategories.push(name);
matchingFilters[name] = filtered;
}
});
return {
matchingCategories: ['All', ...matchingCategories],
matchingFilters,
};
return { matchingCategories, matchingFilters };
};
interface Props {
isLive?: boolean;
conditionalFilters: any;
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';
}
// Custom debounce hook to optimize search
const useDebounce = (value: any, delay = 300) => {
const [debouncedValue, setDebouncedValue] = useState(value);
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(() => {
if (inputRef.current) {
inputRef.current.focus();
const handler = setTimeout(() => {
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]);
const displayedFilters =
category === 'All'
? Object.entries(matchingFilters).flatMap(([category, filters]) =>
filters.map((f: any) => ({ ...f, category })),
)
: matchingFilters[category];
if (isLoading) {
return (
<div style={{ width: '490px', maxHeight: '380px' }}>
<div className="flex items-center justify-center h-60">
<Loader loading />
</div>
</div>
);
}
return (
<div className={stl.wrapper} style={{ width: '460px', maxHeight: '380px' }}>
<div style={{ width: '490px', maxHeight: '380px' }}>
<Input
ref={inputRef}
className="mb-4 rounded-xl text-lg font-medium placeholder:text-lg placeholder:font-medium placeholder:text-neutral-300"
@ -282,149 +203,41 @@ function FilterModal(props: Props) {
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
<div className="flex gap-2 items-start">
<div className="flex flex-col gap-1">
{matchingCategories.map((key) => (
<div
key={key}
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
className="flex flex-col gap-1 overflow-y-auto w-full h-full"
style={{ maxHeight: 300, flex: 2 }}
>
{displayedFilters && displayedFilters.length
? displayedFilters.map((filter: Record<string, any>) => (
<div
key={filter.label}
className={cn(
'flex items-center p-2 cursor-pointer gap-1 rounded-lg hover:bg-active-blue',
)}
onClick={() => parseAndAdd({ ...filter })}
>
{filter.category ? (
<div
style={{ width: 100 }}
className="text-neutral-500/90 w-full flex justify-between items-center"
>
<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">
{isResultEmpty ? (
<div className="flex items-center flex-col justify-center h-60">
<AnimatedSVG name={ICONS.NO_SEARCH_RESULTS} size={30} />
<div className="font-medium px-3 mt-4">
{' '}
{t('No matching filters.')}
</div>
<div className="font-medium px-3 mt-4">{t('No matching filters.')}</div>
</div>
) : (
Object.keys(filterSearchList).map((key, index) => {
const filter = filterSearchList[key];
const option = filtersMap[key];
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 className="flex gap-2 items-start">
<div className="flex flex-col gap-1 min-w-40">
<CategoryList
categories={matchingCategories}
activeCategory={category}
onSelect={handleCategoryClick}
/>
</div>
<div className="flex flex-col gap-1 overflow-y-auto w-full" style={{ maxHeight: 300, flex: 2 }}>
{displayedFilters.length > 0 ? (
displayedFilters.map((filter: Filter, index: number) => (
<FilterItem
key={`${filter.name}-${index}`}
filter={filter}
onClick={handleFilterClick}
showCategory={category === 'All'}
/>
))
) : (
<></>
);
})
<div className="flex items-center justify-center h-40">
<div className="text-neutral-500">{t('No filters in this category')}</div>
</div>
)}
</div>
</Loader>
</div>
)}
</div>
);
}
export default observer(FilterModal);
export default React.memo(observer(FilterModal));

View file

@ -1,121 +1,64 @@
import React, { useState } from 'react';
import OutsideClickDetectingDiv from 'Shared/OutsideClickDetectingDiv';
import { assist as assistRoute, isRoute } from 'App/routes';
import cn from 'classnames';
import React, { useState, useCallback } from 'react';
import { Popover } from 'antd';
import { observer } from 'mobx-react-lite';
import FilterModal from '../FilterModal';
import { getNewIcon } from '../FilterModal/FilterModal';
import FilterModal from '../FilterModal/FilterModal';
import { Filter } from '@/mstore/types/filterConstants';
const ASSIST_ROUTE = assistRoute();
interface Props {
filter?: any;
onFilterClick: (filter: any) => void;
children?: any;
excludeFilterKeys?: Array<string>;
excludeCategory?: Array<string>;
allowedFilterKeys?: Array<string>;
interface FilterSelectionProps {
filters: Filter[];
onFilterClick: (filter: Filter) => void;
children?: React.ReactNode;
disabled?: boolean;
isConditional?: boolean;
isMobile?: boolean;
mode: 'filters' | 'events';
isLive?: boolean;
}
function FilterSelection(props: Props) {
const {
filter,
const FilterSelection: React.FC<FilterSelectionProps> = observer(({
filters,
onFilterClick,
children,
excludeFilterKeys = [],
excludeCategory = [],
allowedFilterKeys = [],
disabled = false,
isConditional,
isMobile,
mode,
isLive,
} = props;
const [showModal, setShowModal] = useState(false);
const modalRef = React.useRef<HTMLDivElement>(null);
isLive
}) => {
const [open, setOpen] = useState(false);
const onAddFilter = (filter: any) => {
onFilterClick(filter);
setShowModal(false);
};
const handleFilterClick = useCallback((selectedFilter: Filter) => {
onFilterClick(selectedFilter);
setOpen(false);
}, [onFilterClick]);
React.useEffect(() => {
if (showModal && modalRef.current) {
const modalRect = modalRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
if (modalRect.right > viewportWidth) {
modalRef.current.style.left = 'unset';
modalRef.current.style.right = '-280px';
const handleOpenChange = useCallback((newOpen: boolean) => {
if (!disabled) {
setOpen(newOpen);
}
}
}, [showModal]);
}, [disabled]);
const label = filter?.category === 'Issue' ? 'Issue' : filter?.label;
return (
<div className="relative flex-shrink-0 my-1.5">
<OutsideClickDetectingDiv
className="relative"
onClickOutside={() => {
setTimeout(() => {
setShowModal(false);
}, 0);
}}
>
{children ? (
React.cloneElement(children, {
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"
>
const content = (
<FilterModal
isLive={isRoute(ASSIST_ROUTE, window.location.pathname) || isLive}
onFilterClick={onAddFilter}
excludeFilterKeys={excludeFilterKeys}
allowedFilterKeys={allowedFilterKeys}
excludeCategory={excludeCategory}
isConditional={isConditional}
isMobile={isMobile}
mode={mode}
onFilterClick={handleFilterClick}
filters={filters}
/>
</div>
)}
</OutsideClickDetectingDiv>
);
const triggerElement = React.isValidElement(children)
? React.cloneElement(children, { disabled })
: children;
return (
<div className="relative flex-shrink-0">
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={handleOpenChange}
placement="bottomLeft"
overlayClassName="filter-selection-popover rounded-lg border border-gray-200 shadow-sm shadow-gray-200"
destroyTooltipOnHide
arrow={false}
>
{triggerElement}
</Popover>
</div>
);
}
});
export default observer(FilterSelection);
export default FilterSelection;

View file

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

View file

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

View file

@ -1,110 +1,128 @@
import React, { useEffect } from 'react';
import { debounce } from 'App/utils';
import { FilterList, EventsList } from 'Shared/Filters/FilterList';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import useSessionSearchQueryHandler from 'App/hooks/useSessionSearchQueryHandler';
import { FilterKey } from 'App/types/filter/filterType';
import { addOptionsToFilter } from 'App/types/filter/newFilter';
import UnifiedFilterList from 'Shared/Filters/FilterList/UnifiedFilterList';
import FilterSelection from 'Shared/Filters/FilterSelection';
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() {
const { searchStore, projectsStore, customFieldStore, tagWatchStore } =
const { searchStore, projectsStore, filterStore } =
useStore();
const appliedFilter = searchStore.instance;
const metaLoading = customFieldStore.isLoading;
const searchInstance = searchStore.instance;
const saveRequestPayloads =
projectsStore.instance?.saveRequestPayloads ?? false;
const activeProject = projectsStore.active;
const reloadTags = async () => {
const tags = await tagWatchStore.getTags();
if (tags) {
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 allFilterOptions: Filter[] = filterStore.getCurrentProjectFilters();
const eventOptions = allFilterOptions.filter(i => i.isEvent);
const propertyOptions = allFilterOptions.filter(i => !i.isEvent);
const onAddFilter = (filter: any) => {
filter.autoOpen = true;
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) => {
searchStore.edit({
eventsOrder: value,
eventsOrder: value
});
// debounceFetch();
};
return (
<div className="relative">
<EventsList
filter={appliedFilter}
onAddFilter={onAddFilter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
saveRequestPayloads={saveRequestPayloads}
onFilterMove={onFilterMove}
mergeDown
<div
className={cn(
'bg-white',
'py-2 px-4 rounded-xl border border-gray-lighter'
)}
>
<FilterListHeader
title={'Events'}
showEventsOrder={true}
orderProps={searchInstance}
onChangeOrder={onChangeEventsOrder}
filterSelection={
<FilterSelection
filters={eventOptions}
onFilterClick={(newFilter) => {
console.log('newFilter', newFilter);
onAddFilter(newFilter);
}}>
<Button type="default" size="small">
<div className="flex items-center gap-1">
<Plus size={16} strokeWidth={1} />
<span>Add Event</span>
</div>
</Button>
</FilterSelection>
}
/>
<FilterList
mergeUp
filter={appliedFilter}
onAddFilter={onAddFilter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
saveRequestPayloads={saveRequestPayloads}
onFilterMove={onFilterMove}
<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>
);
}

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 { Filter, Operator, COMMON_FILTERS, getOperatorsByType } from './types/filterConstants';
import { FilterKey } from 'Types/filter/filterType';
import { projectStore } from '@/mstore/index';
interface TopValue {
rowCount?: number;
@ -11,17 +15,40 @@ interface TopValues {
[key: string]: TopValue[];
}
interface ProjectFilters {
[projectId: string]: Filter[];
}
export default class FilterStore {
topValues: TopValues = {};
filters: ProjectFilters = {};
commonFilters: Filter[] = [];
isLoadingFilters: boolean = true;
filterCache: Record<string, Filter[]> = {};
private pendingFetches: Record<string, Promise<Filter[]>> = {};
constructor() {
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[]) => {
const vals = Array.isArray(values) ? values : values.data;
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 = {};
};
fetchTopValues = async (key: string, siteId: string, source?: string) => {
const valKey = `${siteId}_${key}${source || ''}`
fetchTopValues = async (id: string, siteId: string, source?: string) => {
const valKey = `${siteId}_${id}${source || ''}`;
if (this.topValues[valKey] && this.topValues[valKey].length) {
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);
});
};
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

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

View file

@ -61,33 +61,22 @@ export default class Filter implements IFilter {
}
filterId: string = '';
name: string = '';
autoOpen = false;
filters: FilterItem[] = [];
excludes: FilterItem[] = [];
eventsOrder: string = 'then';
eventsOrderSupport: string[] = ['then', 'or', 'and'];
startTimestamp: number = 0;
endTimestamp: number = 0;
eventsHeader: string = 'EVENTS';
page: number = 1;
limit: number = 10;
constructor(
filters: any[] = [],
private readonly isConditional = false,
private readonly isMobile = false,
private readonly isMobile = false
) {
makeAutoObservable(this, {
filters: observable,
@ -101,7 +90,7 @@ export default class Filter implements IFilter {
merge: action,
addExcludeFilter: action,
updateFilter: action,
replaceFilters: action,
replaceFilters: action
});
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(
i,
undefined,
isHeatmap,
),
isHeatmap
)
);
this.eventsOrder = json.eventsOrder;
return this;
@ -156,7 +145,7 @@ export default class Filter implements IFilter {
fromData(data: any) {
this.name = data.name;
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;
return this;
@ -168,7 +157,7 @@ export default class Filter implements IFilter {
filters: this.filters.map((i) => i.toJson()),
eventsOrder: this.eventsOrder,
startTimestamp: this.startTimestamp,
endTimestamp: this.endTimestamp,
endTimestamp: this.endTimestamp
};
return json;
}
@ -182,7 +171,7 @@ export default class Filter implements IFilter {
const json = {
name: this.name,
filters: this.filters.map((i) => i.toJson()),
eventsOrder: this.eventsOrder,
eventsOrder: this.eventsOrder
};
return json;
}
@ -204,12 +193,12 @@ export default class Filter implements IFilter {
this.addFilter({
...filtersMap[FilterKey.LOCATION],
value: [''],
operator: 'isAny',
operator: 'isAny'
});
this.addFilter({
...filtersMap[FilterKey.CLICK],
value: [''],
operator: 'onAny',
operator: 'onAny'
});
}
@ -217,7 +206,7 @@ export default class Filter implements IFilter {
return {
name: this.name,
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,
operator: undefined,
sourceOperator: undefined,
source: undefined,
source: undefined
) {
let defaultFilter = { ...filtersMap[key] };
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 {
conditionalFiltersMap,
filtersMap,
mobileConditionalFiltersMap,
mobileConditionalFiltersMap
} from 'Types/filter/newFilter';
import { makeAutoObservable } from 'mobx';
import { pageUrlOperators } from '../../constants/filterOptions';
import { pageUrlOperators } from '@/constants/filterOptions';
export default class FilterItem {
type: string = '';
category: FilterCategory = FilterCategory.METADATA;
subCategory: string = '';
key: string = '';
label: string = '';
value: any = [''];
isEvent: boolean = false;
operator: string = '';
hasSource: boolean = false;
source: string = '';
sourceOperator: string = '';
sourceOperatorOptions: any = [];
filters: FilterItem[] = [];
operatorOptions: any[] = [];
options: any[] = [];
isActive: boolean = true;
completed: number = 0;
dropped: number = 0;
constructor(
data: any = {},
private readonly isConditional?: boolean,
private readonly isMobile?: boolean,
private readonly isMobile?: boolean
) {
makeAutoObservable(this);
if (Array.isArray(data.filters)) {
data.filters = data.filters.map(
(i: Record<string, any>) => new FilterItem(i),
(i: Record<string, any>) => new FilterItem(i)
);
}
@ -163,7 +146,7 @@ export default class FilterItem {
sourceOperator: this.sourceOperator,
filters: Array.isArray(this.filters)
? this.filters.map((i) => i.toJson())
: [],
: []
};
if (this.type === FilterKey.DURATION) {
json.value = this.value.map((i: any) => (!i ? 0 : i));

View file

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

View file

@ -11,12 +11,27 @@ export default class FilterService {
this.client = client || new APIClient();
}
fetchTopValues = async (key: string, source?: string) => {
let path = `/PROJECT_ID/events/search?type=${key}`;
fetchTopValues = async (name: string, source?: string) => {
// 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) {
path += `&source=${source}`;
}
const response = await this.client.get(path);
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 {
STRING = 'STRING',
STRING = 'string',
ISSUE = 'ISSUE',
BOOLEAN = 'BOOLEAN',
NUMBER = 'NUMBER',