diff --git a/frontend/app/PrivateRoutes.tsx b/frontend/app/PrivateRoutes.tsx index 45b5d9dd4..9a6baf1fa 100644 --- a/frontend/app/PrivateRoutes.tsx +++ b/frontend/app/PrivateRoutes.tsx @@ -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]); diff --git a/frontend/app/components/Dashboard/components/FilterSeries/AddStepButton.tsx b/frontend/app/components/Dashboard/components/FilterSeries/AddStepButton.tsx index 54fbb60ee..ef263a707 100644 --- a/frontend/app/components/Dashboard/components/FilterSeries/AddStepButton.tsx +++ b/frontend/app/components/Dashboard/components/FilterSeries/AddStepButton.tsx @@ -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 ( + + {filter.filters?.length > 0 && ( +
+ {filteredSubFilters.map((subFilter: any, index: number) => ( + 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 }) + } + /> + ))}
)} ); } -export default FilterItem; +export default React.memo(FilterItem); diff --git a/frontend/app/components/shared/Filters/FilterList/EventsOrder.tsx b/frontend/app/components/shared/Filters/FilterList/EventsOrder.tsx index 0ad5acc05..a56e02c9f 100644 --- a/frontend/app/components/shared/Filters/FilterList/EventsOrder.tsx +++ b/frontend/app/components/shared/Filters/FilterList/EventsOrder.tsx @@ -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 (
@@ -57,7 +57,7 @@ const EventsOrder = observer(
); - }, + } ); export default EventsOrder; diff --git a/frontend/app/components/shared/Filters/FilterList/FilterList.tsx b/frontend/app/components/shared/Filters/FilterList/FilterList.tsx index 49bc214d8..e3213a74f 100644 --- a/frontend/app/components/shared/Filters/FilterList/FilterList.tsx +++ b/frontend/app/components/shared/Filters/FilterList/FilterList.tsx @@ -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) => {
@@ -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)' }} > { isConditional={isConditional} />
- ) : null, + ) : null )}
); @@ -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>({ i: null, - position: null, + position: null }); const [draggedInd, setDraggedItem] = React.useState(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,15 +207,15 @@ export const EventsList = observer((props: Props) => { hoveredItem.position, props, setHoveredItem, - setDraggedItem, - ], + setDraggedItem + ] ); const eventsNum = filters.filter((i: any) => i.isEvent).length; return (
{ 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 }} >
@@ -232,8 +234,8 @@ export const EventsList = observer((props: Props) => { mode="events" filter={undefined} onFilterClick={onAddFilter} - excludeFilterKeys={excludeFilterKeys} - excludeCategory={excludeCategory} + // excludeFilterKeys={excludeFilterKeys} + // excludeCategory={excludeCategory} >
- ) : null, + ) : null )}
diff --git a/frontend/app/components/shared/Filters/FilterList/FilterListHeader.tsx b/frontend/app/components/shared/Filters/FilterList/FilterListHeader.tsx new file mode 100644 index 000000000..da05fe570 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterList/FilterListHeader.tsx @@ -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 ( +
+ +
{title}
+ {filterSelection} +
+
+ {showEventsOrder && onChangeOrder && ( + + )} + {actions.map((action, index) => ( +
{action}
+ ))} +
+
+ ); +}; + +export default FilterListHeader; diff --git a/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx b/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx new file mode 100644 index 000000000..4fe43b640 --- /dev/null +++ b/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx @@ -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(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 ( +
+ {filters.map((filterItem: any, filterIndex: number) => ( +
handleDragOver(e, filterIndex) : undefined} + onDrop={isDraggable ? handleDrop : undefined} + > + {isDraggable && filters.length > 1 && ( +
handleDragStart(e, filterIndex, `filter-${filterIndex}`)} + onDragEnd={handleDragEnd} + style={{ cursor: draggedInd !== null ? 'grabbing' : 'grab' }} + > + +
+ )} + + updateFilter(filterItem.key, updatedFilter)} + onRemoveFilter={() => removeFilter(filterItem.key)} + saveRequestPayloads={saveRequestPayloads} + disableDelete={cannotDelete} + readonly={readonly} + isConditional={isConditional} + hideIndex={!showIndices} + /> +
+ ))} +
+ ); +}; + +export default UnifiedFilterList; diff --git a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx index 6ef3d7d79..f7cc3c1f3 100644 --- a/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx +++ b/frontend/app/components/shared/Filters/FilterModal/FilterModal.tsx @@ -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]: , - [FilterKey.LOCATION]: , - [FilterKey.INPUT]: , - [FilterKey.CUSTOM]: , - [FilterKey.FETCH]: , - [FilterKey.GRAPHQL]: , - [FilterKey.STATEACTION]: , - [FilterKey.ERROR]: , - [FilterKey.ISSUE]: , - [FilterKey.FETCH_FAILED]: , - [FilterKey.DOM_COMPLETE]: , - [FilterKey.LARGEST_CONTENTFUL_PAINT_TIME]: , - [FilterKey.TTFB]: , - [FilterKey.AVG_CPU_LOAD]: , - [FilterKey.AVG_MEMORY_USAGE]: , - [FilterKey.USERID]: , - [FilterKey.USERANONYMOUSID]: , - [FilterKey.USER_CITY]: , - [FilterKey.USER_STATE]: , - [FilterKey.USER_COUNTRY]: , - [FilterKey.USER_DEVICE]: , - [FilterKey.USER_OS]: , - [FilterKey.USER_BROWSER]: , - [FilterKey.PLATFORM]: , - [FilterKey.REVID]: , - [FilterKey.REFERRER]: , - [FilterKey.DURATION]: , - [FilterKey.TAGGED_ELEMENT]: , - [FilterKey.METADATA]: , - [FilterKey.UTM_SOURCE]: , - [FilterKey.UTM_MEDIUM]: , - [FilterKey.UTM_CAMPAIGN]: , - [FilterKey.FEATURE_FLAG]: , +export const getIconForFilter = (filter: Filter) => ; + +// 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, - excludeKeys: string[] = [], - excludeCategory: string[] = [], - allowedFilterKeys: string[] = [], - mode: 'filters' | 'events', -): Record { - 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, -) => { - const matchingCategories: string[] = []; - const matchingFilters: Record = {}; - 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)) { - matchingCategories.push(name); - matchingFilters[name] = filters[name]; - } else { - const filtersQuery = filters[name].filter((filterOption: any) => - filterOption.label.toLocaleLowerCase().includes(lowerCaseQuery), - ); + const matchingCategories = ['All']; + const matchingFilters = {}; - if (filtersQuery.length > 0) matchingFilters[name] = filtersQuery; - filtersQuery.length > 0 && matchingCategories.push(name); + // 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] = 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 { - 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; - excludeCategory?: Array; - allowedFilterKeys?: Array; - 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) => { - if (filter.icon?.includes('metadata')) { - return IconMap[FilterKey.METADATA]; - } - // @ts-ignore - if (IconMap[filter.key]) { - // @ts-ignore - return IconMap[filter.key]; - } - return ; -}; - -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(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; +}) => ( +
onClick(filter)} + > + {showCategory && filter.category && ( +
+ {filter.subCategory || filter.category} + +
+ )} + + {getIconForFilter(filter)} + + {filter.displayName || filter.name} + + +
+)); + +// Memoized category list component +const CategoryList = React.memo(({ categories, activeCategory, onSelect }: { + categories: string[]; + activeCategory: string; + onSelect: (category: string) => void; +}) => ( + <> + {categories.map((key) => ( +
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} +
+ ))} + +)); + +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 ( +
+
+ +
+
+ ); + } return ( -
+
setSearchQuery(e.target.value)} autoFocus /> -
-
- {matchingCategories.map((key) => ( -
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} -
- ))} - {showMetaCTA ? ( -
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')} -
- ) : null} + + {isResultEmpty ? ( +
+ +
{t('No matching filters.')}
-
- {displayedFilters && displayedFilters.length - ? displayedFilters.map((filter: Record) => ( -
parseAndAdd({ ...filter })} - > - {filter.category ? ( -
- - {filter.subCategory - ? filter.subCategory - : filter.category} - - -
- ) : null} -
- - {getNewIcon(filter)} - - {filter.label} -
-
+ ) : ( +
+
+ +
+
+ {displayedFilters.length > 0 ? ( + displayedFilters.map((filter: Filter, index: number) => ( + )) - : null} - {category === 'META_CTA' && showMetaCTA ? ( -
-
- - {t('No Metadata Available')} -
-
- {t('Identify sessions & data easily by linking user-specific metadata.')} -
- -
- ) : null} -
-
- {showSearchList && ( - -
- {isResultEmpty && !fetchingFilterSearchList ? ( -
- -
- {' '} - {t('No matching filters.')} -
-
) : ( - Object.keys(filterSearchList).map((key, index) => { - const filter = filterSearchList[key]; - const option = filtersMap[key]; - return option ? ( -
-
- {option.label} -
-
- {filter.map((f, i) => ( -
- onFilterSearchClick({ type: key, value: f.value }) - } - > - {getNewIcon(option)} -
- {f.value} -
-
- ))} -
-
- ) : ( - <> - ); - }) +
+
{t('No filters in this category')}
+
)}
-
+
)}
); } -export default observer(FilterModal); +export default React.memo(observer(FilterModal)); diff --git a/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx b/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx index 4e1053193..53e13f3bd 100644 --- a/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx +++ b/frontend/app/components/shared/Filters/FilterSelection/FilterSelection.tsx @@ -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; - excludeCategory?: Array; - allowedFilterKeys?: Array; +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, - onFilterClick, - children, - excludeFilterKeys = [], - excludeCategory = [], - allowedFilterKeys = [], - disabled = false, - isConditional, - isMobile, - mode, - isLive, - } = props; - const [showModal, setShowModal] = useState(false); - const modalRef = React.useRef(null); +const FilterSelection: React.FC = observer(({ + filters, + onFilterClick, + children, + disabled = false, + 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 content = ( + + ); + + const triggerElement = React.isValidElement(children) + ? React.cloneElement(children, { disabled }) + : children; - const label = filter?.category === 'Issue' ? 'Issue' : filter?.label; return ( -
- { - setTimeout(() => { - setShowModal(false); - }, 0); - }} +
+ - {children ? ( - React.cloneElement(children, { - onClick: (e) => { - setShowModal(true); - }, - disabled, - }) - ) : ( -
setShowModal(true)} - > -
- {getNewIcon(filter)} -
-
{`${filter.subCategory ? filter.subCategory : filter.category} •`}
-
- {label} -
-
- )} - {showModal && ( -
- -
- )} - + {triggerElement} +
); -} +}); -export default observer(FilterSelection); +export default FilterSelection; diff --git a/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx b/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx index 896d7d9f6..7691f34b2 100644 --- a/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx +++ b/frontend/app/components/shared/Filters/FilterValue/FilterValue.tsx @@ -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 ( ); } + function BaseDropDown(props) { return ( ); } + switch (filter.type) { case FilterType.NUMBER_MULTIPLE: return ( @@ -145,7 +154,23 @@ function FilterValue(props: Props) { /> ); case FilterType.STRING: - return ; + // return ; + return 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 ; 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) {
{renderValueFiled(filter.value)} diff --git a/frontend/app/components/shared/Filters/SubFilterItem/SubFilterItem.tsx b/frontend/app/components/shared/Filters/SubFilterItem/SubFilterItem.tsx index 9f18cf996..a2408ddb0 100644 --- a/frontend/app/components/shared/Filters/SubFilterItem/SubFilterItem.tsx +++ b/frontend/app/components/shared/Filters/SubFilterItem/SubFilterItem.tsx @@ -9,6 +9,7 @@ interface Props { onRemoveFilter: () => void; isFilter?: boolean; } + export default function SubFilterItem(props: Props) { const { isFilter = false, filterIndex, filter } = props; const canShowValues = !( diff --git a/frontend/app/components/shared/SessionFilters/SessionFilters.tsx b/frontend/app/components/shared/SessionFilters/SessionFilters.tsx index a4f060426..637c9a845 100644 --- a/frontend/app/components/shared/SessionFilters/SessionFilters.tsx +++ b/frontend/app/components/shared/SessionFilters/SessionFilters.tsx @@ -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 (
- - +
+ { + console.log('newFilter', newFilter); + onAddFilter(newFilter); + }}> + + + } + /> + + 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); + }} + /> + + + + { + onAddFilter(newFilter); + }} + > + + + } /> + + !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); + }} + /> +
); } diff --git a/frontend/app/mstore/filterStore.ts b/frontend/app/mstore/filterStore.ts index 93b712f07..f4a181aa1 100644 --- a/frontend/app/mstore/filterStore.ts +++ b/frontend/app/mstore/filterStore.ts @@ -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 = {}; + private pendingFetches: Record> = {}; 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 | 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 => { + // 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 => { + 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 => { + 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; + }; } diff --git a/frontend/app/mstore/searchStore.ts b/frontend/app/mstore/searchStore.ts index 03de2441b..29151f8ce 100644 --- a/frontend/app/mstore/searchStore.ts +++ b/frontend/app/mstore/searchStore.ts @@ -28,18 +28,18 @@ export const checkValues = (key: any, value: any) => { }; export const filterMap = ({ - category, - value, - key, - operator, - sourceOperator, - source, - custom, - isEvent, - filters, - sort, - order -}: any) => ({ + category, + value, + key, + operator, + sourceOperator, + source, + custom, + isEvent, + filters, + sort, + order + }: 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) { 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, - 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) => { this.instance = Object.assign(this.instance, search); }; - updateFilter = (index: number, search: Partial) => { - const newFilters = this.instance.filters.map((_filter: any, i: any) => { - if (i === index) { - return search; + updateFilter = (key: string, search: Partial) => { + 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 { - // TODO - } - - fetchSessions = async ( + async fetchSessions( force: boolean = false, - bookmarked: boolean = false, + bookmarked: boolean = false ): Promise => { if (this.searchInProgress) return; const filter = this.instance.toSearch(); diff --git a/frontend/app/mstore/types/filter.ts b/frontend/app/mstore/types/filter.ts index 19033695a..6a9043156 100644 --- a/frontend/app/mstore/types/filter.ts +++ b/frontend/app/mstore/types/filter.ts @@ -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) => - 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) { diff --git a/frontend/app/mstore/types/filterConstants.ts b/frontend/app/mstore/types/filterConstants.ts new file mode 100644 index 000000000..fa4a23a41 --- /dev/null +++ b/frontend/app/mstore/types/filterConstants.ts @@ -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(); +// +// 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); +// }; diff --git a/frontend/app/mstore/types/filterItem.ts b/frontend/app/mstore/types/filterItem.ts index f8785035d..7436c1541 100644 --- a/frontend/app/mstore/types/filterItem.ts +++ b/frontend/app/mstore/types/filterItem.ts @@ -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) => new FilterItem(i), + (i: Record) => 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)); diff --git a/frontend/app/mstore/types/search.ts b/frontend/app/mstore/types/search.ts index 03c20904c..0df6eb594 100644 --- a/frontend/app/mstore/types/search.ts +++ b/frontend/app/mstore/types/search.ts @@ -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; @@ -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; - 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) { 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; + // }) }); } } diff --git a/frontend/app/services/FilterService.ts b/frontend/app/services/FilterService.ts index 9328c2ab8..cbd251561 100644 --- a/frontend/app/services/FilterService.ts +++ b/frontend/app/services/FilterService.ts @@ -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(); + }; } diff --git a/frontend/app/types/filter/filterType.ts b/frontend/app/types/filter/filterType.ts index 4afa6ec1c..35cc12b68 100644 --- a/frontend/app/types/filter/filterType.ts +++ b/frontend/app/types/filter/filterType.ts @@ -181,7 +181,7 @@ export enum IssueCategory { } export enum FilterType { - STRING = 'STRING', + STRING = 'string', ISSUE = 'ISSUE', BOOLEAN = 'BOOLEAN', NUMBER = 'NUMBER',