From 634e43a6c99f381ae6998941a44536b5595e791d Mon Sep 17 00:00:00 2001 From: suryacp23 Date: Wed, 29 Jul 2026 11:22:12 +0530 Subject: [PATCH] added filter multiselect --- src/api/client.ts | 19 ++- src/api/types.ts | 2 +- src/components/reusable/Select.tsx | 247 +++++++++++++++++++++++++++++ src/components/rv/RecordView.tsx | 110 ++++++++++--- 4 files changed, 352 insertions(+), 26 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index c5e7fa6..828b80c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -153,12 +153,19 @@ export class ZinoClient { sort_by: params.sortBy ?? '', sort_dir: params.sortDir ?? 'desc', search: params.search ?? '', - filters: (params.filters ?? []).map((f) => ({ - field_key: f.field_key, - value: f.value, - value2: '', - data_type: f.data_type ?? 'string', - })), + filters: (params.filters ?? []).map((f) => { + const item: Record = { + field_key: f.field_key, + data_type: f.data_type ?? 'character varying', + }; + if (f.values && f.values.length > 0) { + item.values = f.values; + } else { + item.value = f.value ?? ''; + item.value2 = ''; + } + return item; + }), }, }); } diff --git a/src/api/types.ts b/src/api/types.ts index ea4e7a3..80d867a 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -65,7 +65,7 @@ export interface RecordViewParams { sortBy?: string; sortDir?: 'asc' | 'desc'; search?: string; - filters?: Array<{ field_key: string; value: string; data_type?: string }>; + filters?: Array<{ field_key: string; value?: string; values?: string[]; data_type?: string }>; presetAlias?: string; } diff --git a/src/components/reusable/Select.tsx b/src/components/reusable/Select.tsx index 95b25fb..c1461e4 100644 --- a/src/components/reusable/Select.tsx +++ b/src/components/reusable/Select.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback, type SelectHTMLAttributes } f import { createPortal } from 'react-dom'; import { ChevronDown, Search, X, Check } from 'lucide-react'; import { cn } from '../../lib/cn'; +import { Button } from '../buttons/Button'; export interface SelectOption { value: string; @@ -217,3 +218,249 @@ export function Select({ ); } + +export interface MultiSelectProps { + label?: string; + hint?: string; + error?: string; + placeholder?: string; + /** Either strings or {value,label} objects. */ + options?: Array; + value?: string[]; + onChange?: (values: string[]) => void; + required?: boolean; + disabled?: boolean; + className?: string; + /** Disable search header filter if set to false */ + searchable?: boolean; +} + +/** Custom searchable multiselect component using React Portal. */ +export function MultiSelect({ + label, + hint, + error, + options = [], + value = [], + onChange, + required, + className, + disabled, + placeholder, + searchable = true, +}: MultiSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [dropdownStyle, setDropdownStyle] = useState({}); + const [draftValues, setDraftValues] = useState(value ?? []); + + const containerRef = useRef(null); + const dropdownRef = useRef(null); + + const normalizedOptions: SelectOption[] = options.map((o) => + typeof o === 'string' ? { value: o, label: o } : { value: String(o.value ?? ''), label: o.label } + ); + + const currentValues = Array.isArray(value) ? value : []; + + const handleOpenToggle = () => { + if (!disabled) { + if (!isOpen) { + setDraftValues(currentValues); + setSearchQuery(''); + } + setIsOpen(!isOpen); + } + }; + + const filteredOptions = normalizedOptions.filter((o) => + o.label.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const updatePosition = useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const dropdownHeight = 320; + const spaceBelow = window.innerHeight - rect.bottom; + const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight; + + setDropdownStyle({ + position: 'fixed', + left: `${rect.left}px`, + width: `${rect.width}px`, + zIndex: 999999, + ...(openUpwards + ? { bottom: `${window.innerHeight - rect.top + 4}px` } + : { top: `${rect.bottom + 4}px` }), + }); + } + }, []); + + useEffect(() => { + if (isOpen) { + updatePosition(); + const handleScrollOrResize = () => updatePosition(); + window.addEventListener('resize', handleScrollOrResize); + window.addEventListener('scroll', handleScrollOrResize, true); + return () => { + window.removeEventListener('resize', handleScrollOrResize); + window.removeEventListener('scroll', handleScrollOrResize, true); + }; + } + }, [isOpen, updatePosition]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + const isOutsideContainer = containerRef.current && !containerRef.current.contains(target); + const isOutsideDropdown = dropdownRef.current && !dropdownRef.current.contains(target); + + if (isOutsideContainer && isOutsideDropdown) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const toggleOption = (val: string) => { + setDraftValues((prev) => + prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val] + ); + }; + + const handleApply = () => { + onChange?.(draftValues); + setIsOpen(false); + }; + + const handleClear = () => { + setDraftValues([]); + }; + + const selectedLabels = normalizedOptions + .filter((o) => currentValues.includes(o.value)) + .map((o) => o.label); + + const triggerText = + selectedLabels.length === 0 + ? placeholder || 'Select...' + : selectedLabels.length === 1 + ? selectedLabels[0] + : `${selectedLabels.length} selected`; + + return ( +
+ {label && ( + + )} + + {/* Trigger Box */} +
+ 0 ? "text-strong font-semibold" : "text-faint")}> + {triggerText} + + +
+ + {/* Portaled Dropdown Menu Overlay */} + {isOpen && !disabled && createPortal( +
e.stopPropagation()} + > + {searchable && ( +
+ + setSearchQuery(e.target.value)} + placeholder="Search options..." + className="w-full text-xs bg-transparent border-none outline-none text-strong placeholder:text-muted" + autoFocus + /> + {searchQuery && ( + + )} +
+ )} + +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((opt) => { + const isSelected = draftValues.includes(opt.value); + return ( +
toggleOption(opt.value)} + className={cn( + "px-3 py-2 text-xs flex items-center gap-2.5 cursor-pointer transition-colors select-none", + isSelected ? "bg-blue-50/70 text-blue-800 font-semibold" : "hover:bg-black/5 text-strong" + )} + > + {}} + className="rounded border-slate-300 text-blue-600 focus:ring-blue-500 pointer-events-none h-3.5 w-3.5" + /> + {opt.label} +
+ ); + }) + ) : ( +
+ No matching options +
+ )} +
+ + {/* Action Footer */} +
+ + +
+
, + document.body + )} + + {(hint || error) && ( + {error || hint} + )} +
+ ); +} diff --git a/src/components/rv/RecordView.tsx b/src/components/rv/RecordView.tsx index 1f1ec56..4993fa4 100644 --- a/src/components/rv/RecordView.tsx +++ b/src/components/rv/RecordView.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Search } from 'lucide-react'; +import { Search, X } from 'lucide-react'; import { cn } from '../../lib/cn'; import { formatValue } from '../../lib/format'; import type { ZinoClient } from '../../api/client'; @@ -10,7 +10,7 @@ import { Spinner } from '../reusable/Spinner'; import { EmptyState } from '../reusable/EmptyState'; import { StatsTiles } from '../reusable/StatsTiles'; import { AnalyticsChart } from '../reusable/AnalyticsChart'; -import { Select } from '../reusable/Select'; +import { Select, MultiSelect } from '../reusable/Select'; export interface RecordViewProps { /** Workflow-bound client (see api/clients.ts). */ @@ -40,7 +40,7 @@ export interface RecordViewProps { /** Sort direction */ sortDir?: 'asc' | 'desc'; /** Default filters to apply initially. */ - initialFilters?: Record; + initialFilters?: Record; /** Custom preset alias to send with the request (e.g. preset_alias: my_orders) */ presetAlias?: string; /** Component to render next to charts (e.g., a map) */ @@ -74,7 +74,22 @@ export function RecordView({ const [page, setPage] = useState(1); const [search, setSearch] = useState(''); const [debounced, setDebounced] = useState(''); - const [activeFilters, setActiveFilters] = useState>(initialFilters); + + const initialFiltersNormalized = useMemo(() => { + const res: Record = {}; + if (initialFilters) { + Object.entries(initialFilters).forEach(([k, v]) => { + if (Array.isArray(v)) { + res[k] = v; + } else if (v) { + res[k] = [v]; + } + }); + } + return res; + }, [initialFilters]); + + const [activeFilters, setActiveFilters] = useState>(initialFiltersNormalized); const [resp, setResp] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -92,15 +107,18 @@ export function RecordView({ }; }, [search]); + const activeFiltersStr = JSON.stringify(activeFilters); + const filtersParam = useMemo(() => { - return Object.entries(activeFilters) - .filter(([_, val]) => val) - .map(([key, val]) => ({ + const parsed = JSON.parse(activeFiltersStr) as Record; + return Object.entries(parsed) + .filter(([_, vals]) => vals && vals.length > 0) + .map(([key, vals]) => ({ field_key: key, - value: val, - data_type: 'string', + values: vals, + data_type: 'character varying', })); - }, [activeFilters]); + }, [activeFiltersStr]); useEffect(() => { let live = true; @@ -203,12 +221,16 @@ export function RecordView({ key={key} type="date" title={`Filter by ${label}`} - value={activeFilters[key] || ''} + value={activeFilters[key]?.[0] || ''} onChange={(e) => { - setActiveFilters(prev => ({ ...prev, [key]: e.target.value })); + const val = e.target.value; + setActiveFilters(prev => ({ + ...prev, + [key]: val ? [val] : [] + })); setPage(1); }} - className="w-36 sm:w-40 h-9 rounded-md border border-border-default bg-card px-3 font-sans text-sm text-strong outline-none focus-ring shrink-0" + className="w-36 sm:w-44 h-[38px] rounded-md border border-border-default bg-card px-3 font-sans text-xs text-strong outline-none focus-ring shrink-0 cursor-pointer" /> ); } @@ -218,15 +240,16 @@ export function RecordView({ const opts = options.map(o => ({ value: o, label: o })); return ( -