From 6a100561bf28aac10d0edf57d23771c85074cebc Mon Sep 17 00:00:00 2001 From: Shekar Siri Date: Thu, 3 Apr 2025 22:26:02 +0200 Subject: [PATCH] feat(ui): dynamic fitlers - ui improvements --- .../FilterAutoComplete/AutocompleteModal.tsx | 2 +- .../FilterAutoComplete/FilterAutoComplete.tsx | 157 +++---- .../shared/Filters/FilterItem/FilterItem.tsx | 202 +++++---- .../Filters/FilterList/UnifiedFilterList.tsx | 4 +- .../Filters/FilterOperator/FilterOperator.tsx | 102 ++++- .../FilterSelection/FilterSelection.tsx | 4 +- .../Filters/FilterValue/FilterValue.tsx | 56 ++- .../Filters/FilterValue/ValueAutoComplete.tsx | 395 ++++++++++++++++++ frontend/app/mstore/filterStore.ts | 43 +- 9 files changed, 722 insertions(+), 243 deletions(-) create mode 100644 frontend/app/components/shared/Filters/FilterValue/ValueAutoComplete.tsx diff --git a/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx b/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx index 88042887c..9be726482 100644 --- a/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx +++ b/frontend/app/components/shared/Filters/FilterAutoComplete/AutocompleteModal.tsx @@ -224,7 +224,7 @@ export function AutocompleteModal({ ); } -interface Props { +export interface Props { value: string[]; params?: any; onApplyValues: (values: string[]) => void; diff --git a/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx index 1eb37e0b3..fd5556752 100644 --- a/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx +++ b/frontend/app/components/shared/Filters/FilterAutoComplete/FilterAutoComplete.tsx @@ -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 = {}; // 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; - 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([]); 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); }; diff --git a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx index 4f5e48cc4..4181a6198 100644 --- a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx +++ b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx @@ -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 (
{/* Use items-start */} {!isSubItem && !hideIndex && filterIndex !== undefined && filterIndex >= 0 && (
{/* 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 */} {filterIndex + 1}
)} {isSubItem && (
{/* Align where/and top */} + className="flex-shrink-0 w-14 text-right text-sm text-neutral-500/90 pr-2"> {subFilterIndex === 0 && ( where @@ -220,109 +227,120 @@ function FilterItem(props: Props) { {/* Main content area */}
{/* Use baseline inside here */} + className="flex flex-grow flex-wrap gap-x-1 items-center"> - -
- {filter && getIconForFilter(filter)} -
- {(filter?.subCategory || filter?.category) && ( -
- {`${filter?.subCategory ? filter.subCategory : filter?.category}`} -
- )} - {(filter?.subCategory || filter?.category) && (filter.displayName || filter.name) && - - } -
- {filter.displayName || filter.name || 'Select Filter'} -
-
+ + {/* Icon */} + {filter && ( + + {getIconForFilter(filter)} + + )} + + {/* Category/SubCategory */} + {hasCategory && ( + + {categoryPart} + + )} + + {showSeparator && ( + + )} + + + {hasName ? namePart : (hasCategory ? '' : defaultText)} {/* Show name or placeholder */} + + +
-
- {filter.hasSource && ( - <> - - - - )} + {/**/} + {filter.hasSource && ( + <> + + + + )} - {operatorOptions.length > 0 && filter.type && ( - <> - - {canShowValues && - (readonly ? ( -
- {filter.value - .map((val: string) => - filter.options?.find((i: any) => i.value === val)?.label ?? val - ) - .join(', ')} -
- ) : ( -
{/* Wrap FilterValue */} - -
- ))} - - )} + {operatorOptions.length > 0 && filter.type && ( + <> + + {canShowValues && + (readonly ? ( +
+ {filter.value + .map((val: string) => + filter.options?.find((i: any) => i.value === val)?.label ?? val + ) + .join(', ')} +
+ ) : ( +
{/* Wrap FilterValue */} + +
+ ))} + + )} - {filter.isEvent && !isSubItem && ( - - -
+ {filter.isEvent && !isSubItem && ( + + +
*/}
{/* Action Buttons */} {!readonly && !hideDelete && ( -
{/* Align top */} +
{/* Align top */}
@@ -341,7 +359,7 @@ function FilterItem(props: Props) { {filteredSubFilters.length > 0 && (
{/* Dashed line */} diff --git a/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx b/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx index 54b16f2ea..e8722438e 100644 --- a/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx +++ b/frontend/app/components/shared/Filters/FilterList/UnifiedFilterList.tsx @@ -153,7 +153,7 @@ const UnifiedFilterList = (props: UnifiedFilterListProps) => { }, []); return filters.length ? ( -
+
{filters.map((filterItem: any, filterIndex: number) => (
{ > {isDraggable && filters.length > 1 && (
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 = ( + + {/* Add Clear Option if allowClear is true and a value is selected */} + {allowClear && value !== undefined && ( + <> + + Clear Selection {/* Or use a specific label */} + + + + )} + {/* Map options to Menu.Item */} + {options.map(option => ( + + {option.label} + + ))} + + ); + return ( -
-