feat(ui): dynamic fitlers - apply filters to cards

This commit is contained in:
Shekar Siri 2025-04-04 18:28:00 +02:00
parent 6a100561bf
commit 9ee8cbd24a
19 changed files with 1007 additions and 756 deletions

View file

@ -1,11 +1,16 @@
import React, { useEffect, useState } from 'react';
import { EventsList, FilterList } from 'Shared/Filters/FilterList';
import React from 'react';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import { Button, Space } from 'antd';
import { Button, Divider, Space } from 'antd';
import { ChevronDown, ChevronUp, Trash } from 'lucide-react';
import ExcludeFilters from './ExcludeFilters';
import SeriesName from './SeriesName';
import FilterListHeader from 'Shared/Filters/FilterList/FilterListHeader';
import FilterSelection from 'Shared/Filters/FilterSelection';
import { Filter } from '@/mstore/types/filterConstants';
import { Plus } from '.store/lucide-react-virtual-9282d60eb0/package';
import UnifiedFilterList from 'Shared/Filters/FilterList/UnifiedFilterList';
import { useStore } from '@/mstore';
const FilterCountLabels = observer(
(props: { filters: any; toggleExpand: any }) => {
@ -38,7 +43,7 @@ const FilterCountLabels = observer(
</Space>
</div>
);
},
}
);
const FilterSeriesHeader = observer(
@ -62,8 +67,8 @@ const FilterSeriesHeader = observer(
'px-4 ps-2 h-12 flex items-center relative bg-white border-gray-lighter border-t border-l border-r rounded-t-xl',
{
hidden: props.hidden,
'rounded-b-xl': !props.expanded,
},
'rounded-b-xl': !props.expanded
}
)}
>
<Space className="mr-auto" size={30}>
@ -106,7 +111,7 @@ const FilterSeriesHeader = observer(
</Space>
</div>
);
},
}
);
interface Props {
@ -130,7 +135,8 @@ interface Props {
function FilterSeries(props: Props) {
const {
observeChanges = () => {},
observeChanges = () => {
},
canDelete,
hideHeader = false,
emptyMessage = 'Add an event or filter step to define the series.',
@ -142,12 +148,17 @@ function FilterSeries(props: Props) {
removeEvents,
collapseState,
onToggleCollapse,
excludeCategory,
excludeCategory
} = props;
const { filterStore } = useStore();
const expanded = isHeatmap || !collapseState;
const setExpanded = onToggleCollapse;
const { series, seriesIndex } = props;
const allFilterOptions: Filter[] = filterStore.getCurrentProjectFilters();
const eventOptions: Filter[] = allFilterOptions.filter((i) => i.isEvent);
const propertyOptions: Filter[] = allFilterOptions.filter((i) => !i.isEvent);
const onUpdateFilter = (filterIndex: any, filter: any) => {
series.filter.updateFilter(filterIndex, filter);
observeChanges();
@ -174,6 +185,8 @@ function FilterSeries(props: Props) {
observeChanges();
};
console.log('series.filter.filters', series.filter.filters);
return (
<div>
{canExclude && <ExcludeFilters filter={series.filter} />}
@ -216,33 +229,79 @@ function FilterSeries(props: Props) {
{expanded ? (
<>
{removeEvents ? null : (
<EventsList
filter={series.filter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
supportsEmpty={supportsEmpty}
onFilterMove={onFilterMove}
excludeFilterKeys={excludeFilterKeys}
onAddFilter={onAddFilter}
mergeUp={!hideHeader}
mergeDown
cannotAdd={isHeatmap}
excludeCategory={excludeCategory}
/>
<div className="bg-white rounded-b-xl border p-4">
<FilterListHeader
title={'Events'}
showEventsOrder={series.filter.filters.filter((f: any) => f.isEvent).length > 0}
orderProps={{
eventsOrder: series.filter.eventsOrder,
eventsOrderSupport: ['then', 'and', 'or']
}}
onChangeOrder={onChangeEventsOrder}
filterSelection={
<FilterSelection
filters={eventOptions}
onFilterClick={(newFilter: Filter) => {
onAddFilter(newFilter);
}}
>
<Button type="default" size="small">
<div className="flex items-center gap-1">
<Plus size={16} strokeWidth={1} />
<span>Add</span>
</div>
</Button>
</FilterSelection>
}
/>
<UnifiedFilterList
title="Events"
filters={series.filter.filters.filter((f: any) => f.isEvent)}
isDraggable={true}
showIndices={true}
className="mt-2"
handleRemove={onRemoveFilter}
handleUpdate={onUpdateFilter}
handleAdd={onAddFilter}
handleMove={onFilterMove}
/>
<Divider className="my-2" />
<FilterListHeader
title={'Filters'}
showEventsOrder={series.filter.filters.map((f: any) => !f.isEvent).length > 0}
filterSelection={
<FilterSelection
filters={propertyOptions}
onFilterClick={(newFilter: Filter) => {
onAddFilter(newFilter);
}}
>
<Button type="default" size="small">
<div className="flex items-center gap-1">
<Plus size={16} strokeWidth={1} />
<span>Add</span>
</div>
</Button>
</FilterSelection>
}
/>
<UnifiedFilterList
title="Events"
filters={series.filter.filters.filter((f: any) => !f.isEvent)}
isDraggable={false}
showIndices={false}
className="mt-2"
handleRemove={onRemoveFilter}
handleUpdate={onUpdateFilter}
handleAdd={onAddFilter}
handleMove={onFilterMove}
/>
</div>
)}
<FilterList
filter={series.filter}
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
supportsEmpty={supportsEmpty}
onFilterMove={onFilterMove}
excludeFilterKeys={excludeFilterKeys}
onAddFilter={onAddFilter}
mergeUp={!removeEvents}
excludeCategory={excludeCategory}
/>
</>
) : null}
</div>

View file

@ -26,7 +26,7 @@ interface Props {
isSubItem?: boolean;
subFilterIndex?: number;
propertyOrder?: string;
onToggleOperator?: (newOp: string) => void;
onPropertyOrderChange?: (newOp: string) => void;
parentEventFilterOptions?: Filter[];
isDragging?: boolean;
isFirst?: boolean;
@ -47,7 +47,7 @@ function FilterItem(props: Props) {
isSubItem = false,
subFilterIndex = 0, // Default to 0
propertyOrder,
onToggleOperator,
onPropertyOrderChange,
parentEventFilterOptions,
isDragging,
isFirst = false // Default to false
@ -70,27 +70,59 @@ function FilterItem(props: Props) {
const operatorOptions = getOperatorsByType(filter.type);
useEffect(() => {
let isMounted = true; // Mounted flag
async function loadFilters() {
if (!isSubItem && filter.isEvent && filter.name) {
const shouldFetch = !isSubItem && filter.isEvent && filter.name;
const fetchName = filter.name; // Capture value at effect start
if (shouldFetch) {
try {
setEventFiltersLoading(true);
const options = await filterStore.getEventFilters(filter.name);
setEventFilterOptions(options);
// Only set loading if not already loading for this specific fetch
if (isMounted) setEventFiltersLoading(true);
const options = await filterStore.getEventFilters(fetchName);
// Check mount status AND if the relevant dependencies are still the same
if (isMounted && filter.name === fetchName && !isSubItem && filter.isEvent) {
// Avoid setting state if options haven't actually changed (optional optimization)
// This requires comparing options, which might be complex/costly.
// Sticking to setting state is usually fine if dependencies are stable.
setEventFilterOptions(options);
}
} catch (error) {
console.error('Failed to load event filters:', error);
setEventFilterOptions([]);
if (isMounted && filter.name === fetchName && !isSubItem && filter.isEvent) {
setEventFilterOptions([]);
}
} finally {
setEventFiltersLoading(false);
if (isMounted && filter.name === fetchName && !isSubItem && filter.isEvent) {
setEventFiltersLoading(false);
}
}
} else {
if (eventFilterOptions.length > 0) {
setEventFilterOptions([]);
// Reset state only if necessary and component is mounted
if (isMounted) {
// Avoid calling setState if already in the desired state
if (eventFilterOptions.length > 0) {
setEventFilterOptions([]);
}
// Might need to check loading state too if it could be stuck true
if (eventFiltersLoading) {
setEventFiltersLoading(false);
}
}
}
}
void loadFilters();
}, [filter.name, filter.isEvent, isSubItem, filterStore]);
return () => {
isMounted = false; // Cleanup on unmount
};
// Dependencies should be the minimal primitive values or stable references
// that determine *if* and *what* to fetch.
}, [filter.name, filter.isEvent, isSubItem, filterStore]); //
const canShowValues = useMemo(
() =>
@ -169,7 +201,7 @@ function FilterItem(props: Props) {
const newSubFilter = {
...selectedFilter,
value: selectedFilter.value || [''],
operator: selectedFilter.operator || getOperatorsByType(selectedFilter.type)[0]?.value // Default operator
operator: selectedFilter.operator || 'is'
};
onUpdate({
...filter,
@ -193,7 +225,6 @@ function FilterItem(props: Props) {
return (
<div className={cn('w-full', isDragging ? 'opacity-50' : '')}>
<div className="flex items-start w-full gap-x-2"> {/* Use items-start */}
{!isSubItem && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && (
<div
className="flex-shrink-0 w-6 h-6 mt-[2px] text-xs flex items-center justify-center rounded-full bg-gray-lightest text-gray-600 font-medium"> {/* Align index top */}
@ -203,20 +234,20 @@ function FilterItem(props: Props) {
{isSubItem && (
<div
className="flex-shrink-0 w-14 text-right text-sm text-neutral-500/90 pr-2">
className="flex-shrink-0 w-14 text-right text-neutral-500/90 pr-2">
{subFilterIndex === 0 && (
<Typography.Text className="text-inherit">
where
</Typography.Text>
)}
{subFilterIndex !== 0 && propertyOrder && onToggleOperator && (
{subFilterIndex !== 0 && propertyOrder && onPropertyOrderChange && (
<Typography.Text
className={cn(
'text-inherit',
!readonly && 'cursor-pointer hover:text-main transition-colors'
)}
onClick={() =>
!readonly && onToggleOperator(propertyOrder === 'and' ? 'or' : 'and')
!readonly && onPropertyOrderChange(propertyOrder === 'and' ? 'or' : 'and')
}
>
{propertyOrder}
@ -227,7 +258,7 @@ function FilterItem(props: Props) {
{/* Main content area */}
<div
className="flex flex-grow flex-wrap gap-x-1 items-center">
className="flex flex-grow flex-wrap gap-x-2 items-center">
<FilterSelection
filters={filterSelections}
onFilterClick={replaceFilter}
@ -254,7 +285,7 @@ function FilterItem(props: Props) {
{/* Category/SubCategory */}
{hasCategory && (
<span className="text-neutral-500/90 capitalize text-sm truncate">
<span className="text-neutral-500/90 capitalize truncate">
{categoryPart}
</span>
)}
@ -263,7 +294,7 @@ function FilterItem(props: Props) {
<span className="text-neutral-400"></span>
)}
<span className="text-sm text-black truncate">
<span className="text-black truncate">
{hasName ? namePart : (hasCategory ? '' : defaultText)} {/* Show name or placeholder */}
</span>
</Space>
@ -301,7 +332,7 @@ function FilterItem(props: Props) {
{canShowValues &&
(readonly ? (
<div
className="rounded bg-gray-lightest text-gray-dark px-2 py-1 text-sm whitespace-nowrap overflow-hidden text-ellipsis border border-gray-light max-w-xs"
className="rounded bg-gray-lightest text-gray-dark px-2 py-1 whitespace-nowrap overflow-hidden text-ellipsis border border-gray-light max-w-xs"
title={filter.value.join(', ')}
>
{filter.value
@ -359,20 +390,20 @@ function FilterItem(props: Props) {
{filteredSubFilters.length > 0 && (
<div
className={cn(
'relative w-full mt-3 mb-2 flex flex-col gap-2' // Relative parent for border
'relative w-full mt-3 mb-2 flex flex-col gap-2'
)}
>
{/* Dashed line */}
<div className={cn(
'absolute top-0 bottom-0 left-1 w-px',
'border-l border-dashed border-gray-300',
subFilterMarginLeftClass // Dynamic margin based on parent index visibility
subFilterMarginLeftClass
)} style={{ height: 'calc(100% - 4px)' }} />
{filteredSubFilters.map((subFilter: any, index: number) => (
<div
key={`subfilter-wrapper-${filter.id || filterIndex}-${subFilter.key || index}`}
className={cn('relative', subFilterPaddingLeftClass)} // Apply padding to the wrapper, keep relative
className={cn('relative', subFilterPaddingLeftClass)}
>
<FilterItem
filter={subFilter}
@ -382,14 +413,14 @@ function FilterItem(props: Props) {
saveRequestPayloads={saveRequestPayloads}
disableDelete={disableDelete}
readonly={readonly}
hideIndex={true} // Sub-items always hide index
hideIndex={true}
hideDelete={hideDelete}
isConditional={isConditional}
isSubItem={true}
propertyOrder={filter.propertyOrder || 'and'} // Sub-items use parent's propertyOrder
onToggleOperator={onToggleOperator} // Pass down the parent's toggle function
parentEventFilterOptions={isSubItem ? parentEventFilterOptions : eventFilterOptions} // Pass options down
isFirst={index === 0} // Mark the first sub-filter
propertyOrder={filter.propertyOrder || 'and'}
onPropertyOrderChange={onPropertyOrderChange}
parentEventFilterOptions={isSubItem ? parentEventFilterOptions : eventFilterOptions}
isFirst={index === 0}
/>
</div>
))}

View file

@ -252,7 +252,10 @@ export const EventsList = observer((props: Props) => {
<div className="ml-auto">
{!hideEventsOrder && (
<EventsOrder filter={filter} onChange={props.onChangeEventsOrder} />
<EventsOrder orderProps={{
eventsOrder: filter.eventsOrder,
eventsOrderSupport: filter.eventsOrderSupport
}} onChange={props.onChangeEventsOrder} />
)}
{actions &&
actions.map((action, index) => <div key={index}>{action}</div>)}

View file

@ -63,9 +63,6 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
const calculateNewPosition = useCallback(
(hoverIndex: number, hoverPosition: string) => {
// Calculate the target *visual* position
// If hovering top half, target index is hoverIndex.
// If bottom half, target index is hoverIndex + 1.
return hoverPosition === 'bottom' ? hoverIndex + 1 : hoverIndex;
},
[]
@ -124,13 +121,10 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
let newPosition = calculateNewPosition(hoverIndex, hoverPosition);
// Important: Adjust newPosition if dragging downwards past the original position
// because the removal shifts subsequent indices up.
if (dragInd < newPosition) {
newPosition--;
}
// Only call move if the position actually changed
if (dragInd !== newPosition && !(dragInd === hoverIndex && hoverPosition === 'top') && !(dragInd === hoverIndex - 1 && hoverPosition === 'bottom')) {
handleMove(dragInd, newPosition);
}
@ -148,21 +142,18 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
}, []);
const handleDragLeave = useCallback(() => {
// Only clear if leaving the specific item, not just moving within it
setHoveredItem({ i: null, position: null });
}, []);
return filters.length ? (
<div className={cn('flex flex-col gap-2', className)} style={style}>
<div className={cn('flex flex-col', className)} style={style}>
{filters.map((filterItem: any, filterIndex: number) => (
<div
key={`filter-${filterItem.key || filterIndex}`}
className={cn('flex gap-2 items-start hover:bg-active-blue/5 -mx-5 px-5 pe-3 transition-colors duration-100 relative', { // Lighter hover, keep relative
className={cn('flex gap-2 py-2 items-start hover:bg-active-blue -mx-5 px-5 pe-3 transition-colors duration-100 relative', { // Lighter hover, keep relative
'opacity-50': draggedInd === filterIndex,
// Add top/bottom borders based on hover state for visual feedback
'border-t-2 border-dashed border-teal': hoveredItem.i === filterIndex && hoveredItem.position === 'top',
'border-b-2 border-dashed border-teal': hoveredItem.i === filterIndex && hoveredItem.position === 'bottom',
// Add negative margin to compensate for border height only when border is visible
'-mt-0.5': hoveredItem.i === filterIndex && hoveredItem.position === 'top',
'-mb-0.5': hoveredItem.i === filterIndex && hoveredItem.position === 'bottom'
})}
@ -177,7 +168,6 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
{isDraggable && filters.length > 1 && (
<div
className="cursor-grab text-neutral-500 hover:text-neutral-700 pt-[4px] flex-shrink-0" // Align handle visually
// Draggable is set on parent div
style={{ cursor: draggedInd !== null ? 'grabbing' : 'grab' }}
title="Drag to reorder"
>
@ -186,22 +176,26 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
)}
{!isDraggable && showIndices &&
<div className="w-4 flex-shrink-0" />} {/* Placeholder for alignment if not draggable but indices shown */}
<div className="w-4 flex-shrink-0" />}
{!isDraggable && !showIndices &&
<div className="w-4 flex-shrink-0" />} {/* Placeholder for alignment if not draggable and no indices */}
<div className="w-4 flex-shrink-0" />}
<FilterItem
filterIndex={showIndices ? filterIndex : undefined}
filter={filterItem}
onUpdate={(updatedFilter) => updateFilter(filterItem.key, updatedFilter)}
onRemoveFilter={() => removeFilter(filterItem.key)}
onUpdate={(updatedFilter) => updateFilter(filterItem.id, updatedFilter)}
onRemoveFilter={() => removeFilter(filterItem.id)}
saveRequestPayloads={saveRequestPayloads}
disableDelete={cannotDelete}
readonly={readonly}
isConditional={isConditional}
hideIndex={!showIndices}
isDragging={draggedInd === filterIndex}
onPropertyOrderChange={filterItem.isEvent ? (order: string) => {
const newFilter = { ...filterItem, propertyOrder: order };
updateFilter(filterItem.id, newFilter);
} : undefined}
// Pass down if this is the first item for potential styling (e.g., no 'and'/'or' toggle)
isFirst={filterIndex === 0}
/>

View file

@ -1,28 +1,26 @@
import React from 'react';
import { Dropdown, Menu, Button } from 'antd'; // Import Dropdown, Menu, Button
import { DownOutlined } from '@ant-design/icons'; // Optional: Icon for the button
import { Dropdown, Menu, Button, Typography } from 'antd';
interface OptionType {
label: React.ReactNode; // Label can be text or other React elements
value: string | number; // Value is typically string or number
label: React.ReactNode;
value: string | number;
}
interface Props {
name: string;
options: OptionType[];
value?: string | number; // Should match the type of OptionType.value
value?: string | number;
onChange: (
event: unknown, // Keep original signature for compatibility upstream
event: unknown,
payload: { name: string; value: string | number | undefined }
) => void;
isDisabled?: boolean;
className?: string;
placeholder?: string;
allowClear?: boolean; // Prop name from original component
popupClassName?: string; // Use this for the dropdown overlay class
allowClear?: boolean;
popupClassName?: string;
}
// Define a special key for the clear action
const CLEAR_VALUE_KEY = '__antd_clear_value__';
function FilterOperator(props: Props) {
@ -33,37 +31,30 @@ function FilterOperator(props: Props) {
onChange,
isDisabled = false,
className = '',
placeholder = 'Select', // Default placeholder
placeholder = 'select', // Default placeholder
allowClear = false, // Default from original component
popupClassName = 'shadow-lg border border-gray-200 rounded-md w-fit' // Default popup class
} = props;
// Find the label of the currently selected option
const selectedOption = options.find(option => option.value === value);
const displayLabel = selectedOption ? selectedOption.label : placeholder;
const displayLabel = selectedOption ? selectedOption.label :
<Typography.Text className="text-neutral-600">{placeholder}</Typography.Text>;
// Handler for menu item clicks
const handleMenuClick = (e: { key: string }) => {
let selectedValue: string | number | undefined;
if (e.key === CLEAR_VALUE_KEY) {
// Handle the clear action
selectedValue = undefined;
} else {
// Find the option corresponding to the key (which we set as the value)
// Antd Menu keys are strings, so convert value to string for comparison/lookup if needed
const clickedOption = options.find(option => String(option.value) === e.key);
selectedValue = clickedOption?.value;
}
// Call the original onChange prop with the expected structure
onChange(null, { name: name, value: selectedValue });
};
// Construct the menu items
const menu = (
<Menu onClick={handleMenuClick} selectedKeys={value !== undefined ? [String(value)] : []}>
{/* Add Clear Option if allowClear is true and a value is selected */}
{allowClear && value !== undefined && (
<>
<Menu.Item key={CLEAR_VALUE_KEY} danger>
@ -85,12 +76,11 @@ function FilterOperator(props: Props) {
<>
<Dropdown
overlay={menu}
trigger={['click']} // Open dropdown on click
trigger={['click']}
disabled={isDisabled}
overlayClassName={popupClassName} // Apply the custom class to the overlay
overlayClassName={popupClassName}
>
{/* The Button acts as the trigger */}
<Button type="default" size="small" disabled={isDisabled} className="w-fit text-sm">
<Button type="default" size="small" disabled={isDisabled} className="w-fit">
{displayLabel}
</Button>
</Dropdown>

View file

@ -1,5 +1,4 @@
import React, { useState, useCallback } from 'react';
// Import Spin and potentially classnames
import { Popover, Spin } from 'antd';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
@ -11,8 +10,8 @@ interface FilterSelectionProps {
onFilterClick: (filter: Filter) => void;
children?: React.ReactNode;
disabled?: boolean;
isLive?: boolean; // This prop seems unused, consider removing if not needed downstream
loading?: boolean; // <-- Add loading prop
isLive?: boolean;
loading?: boolean;
}
const FilterSelection: React.FC<FilterSelectionProps> = observer(({
@ -26,43 +25,32 @@ const FilterSelection: React.FC<FilterSelectionProps> = observer(({
const [open, setOpen] = useState(false);
const handleFilterClick = useCallback((selectedFilter: Filter) => {
// Don't do anything if loading - though modal shouldn't be clickable then anyway
if (loading) return;
onFilterClick(selectedFilter);
setOpen(false);
}, [onFilterClick, loading]);
const handleOpenChange = useCallback((newOpen: boolean) => {
// Prevent opening if disabled or loading
if (!disabled && !loading) {
setOpen(newOpen);
} else if (!newOpen) {
// Allow closing even if disabled/loading (e.g., clicking outside)
setOpen(newOpen);
}
}, [disabled, loading]);
// Determine the content for the Popover
const content = (
loading
// Show a spinner centered in the popover content area while loading
? <div className="p-4 flex justify-center items-center" style={{ minHeight: '100px', minWidth: '150px' }}>
<Spin />
</div>
// Otherwise, show the filter modal
: <FilterModal
onFilterClick={handleFilterClick}
filters={filters}
// If FilterModal needs to know about the live status, pass it down:
// isLive={isLive}
/>
);
// Combine disabled and loading states for the trigger element
const isDisabled = disabled || loading;
// Clone the trigger element (children) and pass the effective disabled state
// Also add loading class for potential styling (e.g., opacity)
const triggerElement = React.isValidElement(children)
? React.cloneElement(children as React.ReactElement<any>, {
disabled: isDisabled,
@ -71,21 +59,20 @@ const FilterSelection: React.FC<FilterSelectionProps> = observer(({
: children;
return (
// Add a class to the wrapper if needed, e.g., for opacity when loading
// <div className={cn('relative flex-shrink-0')}>
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={handleOpenChange}
placement="bottomLeft"
// Consistent styling class name with your original
overlayClassName="filter-selection-popover rounded-lg border border-gray-200 shadow-sm shadow-gray-200 overflow-hidden"
destroyTooltipOnHide
arrow={false}
>
{triggerElement}
</Popover>
<Popover
content={content}
trigger="click"
open={open}
onOpenChange={handleOpenChange}
placement="bottomLeft"
// Consistent styling class name with your original
overlayClassName="filter-selection-popover rounded-lg border border-gray-200 shadow-sm shadow-gray-200 overflow-hidden"
destroyTooltipOnHide
arrow={false}
>
{triggerElement}
</Popover>
// </div>
);
});

View file

@ -1,14 +1,14 @@
import React, { useState } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { FilterKey, FilterCategory, FilterType } from 'Types/filter/filterType';
import { debounce } from 'App/utils';
import { assist as assistRoute, isRoute } from 'App/routes';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import FilterDuration from '../FilterDuration';
import FilterValueDropdown from '../FilterValueDropdown';
import FilterAutoCompleteLocal from '../FilterAutoCompleteLocal';
import FilterAutoComplete from '../FilterAutoComplete';
import ValueAutoComplete from 'Shared/Filters/FilterValue/ValueAutoComplete';
import { Input, Select } from 'antd';
const ASSIST_ROUTE = assistRoute();
@ -18,233 +18,260 @@ interface Props {
isConditional?: boolean;
}
function BaseFilterLocalAutoComplete(props: any) {
return (
<FilterAutoCompleteLocal
value={props.value}
showCloseButton={props.showCloseButton}
onApplyValues={props.onApplyValues}
onRemoveValue={props.onRemoveValue}
onSelect={props.onSelect}
icon={props.icon}
placeholder={props.placeholder}
isAutoOpen={props.isAutoOpen}
modalProps={props.modalProps}
type={props.type}
allowDecimals={props.allowDecimals}
isMultiple={props.isMultiple}
/>
);
}
function BaseDropDown(props: any) {
return (
<FilterValueDropdown
value={props.value}
isAutoOpen={props.isAutoOpen}
placeholder={props.placeholder}
options={props.options}
onApplyValues={props.onApplyValues}
search={props.search}
onAddValue={props.onAddValue}
onRemoveValue={props.onRemoveValue}
showCloseButton={props.showCloseButton}
showOrButton={props.showOrButton}
isMultiple={props.isMultiple}
/>
);
}
function FilterValue(props: Props) {
const { filter } = props;
const isAutoOpen = filter.autoOpen;
const { filter, onUpdate, isConditional } = props; // Destructure props early
const isAutoOpen = filter.autoOpen; // Assume parent now controls this correctly
React.useEffect(() => {
if (isAutoOpen) {
setTimeout(() => {
filter.autoOpen = false;
}, 250);
}
}, [isAutoOpen]);
const [durationValues, setDurationValues] = useState({
const [durationValues, setDurationValues] = useState(() => ({
minDuration: filter.value?.[0],
maxDuration: filter.value.length > 1 ? filter.value[1] : filter.value[0]
});
maxDuration: filter.value?.length > 1 ? filter.value[1] : filter.value?.[0]
}));
useEffect(() => {
if (filter.type === FilterType.DURATION) {
const incomingMin = filter.value?.[0];
const incomingMax = filter.value?.length > 1 ? filter.value[1] : filter.value?.[0];
if (durationValues.minDuration !== incomingMin || durationValues.maxDuration !== incomingMax) {
setDurationValues({ minDuration: incomingMin, maxDuration: incomingMax });
}
}
}, [filter.value, filter.type]);
const showCloseButton = filter.value.length > 1;
const showOrButton = filter.value.length > 1;
const onAddValue = () => {
const onAddValue = useCallback(() => {
const newValue = filter.value.concat('');
props.onUpdate({ ...filter, value: newValue });
};
onUpdate({ ...filter, value: newValue });
}, [filter, onUpdate]);
const onApplyValues = (values: string[]) => {
props.onUpdate({ ...filter, value: values });
};
const onApplyValues = useCallback((values: string[]) => {
onUpdate({ ...filter, value: values });
}, [filter, onUpdate]);
const onRemoveValue = (valueIndex: any) => {
const onRemoveValue = useCallback((valueIndex: any) => {
const newValue = filter.value.filter(
(_: any, index: any) => index !== valueIndex
);
props.onUpdate({ ...filter, value: newValue });
};
onUpdate({ ...filter, value: newValue });
}, [filter, onUpdate]);
const onChange = (e: any, item: any, valueIndex: any) => {
const newValues = filter.value.map((_: any, _index: any) => {
const stableOnChange = useCallback((e: any, item: any, valueIndex: any) => {
const newValues = filter.value.map((val: any, _index: any) => {
if (_index === valueIndex) {
return item;
}
return _;
return val;
});
props.onUpdate({ ...filter, value: newValues });
};
onUpdate({ ...filter, value: newValues });
}, [filter, onUpdate]);
const debounceOnSelect = React.useCallback(debounce(onChange, 500), [
onChange
]);
const debounceOnSelect = useCallback(debounce(stableOnChange, 500), [stableOnChange]);
const onDurationChange = (newValues: any) => {
setDurationValues({ ...durationValues, ...newValues });
};
const onDurationChange = useCallback((newValues: any) => {
setDurationValues(current => ({ ...current, ...newValues }));
}, []);
const handleBlur = () => {
const handleBlur = useCallback(() => {
if (filter.type === FilterType.DURATION) {
const { maxDuration, minDuration } = filter;
if (maxDuration || minDuration) return;
if (
maxDuration !== durationValues.maxDuration ||
minDuration !== durationValues.minDuration
) {
props.onUpdate({
const currentMinInProp = filter.value?.[0];
const currentMaxInProp = filter.value?.length > 1 ? filter.value[1] : filter.value?.[0];
if (durationValues.minDuration !== currentMinInProp || durationValues.maxDuration !== currentMaxInProp) {
onUpdate({
...filter,
value: [durationValues.minDuration, durationValues.maxDuration]
});
}
}
};
}, [filter, onUpdate, filter.value, durationValues.minDuration, durationValues.maxDuration]); // Add durationValues dependency
const getParams = (key: any) => {
let params: any = {
const params = useMemo(() => {
let baseParams: any = {
type: filter.key,
name: filter.name,
isEvent: filter.isEvent,
id: filter.id
};
switch (filter.category) {
case FilterCategory.METADATA:
params = { type: FilterKey.METADATA, key };
if (filter.category === FilterCategory.METADATA) {
baseParams = { type: FilterKey.METADATA, key: filter.key };
}
if (isRoute(ASSIST_ROUTE, window.location.pathname)) {
params = { ...params, live: true };
baseParams = { ...baseParams, live: true };
}
return baseParams;
}, [filter.key, filter.name, filter.isEvent, filter.id, filter.category]);
return params;
};
const value = filter.value;
const renderValueFiled = (value: any[]) => {
const showOrButton = filter.value.length > 1;
function BaseFilterLocalAutoComplete(props) {
switch (filter.type) {
case FilterType.DOUBLE:
return (
<FilterAutoCompleteLocal
<Input
type="number"
value={value}
size="small"
className="rounded-lg"
style={{ width: '80px' }}
onChange={(e) => {
const newValue = e.target.value;
onUpdate({ ...filter, value: newValue });
}}
placeholder={filter.placeholder}
onBlur={handleBlur}
/>
);
case FilterType.BOOLEAN:
return (
<Select
value={value}
size="small"
style={{ width: '80px' }}
onChange={(value: any) => onUpdate({ ...filter, value })}
placeholder={filter.placeholder}
options={[
{ label: 'True', value: true },
{ label: 'False', value: false }
]}
/>
);
case FilterType.NUMBER_MULTIPLE:
return (
<BaseFilterLocalAutoComplete
value={value}
showCloseButton={showCloseButton}
onApplyValues={onApplyValues}
onRemoveValue={(index) => onRemoveValue(index)}
onSelect={(e, item, index) => debounceOnSelect(e, item, index)}
onRemoveValue={onRemoveValue}
onSelect={debounceOnSelect}
icon={filter.icon}
placeholder={filter.placeholder}
isAutoOpen={isAutoOpen}
modalProps={{ placeholder: '' }}
{...props}
type="number"
isMultiple={true}
/>
);
}
function BaseDropDown(props) {
case FilterType.NUMBER:
return (
<FilterValueDropdown
<BaseFilterLocalAutoComplete
value={value}
showCloseButton={showCloseButton}
onApplyValues={onApplyValues}
onRemoveValue={onRemoveValue}
onSelect={debounceOnSelect}
icon={filter.icon}
placeholder={filter.placeholder}
isAutoOpen={isAutoOpen}
modalProps={{ placeholder: '' }}
type="number"
allowDecimals={false}
isMultiple={false}
/>
);
case FilterType.STRING:
return <ValueAutoComplete
initialValues={value}
isAutoOpen={isAutoOpen}
onApplyValues={onApplyValues}
params={params}
commaQuery={true}
/>;
case FilterType.DROPDOWN:
return <BaseDropDown
value={value}
isAutoOpen={isAutoOpen}
placeholder={filter.placeholder}
options={filter.options}
onApplyValues={onApplyValues}
/>;
case FilterType.ISSUE:
case FilterType.MULTIPLE_DROPDOWN:
return (
<BaseDropDown
value={value}
isAutoOpen={isAutoOpen}
placeholder={filter.placeholder}
options={filter.options}
onApplyValues={onApplyValues}
{...props}
search
onAddValue={onAddValue}
onRemoveValue={onRemoveValue}
showCloseButton={showCloseButton}
showOrButton={showOrButton}
/>
);
}
switch (filter.type) {
case FilterType.NUMBER_MULTIPLE:
return (
<BaseFilterLocalAutoComplete
type="number"
placeholder={filter.placeholder}
/>
);
case FilterType.NUMBER:
return (
<BaseFilterLocalAutoComplete
type="number"
allowDecimals={false}
isMultiple={false}
placeholder={filter.placeholder}
/>
);
case FilterType.STRING:
return <ValueAutoComplete
initialValues={value}
case FilterType.DURATION:
return (
<FilterDuration
onChange={onDurationChange}
onBlur={handleBlur}
minDuration={durationValues.minDuration}
maxDuration={durationValues.maxDuration}
isConditional={isConditional}
/>
);
case FilterType.MULTIPLE:
return (
<FilterAutoComplete
value={value}
isAutoOpen={isAutoOpen}
// showCloseButton={showCloseButton}
// showOrButton={showOrButton}
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}
commaQuery={true}
// onSelect={(e, item, index) => onChange(e, item, index)}
// icon={filter.icon}
// modalProps={{ placeholder: 'Search' }}
/>;
// 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:
case FilterType.MULTIPLE_DROPDOWN:
return (
<BaseDropDown
search
onAddValue={onAddValue}
onRemoveValue={(ind) => onRemoveValue(ind)}
showCloseButton={showCloseButton}
showOrButton={showOrButton}
placeholder={filter.placeholder}
/>
);
case FilterType.DURATION:
return (
<FilterDuration
onChange={onDurationChange}
onBlur={handleBlur}
minDuration={durationValues.minDuration}
maxDuration={durationValues.maxDuration}
isConditional={props.isConditional}
/>
);
case FilterType.MULTIPLE:
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' }}
/>
);
}
};
return (
<
// id="ignore-outside"
// className={cn('grid gap-3 w-fit flex-wrap my-1.5', {
// 'grid-cols-2': filter.hasSource
// })}
>
{renderValueFiled(filter.value)}
</>
);
onRemoveValue={onRemoveValue}
method="GET"
endpoint="/PROJECT_ID/events/search" // TODO: Replace PROJECT_ID dynamically if needed
params={params}
headerText=""
placeholder={filter.placeholder}
onSelect={stableOnChange}
icon={filter.icon}
modalProps={{ placeholder: 'Search' }}
/>
);
default:
console.warn('Unsupported filter type in FilterValue:', filter.type);
return null;
}
}
export default observer(FilterValue);

View file

@ -24,8 +24,6 @@ interface OptionType {
label: string;
}
// Removed custom TruncatedText, will use Typography.Text ellipsis
interface Props {
initialValues: string[];
params: FilterParams;
@ -57,7 +55,7 @@ const ValueAutoComplete = observer(
const [loadingSearch, setLoadingSearch] = useState(false);
const [query, setQuery] = useState('');
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const triggerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null); // Ref for the main trigger button
const filterKey = useMemo(() => {
if (!projectsStore.siteId || !params.id) return null;
@ -92,13 +90,14 @@ const ValueAutoComplete = observer(
useEffect(() => {
if (loadingTopValues) return;
if (showValueModal) {
setSelectedValues(initialValues.filter((i) => i && i.length > 0));
setQuery('');
setOptions(mappedTopValues.length > 0 ? mappedTopValues : []);
setLoadingSearch(false);
}
}, [showValueModal, initialValues, mappedTopValues]);
}, [showValueModal, loadingTopValues]);
const loadOptions = useCallback(async (inputValue: string) => {
const trimmedQuery = inputValue.trim();
@ -195,8 +194,8 @@ const ValueAutoComplete = observer(
}, [queryBlocks]);
const onClearClick = (event: React.MouseEvent) => {
event.stopPropagation();
const onClearClick = (event: React.MouseEvent | React.KeyboardEvent) => {
event.stopPropagation(); // Prevent popover toggle
onApplyValues([]);
setShowValueModal(false);
};
@ -206,7 +205,7 @@ const ValueAutoComplete = observer(
setShowValueModal(visible);
};
const isEmpty = initialValues.length === 0;
const isEmpty = initialValues[0] === '' || initialValues.length === 0;
const popoverContent = (
<div
@ -302,37 +301,15 @@ const ValueAutoComplete = observer(
onOpenChange={handleOpenChange}
placement="bottomLeft"
arrow={false}
getPopupContainer={triggerNode => triggerNode || document.body} // Ensure it attaches correctly
getPopupContainer={triggerNode => triggerNode || document.body}
>
{/* className={cn(*/}
{/* 'rounded-lg px-2 cursor-pointer bg-white border border-gray-light text-ellipsis hover:border-main',*/}
{/* 'transition-colors duration-100 flex-shrink-0 max-w-xs h-[26px] items-center gap-1', // Fixed height, ensure items-center*/}
{/* { 'opacity-70 pointer-events-none': disableDelete || readonly }*/}
{/*)}*/}
<Button // Main trigger container using Ant Design classes
// className={cn(
// // 'ant-input', // Mimic Ant Input appearance
// 'relative rounded-xl px-2 cursor-pointer bg-white border border-gray-light text-ellipsis hover:border-main',
// 'transition-colors duration-100 flex-shrink-0 max-w-xs h-[24px] items-center gap-1 pr-8',
// {
// // 'cursor-pointer hover:border-primary': !isDisabled, // Use theme primary color for hover
// 'cursor-not-allowed bg-disabled border-disabled': isDisabled, // Ant disabled styles
// // 'border-primary shadow-outline-primary': showValueModal && !isDisabled, // Ant focus styles
// // 'border': true, // Base border
// // 'rounded': true // Base rounded corners
// }
// )}
<Button
className="pr-8"
size="small"
// style={{ height: 26, lineHeight: 'normal' }} // Adjust styling
ref={triggerRef}
disabled={isDisabled}
onMouseEnter={() => !isDisabled && setHovered(true)}
onMouseLeave={() => setHovered(false)}
role={isDisabled ? undefined : 'button'}
tabIndex={isDisabled ? -1 : 0}
aria-haspopup="listbox"
aria-expanded={showValueModal}
aria-disabled={isDisabled}
>
<Space size={4} wrap className="w-full overflow-hidden">
{!isEmpty ? (
@ -367,25 +344,37 @@ const ValueAutoComplete = observer(
)}
</>
) : (
<Text type={isDisabled ? 'secondary' : undefined} className={cn({ 'text-disabled': isDisabled })}>
<Text type={'secondary'} className={cn({ 'text-disabled': isDisabled })}>
{placeholder}
</Text>
)}
</Space>
{!isEmpty && hovered && !isDisabled && (
// Using Button for clear for better accessibility/styling consistency
<Button
className="absolute right-1 top-1/2 -translate-y-1/2"
type="text"
size="small"
icon={<CloseCircleFilled className="text-neutral-400 hover:text-neutral-600" />}
onClick={onClearClick}
<span
role="button"
aria-label={t('Clear selection')}
onClick={onClearClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onClearClick(e);
}
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
style={{ height: '100%', border: 'none', background: 'transparent', zIndex: 1 }} // Ensure clickable area
/>
tabIndex={0} // Make it focusable if needed
className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center justify-center text-neutral-400 hover:text-neutral-600"
style={{
height: '100%',
cursor: 'pointer',
zIndex: 1,
padding: '0 4px'
}}
>
<CloseCircleFilled />
</span>
)}
</Button>
</Popover>
);

View file

@ -1,131 +1,107 @@
import React, { useEffect } from 'react';
import React from 'react';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import UnifiedFilterList from 'Shared/Filters/FilterList/UnifiedFilterList';
import FilterSelection from 'Shared/Filters/FilterSelection';
import { Button, Divider } from 'antd';
import { Button, Divider, Card } 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 = () => {
};
function SessionFilters() {
const { searchStore, projectsStore, filterStore } =
useStore();
const { searchStore, filterStore } = useStore();
const searchInstance = searchStore.instance;
const saveRequestPayloads =
projectsStore.instance?.saveRequestPayloads ?? false;
const allFilterOptions: Filter[] = filterStore.getCurrentProjectFilters();
const eventOptions = allFilterOptions.filter(i => i.isEvent);
const propertyOptions = allFilterOptions.filter(i => !i.isEvent);
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 onAddFilter = (filter: Filter) => {
searchStore.addFilter({ ...filter, autoOpen: true });
};
const onChangeEventsOrder = (e: any, { value }: any) => {
const onChangeEventsOrder = (
_e: React.MouseEvent<HTMLButtonElement>,
{ value }: { value: string }
) => {
searchStore.edit({
eventsOrder: value
});
};
const eventFilters = searchInstance.filters.filter((i) => i.isEvent);
const attributeFilters = searchInstance.filters.filter((i) => !i.isEvent);
return (
<div className="relative">
<div
className={cn(
'bg-white',
'py-2 px-4 rounded-xl border border-gray-lighter pt-4'
)}
>
<FilterListHeader
title={'Events'}
showEventsOrder={searchInstance.filters.filter(i => i.isEvent).length > 0}
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>
}
/>
<Card className="rounded-lg" classNames={{ body: '!p-4' }}>
<FilterListHeader
title={'Events'}
showEventsOrder={eventFilters.length > 0}
orderProps={searchInstance}
onChangeOrder={onChangeEventsOrder}
filterSelection={
<FilterSelection
filters={eventOptions}
onFilterClick={(newFilter: Filter) => {
onAddFilter(newFilter);
}}
>
<Button type="default" size="small">
<div className="flex items-center gap-1">
<Plus size={16} strokeWidth={1} />
<span>Add</span>
</div>
</Button>
</FilterSelection>
}
/>
<UnifiedFilterList
title="Events"
filters={searchInstance.filters.filter(i => i.isEvent)}
isDraggable={true}
showIndices={true}
className="mt-2"
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);
}}
/>
<UnifiedFilterList
title="Events"
filters={eventFilters}
isDraggable={true}
showIndices={true}
className="mt-2"
handleRemove={searchStore.removeFilter}
handleUpdate={searchStore.updateFilter}
handleAdd={searchStore.addFilter}
handleMove={searchStore.moveFilter}
/>
<Divider className="my-3" />
<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>
} />
<FilterListHeader
title={'Filters'}
filterSelection={
<FilterSelection
filters={propertyOptions}
onFilterClick={(newFilter: Filter) => {
onAddFilter(newFilter);
}}
>
<Button type="default" size="small">
<div className="flex items-center gap-1">
<Plus size={16} strokeWidth={1} />
<span>Add</span>
</div>
</Button>
</FilterSelection>
}
/>
<UnifiedFilterList
title="Filters"
filters={searchInstance.filters.filter(i => !i.isEvent)}
className="mt-2"
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>
<UnifiedFilterList
title="Filters"
filters={attributeFilters}
className="mt-2"
isDraggable={false}
showIndices={false}
handleRemove={searchStore.removeFilter}
handleUpdate={searchStore.updateFilter}
handleAdd={searchStore.addFilter}
handleMove={searchStore.moveFilter}
/>
</Card>
);
}

View file

@ -9,7 +9,7 @@ import SessionHeader from './components/SessionHeader';
import LatestSessionsMessage from './components/LatestSessionsMessage';
function SessionsTabOverview() {
usePageTitle('Sessions - OpenReplay');
// usePageTitle('Sessions - OpenReplay');
return (
<>

View file

@ -1,6 +1,6 @@
import { makeAutoObservable, runInAction } from 'mobx';
import { filterService } from 'App/services';
import { Filter, Operator, COMMON_FILTERS, getOperatorsByType } from './types/filterConstants';
import { Filter, COMMON_FILTERS } from './types/filterConstants';
import { FilterKey } from 'Types/filter/filterType';
import { projectStore } from '@/mstore/index';
@ -88,14 +88,14 @@ export default class FilterStore {
icon: FilterKey.LOCATION, // TODO - use actual icons
isEvent: category === 'events',
value: filter.value || [],
propertyOrder: 'and'
propertyOrder: 'and',
operator: filter.operator || 'is'
}));
};
addOperatorsToFilters = (filters: Filter[]): Filter[] => {
return filters.map(filter => ({
...filter
// operators: filter.operators?.length ? filter.operators : getOperatorsByType(filter.possibleTypes || [])
...filter,
}));
};

View file

@ -17,6 +17,7 @@ import { sessionStore, settingsStore } from 'App/mstore';
import SavedSearch, { ISavedSearch } from 'App/mstore/types/savedSearch';
import { iTag } from '@/services/NotesService';
import { issues_types } from 'Types/session/issue';
import { Filter } from '@/mstore/types/filterConstants';
const PER_PAGE = 10;
@ -63,7 +64,58 @@ class SearchStore {
latestRequestTime: number | null = null;
latestList = List();
alertMetricId: number | null = null;
instance = new Search();
instance = new Search({
// rangeValue: LAST_24_HOURS,
startDate: Date.now() - 24 * 60 * 60 * 1000,
endDate: Date.now(),
filters: [
{
id: Math.random().toString(36).substring(7),
name: 'CLICK',
category: 'events',
type: 'string',
value: ['/client/account'],
operator: 'is',
isEvent: true,
filters: [
{
id: Math.random().toString(36).substring(7),
name: 'select',
category: 'filters',
type: 'bool',
value: ['true'],
operator: 'is'
},
{
id: Math.random().toString(36).substring(7),
name: 'label',
category: 'filters',
type: 'double',
value: [1],
operator: 'is'
}
]
},
{
id: Math.random().toString(36).substring(7),
name: 'Browser',
category: 'filters',
type: 'string',
value: ['/client/account'],
operator: 'is',
isEvent: false
}
],
groupByUser: false,
sort: 'start',
order: 'desc',
viewed: false,
eventsCount: 0,
suspicious: false,
consoleLevel: '',
strict: true,
eventsOrder: 'start'
});
savedSearch: ISavedSearch = new SavedSearch();
filterSearchList: any = {};
currentPage = 1;
@ -284,11 +336,11 @@ class SearchStore {
const index = filter.isEvent
? -1
: this.instance.filters.findIndex(
(i: FilterItem) => i.key === filter.key
(i: Filter) => i.id === filter.id
);
// new random key
filter.value = checkFilterValue(filter.value);
filter.operator = 'is';
filter.filters = filter.filters
? filter.filters.map((subFilter: any) => ({
...subFilter,
@ -305,7 +357,7 @@ class SearchStore {
oldFilter.merge(updatedFilter);
this.updateFilter(index, updatedFilter);
} else {
filter.key = Math.random().toString(36).substring(7);
// filter.key = Math.random().toString(36).substring(7);
this.instance.filters.push(filter);
this.instance = new Search({
...this.instance.toData()
@ -355,9 +407,9 @@ class SearchStore {
this.instance = Object.assign(this.instance, search);
};
updateFilter = (key: string, search: Partial<FilterItem>) => {
updateFilter = (id: string, search: Partial<FilterItem>) => {
const newFilters = this.instance.filters.map((f: any) => {
if (f.key === key) {
if (f.id === id) {
return {
...f,
...search
@ -372,9 +424,9 @@ class SearchStore {
});
};
removeFilter = (key: string) => {
removeFilter = (id: string) => {
const newFilters = this.instance.filters.filter(
(f: any) => f.key !== key
(f: any) => f.id !== id
);
this.instance = new Search({

View file

@ -1,12 +1,29 @@
import { makeAutoObservable, runInAction, observable, action } from 'mobx';
import { filtersMap, conditionalFiltersMap } from 'Types/filter/newFilter';
import { action, makeAutoObservable, observable, runInAction } from 'mobx';
import { conditionalFiltersMap, filtersMap } from 'Types/filter/newFilter';
import { FilterKey } from 'Types/filter/filterType';
import FilterItem from './filterItem';
import { JsonData } from '@/mstore/types/filterConstants';
export const checkFilterValue = (value: any) =>
Array.isArray(value) ? (value.length === 0 ? [''] : value) : [value];
type FilterData = Partial<FilterItem> & {
key?: FilterKey | string;
value?: any;
operator?: string;
sourceOperator?: string;
source?: any;
filters?: FilterData[]
};
export interface IFilter {
export const checkFilterValue = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.length === 0 ? [''] : value.map(val => String(val ?? ''));
}
if (value === null || value === undefined) {
return [''];
}
return [String(value)];
};
export interface IFilterStore {
filterId: string;
name: string;
filters: FilterItem[];
@ -20,227 +37,356 @@ export interface IFilter {
limit: number;
autoOpen: boolean;
merge(filter: any): void;
addFilter(filter: any): void;
replaceFilters(filters: any): void;
updateFilter(index: number, filter: any): void;
merge(filterData: Partial<FilterStore>): void;
updateKey(key: string, value: any): void;
removeFilter(index: number): void;
addFilter(filterData: FilterData): void;
fromJson(json: any, isHeatmap?: boolean): IFilter;
replaceFilters(newFilters: FilterItem[]): void;
fromData(data: any): IFilter;
updateFilter(filterId: string, filterData: FilterData): void;
toJsonDrilldown(): any;
removeFilter(filterId: string): void;
createFilterBykey(key: string): FilterItem;
fromJson(json: JsonData, isHeatmap?: boolean): this;
toJson(): any;
fromData(data: JsonData): this;
addExcludeFilter(filter: FilterItem): void;
toJsonDrilldown(): JsonData;
updateExcludeFilter(index: number, filter: FilterItem): void;
createFilterByKey(key: FilterKey | string): FilterItem;
removeExcludeFilter(index: number): void;
toJson(): JsonData;
addExcludeFilter(filterData: FilterData): void;
updateExcludeFilter(filterId: string, filterData: FilterData): void;
removeExcludeFilter(filterId: string): void;
addFunnelDefaultFilters(): void;
toData(): any;
addOrUpdateFilter(filterData: FilterData): void;
addOrUpdateFilter(filter: any): void;
addFilterByKeyAndValue(
key: FilterKey | string,
value: unknown,
operator?: string,
sourceOperator?: string,
source?: unknown
): void;
}
export default class Filter implements IFilter {
public static get ID_KEY(): string {
return 'filterId';
}
export default class FilterStore implements IFilterStore {
public static readonly ID_KEY: string = 'filterId';
filterId: string = '';
name: string = '';
autoOpen = false;
autoOpen: boolean = false;
filters: FilterItem[] = [];
excludes: FilterItem[] = [];
eventsOrder: string = 'then';
eventsOrderSupport: string[] = ['then', 'or', 'and'];
readonly eventsOrderSupport: string[] = ['then', 'or', 'and'];
startTimestamp: number = 0;
endTimestamp: number = 0;
eventsHeader: string = 'EVENTS';
page: number = 1;
limit: number = 10;
private readonly isConditional: boolean;
private readonly isMobile: boolean;
constructor(
filters: any[] = [],
private readonly isConditional = false,
private readonly isMobile = false
initialFilters: FilterData[] = [],
isConditional = false,
isMobile = false
) {
this.isConditional = isConditional;
this.isMobile = isMobile;
this.filters = initialFilters.map(
(filterData) => this.createFilterItemFromData(filterData)
);
makeAutoObservable(this, {
filters: observable,
filters: observable.shallow,
excludes: observable.shallow,
eventsOrder: observable,
startTimestamp: observable,
endTimestamp: observable,
addFilter: action,
removeFilter: action,
updateKey: action,
name: observable,
page: observable,
limit: observable,
autoOpen: observable,
filterId: observable,
eventsHeader: observable,
merge: action,
addExcludeFilter: action,
addFilter: action,
replaceFilters: action,
updateFilter: action,
replaceFilters: action
});
this.filters = filters.map((i) => new FilterItem(i));
removeFilter: action,
fromJson: action,
fromData: action,
addExcludeFilter: action,
updateExcludeFilter: action,
removeExcludeFilter: action,
addFunnelDefaultFilters: action,
addOrUpdateFilter: action,
addFilterByKeyAndValue: action,
isConditional: false,
isMobile: false,
eventsOrderSupport: false,
ID_KEY: false
}, { autoBind: true });
}
merge(filter: any) {
merge(filterData: Partial<FilterStore>) {
runInAction(() => {
Object.assign(this, filter);
const validKeys = Object.keys(this).filter(key => typeof (this as any)[key] !== 'function' && key !== 'eventsOrderSupport' && key !== 'isConditional' && key !== 'isMobile');
for (const key in filterData) {
if (validKeys.includes(key)) {
(this as any)[key] = (filterData as any)[key];
}
}
if (filterData.filters) {
this.filters = filterData.filters.map(f => f);
}
if (filterData.excludes) {
this.excludes = filterData.excludes.map(f => f);
}
});
}
addFilter(filter: any) {
filter.value = [''];
if (Array.isArray(filter.filters)) {
filter.filters = filter.filters.map((i: Record<string, any>) => {
i.value = [''];
return new FilterItem(i);
});
}
this.filters.push(new FilterItem(filter));
}
replaceFilters(filters: any) {
this.filters = filters;
}
updateFilter(index: number, filter: any) {
this.filters[index] = new FilterItem(filter);
}
updateKey(key: string, value: any) {
// @ts-ignore fix later
// @ts-ignore
this[key] = value;
}
removeFilter(index: number) {
this.filters.splice(index, 1);
private createFilterItemFromData(filterData: FilterData): FilterItem {
const dataWithValue = {
...filterData,
value: checkFilterValue(filterData.value)
};
if (Array.isArray(dataWithValue.filters)) {
dataWithValue.filters = dataWithValue.filters.map(nestedFilter => this.createFilterItemFromData(nestedFilter));
}
return new FilterItem(dataWithValue);
}
fromJson(json: any, isHeatmap?: boolean) {
this.name = json.name;
this.filters = json.filters.map((i: Record<string, any>) =>
new FilterItem(undefined, this.isConditional, this.isMobile).fromJson(
i,
undefined,
isHeatmap
)
);
this.eventsOrder = json.eventsOrder;
addFilter(filterData: FilterData) {
const newFilter = this.createFilterItemFromData(filterData);
this.filters.push(newFilter);
}
replaceFilters(newFilters: FilterItem[]) {
this.filters = newFilters.map(f => f);
}
private updateFilterByIndex(index: number, filterData: FilterData) {
if (index >= 0 && index < this.filters.length) {
const originalId = this.filters[index].id;
const updatedFilter = this.createFilterItemFromData(filterData);
updatedFilter.id = originalId; // Ensure ID is not lost
this.filters[index] = updatedFilter;
} else {
console.warn(`FilterStore.updateFilterByIndex: Invalid index ${index}`);
}
}
updateFilter(filterId: string, filterData: FilterData) {
const index = this.filters.findIndex(f => f.id === filterId);
if (index > -1) {
const updatedFilter = this.createFilterItemFromData(filterData);
updatedFilter.id = filterId; // Ensure the ID remains the same
this.filters[index] = updatedFilter;
} else {
console.warn(`FilterStore.updateFilter: Filter with id ${filterId} not found.`);
}
}
removeFilter(filterId: string) {
const index = this.filters.findIndex(f => f.id === filterId);
if (index > -1) {
this.filters.splice(index, 1);
} else {
console.warn(`FilterStore.removeFilter: Filter with id ${filterId} not found.`);
}
}
fromJson(json: JsonData, isHeatmap?: boolean): this {
runInAction(() => {
this.name = json.name ?? '';
this.filters = Array.isArray(json.filters)
? json.filters.map((filterJson: JsonData) =>
new FilterItem().fromJson(filterJson)
)
: [];
this.excludes = Array.isArray(json.excludes)
? json.excludes.map((filterJson: JsonData) =>
new FilterItem().fromJson(filterJson)
)
: [];
this.eventsOrder = json.eventsOrder ?? 'then';
this.startTimestamp = json.startTimestamp ?? 0;
this.endTimestamp = json.endTimestamp ?? 0;
this.page = json.page ?? 1;
this.limit = json.limit ?? 10;
this.autoOpen = json.autoOpen ?? false;
this.filterId = json.filterId ?? '';
this.eventsHeader = json.eventsHeader ?? 'EVENTS';
});
return this;
}
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)
);
this.eventsOrder = data.eventsOrder;
fromData(data: JsonData): this {
runInAction(() => {
this.name = data.name ?? '';
this.filters = Array.isArray(data.filters)
? data.filters.map((filterData: JsonData) =>
this.createFilterItemFromData(filterData)
// new FilterItem(undefined, this.isConditional, this.isMobile).fromData(filterData)
)
: [];
this.excludes = Array.isArray(data.excludes)
? data.excludes.map((filterData: JsonData) =>
this.createFilterItemFromData(filterData)
// new FilterItem(undefined, this.isConditional, this.isMobile).fromData(filterData)
)
: [];
this.eventsOrder = data.eventsOrder ?? 'then';
this.startTimestamp = data.startTimestamp ?? 0;
this.endTimestamp = data.endTimestamp ?? 0;
this.page = data.page ?? 1;
this.limit = data.limit ?? 10;
this.autoOpen = data.autoOpen ?? false;
this.filterId = data.filterId ?? '';
this.eventsHeader = data.eventsHeader ?? 'EVENTS';
});
return this;
}
toJsonDrilldown() {
const json = {
toJsonDrilldown(): JsonData {
return {
name: this.name,
filters: this.filters.map((i) => i.toJson()),
filters: this.filters.map((filterItem) => filterItem.toJson()),
eventsOrder: this.eventsOrder,
startTimestamp: this.startTimestamp,
endTimestamp: this.endTimestamp
};
return json;
}
createFilterBykey(key: string) {
const usedMap = this.isConditional ? conditionalFiltersMap : filtersMap;
return usedMap[key] ? new FilterItem(usedMap[key]) : new FilterItem();
createFilterByKey(key: FilterKey | string): FilterItem {
const sourceMap = this.isConditional ? conditionalFiltersMap : filtersMap;
const filterTemplate = sourceMap[key as FilterKey];
const newFilterData = filterTemplate ? { ...filterTemplate, value: [''] } : { key: key, value: [''] };
return this.createFilterItemFromData(newFilterData); // Use helper
}
toJson() {
const json = {
toJson(): JsonData {
return {
name: this.name,
filters: this.filters.map((i) => i.toJson()),
eventsOrder: this.eventsOrder
filterId: this.filterId,
autoOpen: this.autoOpen,
filters: this.filters.map((filterItem) => filterItem?.toJson()),
excludes: this.excludes.map((filterItem) => filterItem?.toJson()),
eventsOrder: this.eventsOrder,
startTimestamp: this.startTimestamp,
endTimestamp: this.endTimestamp,
eventsHeader: this.eventsHeader,
page: this.page,
limit: this.limit
};
return json;
}
addExcludeFilter(filter: FilterItem) {
this.excludes.push(filter);
addExcludeFilter(filterData: FilterData) {
const newExclude = this.createFilterItemFromData(filterData);
this.excludes.push(newExclude);
}
updateExcludeFilter(index: number, filter: FilterItem) {
this.excludes[index] = new FilterItem(filter);
updateExcludeFilter(filterId: string, filterData: FilterData) {
const index = this.excludes.findIndex(f => f.id === filterId);
if (index > -1) {
const updatedExclude = this.createFilterItemFromData(filterData);
updatedExclude.id = filterId; // Ensure the ID remains the same
this.excludes[index] = updatedExclude;
} else {
console.warn(`FilterStore.updateExcludeFilter: Exclude filter with id ${filterId} not found.`);
}
}
removeExcludeFilter(index: number) {
this.excludes.splice(index, 1);
removeExcludeFilter(filterId: string) {
const index = this.excludes.findIndex(f => f.id === filterId);
if (index > -1) {
this.excludes.splice(index, 1);
} else {
console.warn(`FilterStore.removeExcludeFilter: Exclude filter with id ${filterId} not found.`);
}
}
addFunnelDefaultFilters() {
this.filters = [];
this.addFilter({
...filtersMap[FilterKey.LOCATION],
value: [''],
operator: 'isAny'
});
this.addFilter({
...filtersMap[FilterKey.CLICK],
value: [''],
operator: 'onAny'
runInAction(() => {
this.filters = []; // Clear existing filters
const locationFilterData = filtersMap[FilterKey.LOCATION];
if (locationFilterData) {
this.addFilter({
...locationFilterData,
value: [''],
operator: 'isAny'
});
} else {
console.warn(`FilterStore.addFunnelDefaultFilters: Default filter not found for key ${FilterKey.LOCATION}`);
}
const clickFilterData = filtersMap[FilterKey.CLICK];
if (clickFilterData) {
this.addFilter({
...clickFilterData,
value: [''],
operator: 'onAny'
});
} else {
console.warn(`FilterStore.addFunnelDefaultFilters: Default filter not found for key ${FilterKey.CLICK}`);
}
});
}
toData() {
return {
name: this.name,
filters: this.filters.map((i) => i.toJson()),
eventsOrder: this.eventsOrder
addOrUpdateFilter(filterData: FilterData) {
const index = this.filters.findIndex((f) => f.key === filterData.key);
const dataWithCheckedValue = {
...filterData,
value: checkFilterValue(filterData.value)
};
}
addOrUpdateFilter(filter: any) {
const index = this.filters.findIndex((i) => i.key === filter.key);
filter.value = checkFilterValue;
if (index > -1) {
this.updateFilter(index, filter);
this.updateFilterByIndex(index, dataWithCheckedValue);
} else {
this.addFilter(filter);
this.addFilter(dataWithCheckedValue);
}
}
addFilterByKeyAndValue(
key: any,
value: any,
operator: undefined,
sourceOperator: undefined,
source: undefined
key: FilterKey | string,
value: unknown,
operator?: string,
sourceOperator?: string,
source?: unknown
) {
let defaultFilter = { ...filtersMap[key] };
if (defaultFilter) {
defaultFilter = { ...defaultFilter, value: checkFilterValue(value) };
if (operator) {
defaultFilter.operator = operator;
}
if (sourceOperator) {
defaultFilter.sourceOperator = sourceOperator;
}
if (source) {
defaultFilter.source = source;
}
this.addOrUpdateFilter(defaultFilter);
const sourceMap = this.isConditional ? conditionalFiltersMap : filtersMap;
const defaultFilterData = sourceMap[key as FilterKey];
if (defaultFilterData) {
const newFilterData: FilterData = {
...defaultFilterData,
key: key,
value: checkFilterValue(value),
operator: operator ?? defaultFilterData.operator,
sourceOperator: sourceOperator ?? defaultFilterData.sourceOperator,
source: source ?? defaultFilterData.source
};
this.addOrUpdateFilter(newFilterData);
} else {
console.warn(`FilterStore.addFilterByKeyAndValue: No default filter template found for key ${key}. Adding generic filter.`);
this.addOrUpdateFilter({ key: key, value: checkFilterValue(value), operator, sourceOperator, source });
}
}
}

View file

@ -1,3 +1,5 @@
export type JsonData = Record<string, any>;
export interface Operator {
value: string;
label: string;
@ -13,13 +15,13 @@ export interface FilterProperty {
}
export interface Filter {
id?: string;
id: string;
name: string;
displayName?: string;
description?: string;
possibleTypes?: string[];
autoCaptured: boolean;
metadataName: string;
autoCaptured?: boolean;
metadataName?: string;
category: string; // 'event' | 'filter' | 'action' | etc.
subCategory?: string;
type?: string; // 'number' | 'string' | 'boolean' | etc.
@ -30,81 +32,130 @@ export interface Filter {
isEvent?: boolean;
value?: string[];
propertyOrder?: string;
filters?: Filter[];
}
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: 'isNot', label: 'is not', displayName: 'Is not', description: 'Not an exact match' },
{ value: 'contains', label: 'contains', displayName: 'Contains', description: 'Contains the string' },
{
value: 'doesNotContain',
label: 'doesNotContain',
label: 'does not contain',
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' }
{ value: 'startsWith', label: 'starts with', displayName: 'Starts with', description: 'Starts with the string' },
{ value: 'endsWith', label: 'ends with', displayName: 'Ends with', description: 'Ends with the string' },
{ value: 'isBlank', label: 'is blank', displayName: 'Is blank', description: 'Is empty or null' },
{ value: 'isNotBlank', label: 'is not blank', displayName: 'Is not blank', description: 'Is not empty or null' }
],
number: [
{ value: 'equals', label: 'equals', displayName: 'Equals', description: 'Exactly equals the value' },
{
value: 'doesNotEqual',
label: 'doesNotEqual',
label: 'does not equal', // Fixed: added space
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: 'greaterThan', label: 'greater than', displayName: 'Greater than', description: 'Greater than the value' },
{
value: 'lessThan', label: 'less than', // Fixed: added space and lowercased
displayName: 'Less than', description: 'Less than the value'
},
{
value: 'greaterThanOrEquals',
label: 'greaterThanOrEquals',
label: 'greater than or equals', // Fixed: added spaces and lowercased
displayName: 'Greater than or equals',
description: 'Greater than or equal to the value'
},
{
value: 'lessThanOrEquals',
label: 'lessThanOrEquals',
label: 'less than or equals', // Fixed: added spaces and lowercased
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' }
{
value: 'isBlank', label: 'is blank', // Fixed: added space and lowercased
displayName: 'Is blank', description: 'Is empty or null'
},
{
value: 'isNotBlank', label: 'is not blank', // Fixed: added spaces and lowercased
displayName: 'Is not blank', description: 'Is not empty or null'
}
],
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' }
{
value: 'isTrue', label: 'is true', // Fixed: added space and lowercased
displayName: 'Is true', description: 'Value is true'
},
{
value: 'isFalse', label: 'is false', // Fixed: added space and lowercased
displayName: 'Is false', description: 'Value is false'
},
{
value: 'isBlank', label: 'is blank', // Fixed: added space and lowercased
displayName: 'Is blank', description: 'Is null'
},
{
value: 'isNotBlank', label: 'is not blank', // Fixed: added spaces and lowercased
displayName: 'Is not blank', description: 'Is not null'
}
],
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: 'notOn', label: 'not on', // Fixed: added space and lowercased
displayName: 'Not on', description: 'Not on the exact date'
},
{ value: 'before', label: 'before', displayName: 'Before', description: 'Before the date' },
{ value: 'after', label: 'after', displayName: 'After', description: 'After the date' },
{ value: 'onOrBefore', label: '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' }
{
value: 'onOrBefore', label: 'on or before', // Fixed: added spaces and lowercased
displayName: 'On or before', description: 'On or before the date'
},
{
value: 'onOrAfter', label: 'on or after', // Fixed: added spaces and lowercased
displayName: 'On or after', description: 'On or after the date'
},
{
value: 'isBlank', label: 'is blank', // Fixed: added space and lowercased
displayName: 'Is blank', description: 'Is empty or null'
},
{
value: 'isNotBlank', label: 'is not blank', // Fixed: added spaces and lowercased
displayName: 'Is not blank', description: 'Is not empty or null'
}
],
array: [
{ value: 'contains', label: 'contains', displayName: 'Contains', description: 'Array contains the value' },
{
value: 'doesNotContain',
label: 'doesNotContain',
label: 'does not contain', // Fixed: added spaces and lowercased
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' }
{
value: 'hasAny', label: 'has any', // Fixed: added space and lowercased
displayName: 'Has any', description: 'Array has any of the values'
},
{
value: 'hasAll', label: 'has all', // Fixed: added space and lowercased
displayName: 'Has all', description: 'Array has all of the values'
},
{
value: 'isEmpty', label: 'is empty', // Fixed: added space and lowercased
displayName: 'Is empty', description: 'Array is empty'
},
{
value: 'isNotEmpty', label: 'is not empty', // Fixed: added spaces and lowercased
displayName: 'Is not empty', description: 'Array is not empty'
}
]
};
@ -113,7 +164,7 @@ export const COMMON_FILTERS: Filter[] = [];
export const getOperatorsByType = (type: string): Operator[] => {
let operators: Operator[] = [];
switch (type.toLowerCase()) {
switch (type?.toLowerCase()) {
case 'string':
operators = OPERATORS.string;
break;

View file

@ -1,38 +1,30 @@
import { FilterCategory, FilterKey, FilterType } from 'Types/filter/filterType';
import {
conditionalFiltersMap,
filtersMap,
mobileConditionalFiltersMap
} from 'Types/filter/newFilter';
import { FilterCategory, FilterKey } from 'Types/filter/filterType';
import { makeAutoObservable } from 'mobx';
import { FilterProperty, Operator } from '@/mstore/types/filterConstants';
import { pageUrlOperators } from '@/constants/filterOptions';
type JsonData = Record<string, any>;
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;
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;
filters?: FilterItem[];
constructor(
data: any = {},
private readonly isConditional?: boolean,
private readonly isMobile?: boolean
) {
constructor(data: any = {}) {
makeAutoObservable(this);
if (Array.isArray(data.filters)) {
@ -40,6 +32,7 @@ export default class FilterItem {
(i: Record<string, any>) => new FilterItem(i)
);
}
data.operator = data.operator || 'is';
this.merge(data);
}
@ -58,99 +51,51 @@ export default class FilterItem {
fromData(data: any) {
Object.assign(this, data);
this.type = data.type;
this.key = data.key;
this.label = data.label;
this.operatorOptions = data.operatorOptions;
this.hasSource = data.hasSource;
this.type = 'string';
this.name = data.type;
this.category = data.category;
this.subCategory = data.subCategory;
this.sourceOperatorOptions = data.sourceOperatorOptions;
this.value = data.value;
this.isEvent = Boolean(data.isEvent);
this.operator = data.operator;
this.source = data.source;
this.sourceOperator = data.sourceOperator;
this.filters = data.filters;
this.isActive = Boolean(data.isActive);
this.completed = data.completed;
this.dropped = data.dropped;
this.options = data.options;
this.filters = data.filters.map((i: JsonData) => new FilterItem(i));
return this;
}
fromJson(json: any, mainFilterKey = '', isHeatmap?: boolean) {
const isMetadata = json.type === FilterKey.METADATA;
let _filter: any =
(isMetadata ? filtersMap[`_${json.source}`] : filtersMap[json.type]) ||
{};
if (this.isConditional) {
if (this.isMobile) {
_filter =
mobileConditionalFiltersMap[_filter.key] ||
mobileConditionalFiltersMap[_filter.source];
} else {
_filter =
conditionalFiltersMap[_filter.key] ||
conditionalFiltersMap[_filter.source];
}
}
if (mainFilterKey) {
const mainFilter = filtersMap[mainFilterKey];
const subFilterMap = {};
mainFilter.filters.forEach((option: any) => {
// @ts-ignore
subFilterMap[option.key] = option;
});
// @ts-ignore
_filter = subFilterMap[json.type];
}
this.type = _filter.type;
this.key = _filter.key;
this.label = _filter.label;
this.operatorOptions = _filter.operatorOptions;
this.hasSource = _filter.hasSource;
this.category = _filter.category;
this.subCategory = _filter.subCategory;
this.sourceOperatorOptions = _filter.sourceOperatorOptions;
if (isHeatmap && this.key === FilterKey.LOCATION) {
this.operatorOptions = pageUrlOperators;
}
this.options = _filter.options;
this.isEvent = Boolean(_filter.isEvent);
this.value = !json.value || json.value.length === 0 ? [''] : json.value;
this.operator = json.operator;
this.source = isMetadata ? `_${json.source}` : json.source;
this.sourceOperator = json.sourceOperator;
this.filters =
_filter.type === FilterType.SUB_FILTERS && json.filters
? json.filters.map((i: any) => new FilterItem().fromJson(i, json.type))
: [];
this.completed = json.completed;
this.dropped = json.dropped;
fromJson(data: JsonData) {
this.type = 'string';
this.name = data.type;
this.category = data.category;
this.subCategory = data.subCategory;
this.operator = data.operator;
this.value = data.value || [''];
this.filters = data.filters.map((i: JsonData) => new FilterItem(i));
return this;
}
toJson(): any {
const isMetadata = this.category === FilterCategory.METADATA;
const json = {
type: isMetadata ? FilterKey.METADATA : this.key,
const json: any = {
type: this.name,
isEvent: Boolean(this.isEvent),
value: this.value?.map((i: any) => (i ? i.toString() : '')) || [],
operator: this.operator,
source: isMetadata ? this.key.replace(/^_/, '') : this.source,
sourceOperator: this.sourceOperator,
source: this.name,
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));
const isMetadata = this.category === FilterCategory.METADATA;
if (isMetadata) {
json.type = FilterKey.METADATA;
json.source = this.name;
json.sourceOperator = this.operator;
}
if (this.type === FilterKey.DURATION) {
json.value = this.value?.map((i: any) => (!i ? 0 : i));
}
return json;
}
}

View file

@ -1,6 +1,6 @@
// import Filter from 'Types/filter';
import { makeAutoObservable, observable, action } from 'mobx';
import Filter from './filter';
import FilterStore from './filter';
import { JsonData } from '@/mstore/types/filterConstants';
export default class FilterSeries {
public static get ID_KEY(): string {
@ -8,38 +8,37 @@ export default class FilterSeries {
}
seriesId?: any = undefined;
name: string = 'Series 1';
filter: Filter = new Filter();
filter: FilterStore = new FilterStore();
constructor() {
makeAutoObservable(this, {
name: observable,
filter: observable,
filter: observable.shallow,
update: action,
update: action
});
}
update(key, value) {
update(key: any, value: any) {
// @ts-ignore
this[key] = value;
}
fromJson(json, isHeatmap = false) {
fromJson(json: JsonData, isHeatmap = false) {
this.seriesId = json.seriesId;
this.name = json.name;
this.filter = new Filter().fromJson(
this.filter = new FilterStore().fromJson(
json.filter || { filters: [] },
isHeatmap,
isHeatmap
);
return this;
}
fromData(data) {
fromData(data: any) {
this.seriesId = data.seriesId;
this.name = data.name;
this.filter = new Filter().fromData(data.filter);
this.filter = new FilterStore().fromData(data.filter);
return this;
}
@ -47,7 +46,7 @@ export default class FilterSeries {
return {
seriesId: this.seriesId,
name: this.name,
filter: this.filter.toJson(),
filter: this.filter.toJson()
};
}
}

View file

@ -3,11 +3,11 @@ import {
DATE_RANGE_VALUES,
getDateRangeFromValue
} from 'App/dateRange';
import Filter, { IFilter } from 'App/mstore/types/filter';
import FilterItem from 'App/mstore/types/filterItem';
import { makeAutoObservable, observable } from 'mobx';
import { LAST_24_HOURS, LAST_30_DAYS, LAST_7_DAYS } from 'Types/app/period';
import { roundToNextMinutes } from '@/utils';
// import { Filter } from '@/mstore/types/filterConstants';
// @ts-ignore
const rangeValue = DATE_RANGE_VALUES.LAST_24_HOURS;
@ -25,7 +25,7 @@ interface ISearch {
userDevice?: string;
fid0?: string;
events: Event[];
filters: FilterItem[];
filters: Filter[];
minDuration?: number;
maxDuration?: number;
custom: Record<string, any>;
@ -54,7 +54,7 @@ export default class Search {
userDevice?: string;
fid0?: string;
events: Event[];
filters: FilterItem[];
filters: Filter[];
minDuration?: number;
maxDuration?: number;
custom: Record<string, any>;

View file

@ -118,7 +118,7 @@
}
}
.img-crcle {
.img-circle {
border-radius: 50%;
box-shadow: 1px 1px 1px 1px rgba(0, 0, 0 0.3);
}

View file

@ -182,11 +182,13 @@ export enum IssueCategory {
export enum FilterType {
STRING = 'string',
NUMBER = 'number',
DOUBLE = 'double',
INTEGER = 'int',
ISSUE = 'ISSUE',
BOOLEAN = 'BOOLEAN',
NUMBER = 'NUMBER',
BOOLEAN = 'bool',
NUMBER_MULTIPLE = 'NUMBER_MULTIPLE',
DURATION = 'DURATION',
DURATION = 'duration',
MULTIPLE = 'MULTIPLE',
SUB_FILTERS = 'SUB_FILTERS',
COUNTRY = 'COUNTRY',