feat(ui): dynamic fitlers - ui improvements

This commit is contained in:
Shekar Siri 2025-04-03 22:26:02 +02:00
parent a6fa45041f
commit 6a100561bf
9 changed files with 722 additions and 243 deletions

View file

@ -224,7 +224,7 @@ export function AutocompleteModal({
);
}
interface Props {
export interface Props {
value: string[];
params?: any;
onApplyValues: (values: string[]) => void;

View file

@ -1,129 +1,132 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { debounce } from 'App/utils';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { searchService } from 'App/services';
import { AutocompleteModal, AutoCompleteContainer } from './AutocompleteModal';
import { AutoCompleteContainer, AutocompleteModal, Props } from './AutocompleteModal';
import { TopValue } from '@/mstore/filterStore';
type FilterParam = { [key: string]: any };
interface FilterParams {
id: string;
type: string;
name?: string;
// ... other potential properties
[key: string]: any; // Keep flexible if needed, but prefer specific types
}
interface OptionType {
value: string;
label: string;
}
function processMetadataValues(input: FilterParams): FilterParams {
const result: Partial<FilterParams> = {}; // Use Partial if creating a subset initially
const isMetadata = input.type === 'metadata';
function processKey(input: FilterParam): FilterParam {
const result: FilterParam = {};
for (const key in input) {
if (
input.type === 'metadata' &&
typeof input[key] === 'string' &&
input[key].startsWith('_')
) {
result[key] = input[key].substring(1);
} else {
result[key] = input[key];
if (Object.prototype.hasOwnProperty.call(input, key)) {
const value = input[key];
if (isMetadata && typeof value === 'string' && value.startsWith('_')) {
result[key] = value.substring(1);
} else {
result[key] = value;
}
}
}
return result;
return result as FilterParams; // Cast back if confident, or adjust logic
}
interface Props {
showOrButton?: boolean;
showCloseButton?: boolean;
onRemoveValue?: (ind: number) => void;
onAddValue?: (ind: number) => void;
endpoint?: string;
method?: string;
params?: any;
headerText?: string;
placeholder?: string;
onSelect: (e: any, item: any, index: number) => void;
value: any;
icon?: string;
hideOrText?: boolean;
onApplyValues: (values: string[]) => void;
modalProps?: Record<string, any>;
isAutoOpen?: boolean;
}
const FilterAutoComplete = observer(
({
params = {},
params, // Expect FilterParams type here
values,
onClose,
onApply,
values,
placeholder
}: {
params: any;
params: FilterParams;
values: string[];
onClose: () => void;
onApply: (values: string[]) => void;
placeholder?: string;
}) => {
const [options, setOptions] = useState<{ value: string; label: string }[]>(
[]
);
const [initialFocus, setInitialFocus] = useState(false);
const [options, setOptions] = useState<OptionType[]>([]);
const [loading, setLoading] = useState(false);
const { filterStore, projectsStore } = useStore();
const _params = processKey(params);
const filterKey = `${projectsStore.siteId}_${params.id}`;
const topValues = filterStore.topValues[filterKey] || [];
React.useEffect(() => {
const filterKey = `${projectsStore.siteId}_${params.id}`;
const topValues: TopValue[] = filterStore.topValues[filterKey] || [];
// Memoize the mapped top values
const mappedTopValues = useMemo(() => {
console.log('Recalculating mappedTopValues'); // For debugging memoization
return topValues.map((i) => ({ value: i.value, label: i.value }));
}, [topValues]);
useEffect(() => {
setOptions([]);
}, [projectsStore.siteId]);
const loadTopValues = async () => {
setLoading(true);
if (projectsStore.siteId) {
await filterStore.fetchTopValues(params.id, projectsStore.siteId);
const loadTopValues = useCallback(async () => {
if (projectsStore.siteId && params.id) {
setLoading(true);
try {
await filterStore.fetchTopValues(params.id, projectsStore.siteId);
} catch (error) {
console.error('Failed to load top values', error);
// Handle error state if needed
} finally {
setLoading(false); // Ensure loading is set false even on error
}
} else {
setOptions([]);
}
setLoading(false);
};
useEffect(() => {
if (topValues.length > 0) {
const mappedValues = topValues.map((i) => ({
value: i.value,
label: i.value
}));
setOptions(mappedValues);
}
}, [topValues, initialFocus]);
}, [filterStore, params.id, projectsStore.siteId]);
useEffect(() => {
void loadTopValues();
}, [_params.type]);
}, [loadTopValues]);
const loadOptions = async (inputValue: string) => {
useEffect(() => {
setOptions(mappedTopValues);
}, [mappedTopValues]);
const loadOptions = useCallback(async (inputValue: string) => {
if (!inputValue.length) {
const mappedValues = topValues.map((i) => ({
value: i.value,
label: i.value
}));
setOptions(mappedValues);
setOptions(mappedTopValues);
return;
}
setLoading(true);
try {
const data = await searchService.fetchAutoCompleteValues({
type: params.name?.toLowerCase(),
const searchType = params.name?.toLowerCase();
if (!searchType) {
console.warn('Search type (params.name) is missing.');
setOptions([]);
return;
}
const data: { value: string }[] = await searchService.fetchAutoCompleteValues({
type: searchType,
q: inputValue
});
const _options =
data.map((i: any) => ({ value: i.value, label: i.value })) || [];
const _options = data.map((i) => ({ value: i.value, label: i.value })) || [];
setOptions(_options);
} catch (e) {
throw new Error(e);
console.error('Failed to fetch autocomplete values:', e);
setOptions(mappedTopValues);
} finally {
setLoading(false);
}
};
}, [mappedTopValues, params.name, searchService.fetchAutoCompleteValues]);
const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [
params,
topValues
]);
const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [loadOptions]);
const handleInputChange = (newValue: string) => {
setInitialFocus(true);
debouncedLoadOptions(newValue);
};

View file

@ -183,20 +183,27 @@ function FilterItem(props: Props) {
const subFilterMarginLeftClass = parentShowsIndex ? 'ml-[1.75rem]' : 'ml-[0.75rem]';
const subFilterPaddingLeftClass = parentShowsIndex ? 'pl-11' : 'pl-7';
const categoryPart = filter?.subCategory ? filter.subCategory : filter?.category;
const namePart = filter?.displayName || filter?.name;
const hasCategory = Boolean(categoryPart);
const hasName = Boolean(namePart);
const showSeparator = hasCategory && hasName;
const defaultText = 'Select Filter';
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-[7px] text-xs flex items-center justify-center rounded-full bg-gray-lightest text-gray-600 font-medium"> {/* Align index top */}
className="flex-shrink-0 w-6 h-6 mt-[2px] text-xs flex items-center justify-center rounded-full bg-gray-lightest text-gray-600 font-medium"> {/* Align index top */}
<span>{filterIndex + 1}</span>
</div>
)}
{isSubItem && (
<div
className="flex-shrink-0 w-14 text-right text-sm text-neutral-500/90 pr-2 pt-[5px]"> {/* Align where/and top */}
className="flex-shrink-0 w-14 text-right text-sm text-neutral-500/90 pr-2">
{subFilterIndex === 0 && (
<Typography.Text className="text-inherit">
where
@ -220,109 +227,120 @@ function FilterItem(props: Props) {
{/* Main content area */}
<div
className="flex flex-grow flex-wrap gap-x-1 items-center"> {/* Use baseline inside here */}
className="flex flex-grow flex-wrap gap-x-1 items-center">
<FilterSelection
filters={filterSelections}
onFilterClick={replaceFilter}
disabled={disableDelete || readonly}
loading={isSubItem ? false : eventFiltersLoading}
>
<Space
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 }
)}
// style={{ lineHeight: '1rem' }}
<Button
type="default"
size="small"
// disabled={isDisabled}
// onClick={onClick} // Pass onClick handler
style={{
maxWidth: '20rem',
flexShrink: 0
}}
>
<div className="text-gray-600 flex-shrink-0">
{filter && getIconForFilter(filter)}
</div>
{(filter?.subCategory || filter?.category) && (
<div className="text-neutral-500/90 capitalize text-sm truncate">
{`${filter?.subCategory ? filter.subCategory : filter?.category}`}
</div>
)}
{(filter?.subCategory || filter?.category) && (filter.displayName || filter.name) &&
<span className="text-neutral-400 mx-1"></span>
}
<div
className="text-sm text-black truncate"
>
{filter.displayName || filter.name || 'Select Filter'}
</div>
</Space>
<Space size={4} align="center">
{/* Icon */}
{filter && (
<span className="text-gray-600 flex-shrink-0">
{getIconForFilter(filter)}
</span>
)}
{/* Category/SubCategory */}
{hasCategory && (
<span className="text-neutral-500/90 capitalize text-sm truncate">
{categoryPart}
</span>
)}
{showSeparator && (
<span className="text-neutral-400"></span>
)}
<span className="text-sm text-black truncate">
{hasName ? namePart : (hasCategory ? '' : defaultText)} {/* Show name or placeholder */}
</span>
</Space>
</Button>
</FilterSelection>
<div
className={cn(
'flex items-center flex-wrap gap-x-2 gap-y-1', // Use baseline inside here
isReversed ? 'flex-row-reverse' : 'flex-row'
)}
>
{filter.hasSource && (
<>
<FilterOperator
options={filter.sourceOperatorOptions}
onChange={handleSourceOperatorChange}
value={filter.sourceOperator}
isDisabled={filter.operatorDisabled || readonly}
/>
<FilterSource filter={filter} onUpdate={onUpdate} />
</>
)}
{/*<div*/}
{/* className={cn(*/}
{/* 'flex items-center flex-wrap gap-x-2 gap-y-1', // Use baseline inside here*/}
{/* isReversed ? 'flex-row-reverse' : 'flex-row'*/}
{/* )}*/}
{/*>*/}
{filter.hasSource && (
<>
<FilterOperator
options={filter.sourceOperatorOptions}
onChange={handleSourceOperatorChange}
value={filter.sourceOperator}
isDisabled={filter.operatorDisabled || readonly}
name="operator"
/>
<FilterSource filter={filter} onUpdate={onUpdate} />
</>
)}
{operatorOptions.length > 0 && filter.type && (
<>
<FilterOperator
options={operatorOptions}
onChange={handleOperatorChange}
value={filter.operator}
isDisabled={filter.operatorDisabled || readonly}
/>
{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"
title={filter.value.join(', ')}
>
{filter.value
.map((val: string) =>
filter.options?.find((i: any) => i.value === val)?.label ?? val
)
.join(', ')}
</div>
) : (
<div className="inline-flex"> {/* Wrap FilterValue */}
<FilterValue isConditional={isConditional} filter={filter} onUpdate={onUpdate} />
</div>
))}
</>
)}
{operatorOptions.length > 0 && filter.type && (
<>
<FilterOperator
options={operatorOptions}
onChange={handleOperatorChange}
value={filter.operator}
isDisabled={filter.operatorDisabled || readonly}
name="operator"
/>
{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"
title={filter.value.join(', ')}
>
{filter.value
.map((val: string) =>
filter.options?.find((i: any) => i.value === val)?.label ?? val
)
.join(', ')}
</div>
) : (
<div className="inline-flex"> {/* Wrap FilterValue */}
<FilterValue isConditional={isConditional} filter={filter} onUpdate={onUpdate} />
</div>
))}
</>
)}
{filter.isEvent && !isSubItem && (
<FilterSelection
filters={eventFilterOptions}
onFilterClick={addSubFilter}
disabled={disableDelete || readonly || eventFiltersLoading}
loading={eventFiltersLoading}
>
<Tooltip title="Add filter condition" mouseEnterDelay={1}>
<Button
type="text"
icon={<FunnelPlus size={14} className="text-gray-600" />}
size="small"
className="h-[26px] w-[26px] flex items-center justify-center" // Fixed size button
/>
</Tooltip>
</FilterSelection>
)}
</div>
{filter.isEvent && !isSubItem && (
<FilterSelection
filters={eventFilterOptions}
onFilterClick={addSubFilter}
disabled={disableDelete || readonly || eventFiltersLoading}
loading={eventFiltersLoading}
>
<Tooltip title="Add filter condition" mouseEnterDelay={1}>
<Button
type="text"
icon={<FunnelPlus size={14} className="text-gray-600" />}
size="small"
className="flex items-center justify-center" // Fixed size button
/>
</Tooltip>
</FilterSelection>
)}
{/*</div>*/}
</div>
{/* Action Buttons */}
{!readonly && !hideDelete && (
<div className="flex flex-shrink-0 gap-1 items-center self-start mt-[1px]"> {/* Align top */}
<div className="flex flex-shrink-0 gap-1 items-center self-start"> {/* Align top */}
<Tooltip title={isSubItem ? 'Remove filter condition' : 'Remove filter'} mouseEnterDelay={1}>
<Button
type="text"
@ -330,7 +348,7 @@ function FilterItem(props: Props) {
disabled={disableDelete}
onClick={onRemoveFilter}
size="small"
className="h-[26px] w-[26px] flex items-center justify-center" // Fixed size button
className="flex items-center justify-center" // Fixed size button
/>
</Tooltip>
</div>
@ -341,7 +359,7 @@ function FilterItem(props: Props) {
{filteredSubFilters.length > 0 && (
<div
className={cn(
'relative w-full mt-1' // Relative parent for border
'relative w-full mt-3 mb-2 flex flex-col gap-2' // Relative parent for border
)}
>
{/* Dashed line */}

View file

@ -153,7 +153,7 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
}, []);
return filters.length ? (
<div className={cn('flex flex-col', className)} style={style}>
<div className={cn('flex flex-col gap-2', className)} style={style}>
{filters.map((filterItem: any, filterIndex: number) => (
<div
key={`filter-${filterItem.key || filterIndex}`}
@ -176,7 +176,7 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => {
>
{isDraggable && filters.length > 1 && (
<div
className="cursor-grab text-neutral-500 hover:text-neutral-700 pt-[10px] flex-shrink-0" // Align handle visually
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"

View file

@ -1,38 +1,100 @@
import React from 'react';
import Select from 'Shared/Select';
import { Dropdown, Menu, Button } from 'antd'; // Import Dropdown, Menu, Button
import { DownOutlined } from '@ant-design/icons'; // Optional: Icon for the button
interface OptionType {
label: React.ReactNode; // Label can be text or other React elements
value: string | number; // Value is typically string or number
}
interface Props {
onChange: (e: any, { name, value }: any) => void;
className?: string;
options?: any;
value?: string;
name: string;
options: OptionType[];
value?: string | number; // Should match the type of OptionType.value
onChange: (
event: unknown, // Keep original signature for compatibility upstream
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
}
// Define a special key for the clear action
const CLEAR_VALUE_KEY = '__antd_clear_value__';
function FilterOperator(props: Props) {
const {
name,
options,
value,
onChange,
isDisabled = false,
className = '',
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;
// 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>
Clear Selection {/* Or use a specific label */}
</Menu.Item>
<Menu.Divider />
</>
)}
{/* Map options to Menu.Item */}
{options.map(option => (
<Menu.Item key={String(option.value)}>
{option.label}
</Menu.Item>
))}
</Menu>
);
return (
<div className="mx-2">
<Select
name="operator"
options={options || []}
styles={{ height: 26 }}
popupMatchSelectWidth={false}
placeholder="Select"
isDisabled={isDisabled}
value={value ? options?.find((i: any) => i.value === value) : null}
onChange={({ value }: any) =>
onChange(null, { name: 'operator', value: value.value })
}
className=""
/>
</div>
<>
<Dropdown
overlay={menu}
trigger={['click']} // Open dropdown on click
disabled={isDisabled}
overlayClassName={popupClassName} // Apply the custom class to the overlay
>
{/* The Button acts as the trigger */}
<Button type="default" size="small" disabled={isDisabled} className="w-fit text-sm">
{displayLabel}
</Button>
</Dropdown>
</>
);
}

View file

@ -72,7 +72,7 @@ const FilterSelection: React.FC<FilterSelectionProps> = observer(({
return (
// Add a class to the wrapper if needed, e.g., for opacity when loading
<div className={cn('relative flex-shrink-0')}>
// <div className={cn('relative flex-shrink-0')}>
<Popover
content={content}
trigger="click"
@ -86,7 +86,7 @@ const FilterSelection: React.FC<FilterSelectionProps> = observer(({
>
{triggerElement}
</Popover>
</div>
// </div>
);
});

View file

@ -8,6 +8,7 @@ import FilterDuration from '../FilterDuration';
import FilterValueDropdown from '../FilterValueDropdown';
import FilterAutoCompleteLocal from '../FilterAutoCompleteLocal';
import FilterAutoComplete from '../FilterAutoComplete';
import ValueAutoComplete from 'Shared/Filters/FilterValue/ValueAutoComplete';
const ASSIST_ROUTE = assistRoute();
@ -154,23 +155,40 @@ function FilterValue(props: Props) {
/>
);
case FilterType.STRING:
// return <BaseFilterLocalAutoComplete placeholder={filter.placeholder} />;
return <FilterAutoComplete
value={value}
return <ValueAutoComplete
initialValues={value}
isAutoOpen={isAutoOpen}
showCloseButton={showCloseButton}
showOrButton={showOrButton}
// showCloseButton={showCloseButton}
// showOrButton={showOrButton}
onApplyValues={onApplyValues}
onRemoveValue={(index) => onRemoveValue(index)}
method="GET"
endpoint="/PROJECT_ID/events/search"
// 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' }}
// 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:
@ -218,14 +236,14 @@ function FilterValue(props: Props) {
};
return (
<div
id="ignore-outside"
className={cn('grid gap-3 w-fit flex-wrap my-1.5', {
'grid-cols-2': filter.hasSource
})}
<
// id="ignore-outside"
// className={cn('grid gap-3 w-fit flex-wrap my-1.5', {
// 'grid-cols-2': filter.hasSource
// })}
>
{renderValueFiled(filter.value)}
</div>
</>
);
}

View file

@ -0,0 +1,395 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { debounce } from 'App/utils';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { searchService } from 'App/services';
import { Button, Checkbox, Input, Tooltip, Popover, Spin, Typography, List, Divider, Space } from 'antd';
import { RedoOutlined, CloseCircleFilled } from '@ant-design/icons';
import cn from 'classnames';
import { useTranslation } from 'react-i18next';
import { TopValue } from '@/mstore/filterStore';
const { Text } = Typography;
interface FilterParams {
id: string;
type: string;
name?: string;
[key: string]: any;
}
interface OptionType {
value: string;
label: string;
}
// Removed custom TruncatedText, will use Typography.Text ellipsis
interface Props {
initialValues: string[];
params: FilterParams;
onApplyValues: (values: string[]) => void;
placeholder?: string;
mapValues?: (value: string) => string;
isAutoOpen?: boolean;
commaQuery?: boolean;
isDisabled?: boolean;
}
const ValueAutoComplete = observer(
({
initialValues,
params,
onApplyValues,
placeholder = 'Select value(s)',
mapValues,
isAutoOpen = false,
commaQuery = false,
isDisabled = false
}: Props) => {
const { t } = useTranslation();
const { filterStore, projectsStore } = useStore();
const [showValueModal, setShowValueModal] = useState(false);
const [hovered, setHovered] = useState(false);
const [options, setOptions] = useState<OptionType[]>([]);
const [loadingTopValues, setLoadingTopValues] = useState(false);
const [loadingSearch, setLoadingSearch] = useState(false);
const [query, setQuery] = useState('');
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const triggerRef = useRef<HTMLDivElement>(null);
const filterKey = useMemo(() => {
if (!projectsStore.siteId || !params.id) return null;
return `${projectsStore.siteId}_${params.id}`;
}, [projectsStore.siteId, params.id]);
const topValues: TopValue[] = filterKey ? filterStore.topValues[filterKey] || [] : [];
const mappedTopValues = useMemo(() => {
return topValues.map((i) => ({ value: i.value, label: i.value }));
}, [topValues]);
useEffect(() => {
setSelectedValues(initialValues.filter((i) => i && i.length > 0));
}, [initialValues]);
useEffect(() => {
if (filterKey && !filterStore.topValues[filterKey]) {
setLoadingTopValues(true);
filterStore.fetchTopValues(params.id, projectsStore.siteId)
.catch(error => console.error('Failed to load top values', error))
.finally(() => setLoadingTopValues(false));
}
}, [filterKey, params.id, projectsStore.siteId, filterStore]);
useEffect(() => {
if (isAutoOpen && !isDisabled) {
setShowValueModal(true);
}
}, [isAutoOpen, isDisabled]);
useEffect(() => {
if (showValueModal) {
setSelectedValues(initialValues.filter((i) => i && i.length > 0));
setQuery('');
setOptions(mappedTopValues.length > 0 ? mappedTopValues : []);
setLoadingSearch(false);
}
}, [showValueModal, initialValues, mappedTopValues]);
const loadOptions = useCallback(async (inputValue: string) => {
const trimmedQuery = inputValue.trim();
if (!trimmedQuery.length) {
setOptions(mappedTopValues);
setLoadingSearch(false);
return;
}
setLoadingSearch(true);
try {
const searchType = params.name?.toLowerCase();
if (!searchType) {
console.warn('Search type (params.name) is missing.');
setOptions([]);
return;
}
const data: { value: string }[] = await searchService.fetchAutoCompleteValues({
type: searchType,
q: trimmedQuery
});
const _options = data.map((i) => ({ value: i.value, label: i.value })) || [];
setOptions(_options);
} catch (e) {
console.error('Failed to fetch autocomplete values:', e);
setOptions(mappedTopValues);
} finally {
setLoadingSearch(false);
}
}, [mappedTopValues, params.name, searchService]);
const debouncedLoadOptions = useCallback(debounce(loadOptions, 500), [loadOptions]);
const handleInputChange = (value: string) => {
setQuery(value);
debouncedLoadOptions(value);
};
const onSelectOption = (item: OptionType) => {
const currentlySelected = selectedValues.includes(item.value);
if (!currentlySelected) {
setSelectedValues([...selectedValues, item.value]);
} else {
setSelectedValues(selectedValues.filter((i) => i !== item.value));
}
};
const isSelected = (item: OptionType) => selectedValues.includes(item.value);
const applySelectedValues = () => {
onApplyValues(selectedValues);
setShowValueModal(false);
};
const applyQuery = () => {
const vals = commaQuery ? query.split(',').map((i) => i.trim()).filter(Boolean) : [query.trim()].filter(Boolean);
if (vals.length > 0) {
const merged = Array.from(new Set([...selectedValues, ...vals]));
onApplyValues(merged);
setShowValueModal(false);
}
};
const clearSelection = () => {
setSelectedValues([]);
};
const sortedOptions = useMemo(() => {
const currentOptionsWithValue = [...options];
selectedValues.forEach((val) => {
if (!currentOptionsWithValue.find((i) => i.value === val)) {
currentOptionsWithValue.unshift({ value: val, label: val });
}
});
currentOptionsWithValue.sort((a, b) => {
const aIsSelected = selectedValues.includes(a.value);
const bIsSelected = selectedValues.includes(b.value);
if (aIsSelected && !bIsSelected) return -1;
if (!aIsSelected && bIsSelected) return 1;
return a.label.localeCompare(b.label);
});
return currentOptionsWithValue;
}, [options, selectedValues]);
const queryBlocks = commaQuery ? query.split(',').map(s => s.trim()).filter(Boolean) : [query.trim()].filter(Boolean);
const blocksAmount = queryBlocks.length;
const queryStr = useMemo(() => {
return queryBlocks.map(block => `"${block}"`).join(blocksAmount > 1 ? ', ' : '');
}, [queryBlocks]);
const onClearClick = (event: React.MouseEvent) => {
event.stopPropagation();
onApplyValues([]);
setShowValueModal(false);
};
const handleOpenChange = (visible: boolean) => {
if (isDisabled) return;
setShowValueModal(visible);
};
const isEmpty = initialValues.length === 0;
const popoverContent = (
<div
style={{ width: 360 }}
onClick={(e) => e.stopPropagation()}
>
<Input.Search
value={query}
loading={loadingSearch}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder}
className="mb-2" // Antd margin class
autoFocus
allowClear
/>
<Spin spinning={loadingTopValues && query.length === 0}>
<List
size="small"
locale={{
emptyText: t(
(loadingSearch || loadingTopValues) ? 'Loading...' :
(query.length > 0 ? 'No results found' : 'No options available')
)
}}
dataSource={sortedOptions}
renderItem={item => (
<List.Item
key={item.value}
onClick={() => onSelectOption(item)}
className={cn(
'cursor-pointer w-full py-1 hover:bg-active-blue rounded px-2',
{ 'bg-active-blue-faded': isSelected(item) }
)}
role="option"
aria-selected={isSelected(item)}
style={{ borderBottom: 'none', padding: '4px 8px' }} // Adjust padding
>
<Space>
<Checkbox checked={isSelected(item)} readOnly tabIndex={-1} />
<Text ellipsis={{ tooltip: { title: item.label, placement: 'topLeft', mouseEnterDelay: 0.5 } }}
style={{ maxWidth: 'calc(360px - 80px)' }}>
{item.label}
</Text>
</Space>
</List.Item>
)}
style={{
maxHeight: 200,
overflowY: 'auto',
marginBottom: (query.trim().length > 0 && !loadingSearch) ? 8 : 0
}}
/>
{(query.trim().length > 0 && !loadingSearch && sortedOptions.length > 0) ? (
<>
<Divider style={{ margin: '8px 0' }} />
<Button
type="link"
onClick={applyQuery}
style={{ paddingLeft: 8, whiteSpace: 'normal', height: 'auto', lineHeight: 'inherit' }}
>
{t('Apply search')}: {queryStr}
</Button>
</>
) : null}
</Spin>
<Divider style={{ margin: '12px 0' }} />
<div className="flex justify-between items-center">
<Button
type="primary"
onClick={applySelectedValues}
disabled={selectedValues.length === 0 && initialValues.length === 0}
>
{t('Apply')}
</Button>
<Tooltip title={t('Clear all selection')}>
<Button
onClick={clearSelection}
type="text"
disabled={selectedValues.length === 0}
icon={<RedoOutlined />}
aria-label={t('Clear all selection')}
/>
</Tooltip>
</div>
</div>
);
return (
<Popover
content={popoverContent}
trigger="click"
open={showValueModal && !isDisabled}
onOpenChange={handleOpenChange}
placement="bottomLeft"
arrow={false}
getPopupContainer={triggerNode => triggerNode || document.body} // Ensure it attaches correctly
>
{/* 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
// }
// )}
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 ? (
<>
<Text ellipsis={{
tooltip: {
title: mapValues ? mapValues(initialValues[0]) : initialValues[0],
placement: 'topLeft',
mouseEnterDelay: 0.5
}
}} style={{ maxWidth: '8rem' }}>
{mapValues ? mapValues(initialValues[0]) : initialValues[0]}
</Text>
{initialValues.length > 1 && (
<>
<Text type="secondary" className="flex-shrink-0">{t('or')}</Text>
<Text ellipsis={{
tooltip: {
title: mapValues ? mapValues(initialValues[1]) : initialValues[1],
placement: 'topLeft',
mouseEnterDelay: 0.5
}
}} style={{ maxWidth: '8rem' }}>
{mapValues ? mapValues(initialValues[1]) : initialValues[1]}
</Text>
{initialValues.length > 2 && (
<Text type="secondary" className="flex-shrink-0">
{`+ ${initialValues.length - 2}`}
</Text>
)}
</>
)}
</>
) : (
<Text type={isDisabled ? 'secondary' : undefined} 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}
aria-label={t('Clear selection')}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
style={{ height: '100%', border: 'none', background: 'transparent', zIndex: 1 }} // Ensure clickable area
/>
)}
</Button>
</Popover>
);
}
);
export default ValueAutoComplete;

View file

@ -1,17 +1,16 @@
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 {
export interface TopValue {
rowCount?: number;
rowPercentage?: number;
value?: string;
}
interface TopValues {
export interface TopValues {
[key: string]: TopValue[];
}
@ -31,17 +30,6 @@ export default class FilterStore {
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();
}
@ -156,34 +144,29 @@ export default class FilterStore {
return this.getAllFilters(projectStore.activeSiteId + '');
};
// getEventFilters = (eventName: string): Filter[] => {
// const filters = await filterService.fetchProperties(eventName)
// return filters;
// // const filters = this.getAllFilters(projectStore.activeSiteId + '');
// // return filters.filter(i => !i.isEvent); // TODO fetch from the API for this event and cache them
// };
getEventFilters = async (eventName: string): Promise<Filter[]> => {
if (this.filterCache[eventName]) {
return this.filterCache[eventName];
const cacheKey = `${projectStore.activeSiteId}_${eventName}`;
console.log('cacheKey store', cacheKey);
if (this.filterCache[cacheKey]) {
return this.filterCache[cacheKey];
}
if (await this.pendingFetches[eventName]) {
return this.pendingFetches[eventName];
if (await this.pendingFetches[cacheKey]) {
return this.pendingFetches[cacheKey];
}
try {
this.pendingFetches[eventName] = this.fetchAndProcessPropertyFilters(eventName);
const filters = await this.pendingFetches[eventName];
this.pendingFetches[cacheKey] = this.fetchAndProcessPropertyFilters(eventName);
const filters = await this.pendingFetches[cacheKey];
runInAction(() => {
this.filterCache[eventName] = filters;
this.filterCache[cacheKey] = filters;
});
delete this.pendingFetches[eventName];
delete this.pendingFetches[cacheKey];
return filters;
} catch (error) {
delete this.pendingFetches[eventName];
delete this.pendingFetches[cacheKey];
throw error;
}
};