added filter multiselect
This commit is contained in:
parent
9ede3fd490
commit
634e43a6c9
@ -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<string, unknown> = {
|
||||
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;
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MultiSelectProps {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
/** Either strings or {value,label} objects. */
|
||||
options?: Array<string | SelectOption>;
|
||||
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<React.CSSProperties>({});
|
||||
const [draftValues, setDraftValues] = useState<string[]>(value ?? []);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className={cn('flex flex-col gap-1.5 font-sans relative w-full', className)}>
|
||||
{label && (
|
||||
<label className="text-xs font-bold text-slate-700">
|
||||
{label}
|
||||
{required && <span className="text-ruby-600"> *</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Trigger Box */}
|
||||
<div
|
||||
onClick={handleOpenToggle}
|
||||
className={cn(
|
||||
"relative bg-card rounded-md h-[38px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none",
|
||||
disabled && "opacity-60 cursor-not-allowed bg-slate-50",
|
||||
isOpen ? "border-blue-600 ring-2 ring-blue-600/20" : error ? "border-ruby-600" : "border-border-default"
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-xs truncate pr-2", currentValues.length > 0 ? "text-strong font-semibold" : "text-faint")}>
|
||||
{triggerText}
|
||||
</span>
|
||||
<ChevronDown size={15} className={cn("transition-transform duration-150 text-faint shrink-0 ml-1", isOpen && "rotate-180")} />
|
||||
</div>
|
||||
|
||||
{/* Portaled Dropdown Menu Overlay */}
|
||||
{isOpen && !disabled && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle}
|
||||
className="bg-card border border-border-default rounded-md shadow-2xl overflow-hidden flex flex-col max-h-72 animate-in fade-in-50 duration-100 z-[999999]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{searchable && (
|
||||
<div className="p-2 border-b border-border-subtle bg-slate-50 flex items-center gap-2 shrink-0">
|
||||
<Search size={14} className="text-muted shrink-0 ml-1" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 py-1 min-h-[100px]">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((opt) => {
|
||||
const isSelected = draftValues.includes(opt.value);
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
onClick={() => 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"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500 pointer-events-none h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate flex-1">{opt.label}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="px-3 py-3 text-xs text-muted text-center italic">
|
||||
No matching options
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Footer */}
|
||||
<div className="p-2 border-t border-border-subtle bg-slate-50 flex justify-between items-center text-xs shrink-0 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={draftValues.length === 0}
|
||||
className="text-slate-500 hover:text-ruby-600 disabled:opacity-40 font-medium text-[11px] px-2 py-1 rounded cursor-pointer"
|
||||
>
|
||||
Clear ({draftValues.length})
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleApply}
|
||||
className="!h-8 !px-3.5 !text-xs font-bold"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{(hint || error) && (
|
||||
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<string, string>;
|
||||
initialFilters?: Record<string, string | string[]>;
|
||||
/** 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<Record<string, string>>(initialFilters);
|
||||
|
||||
const initialFiltersNormalized = useMemo(() => {
|
||||
const res: Record<string, string[]> = {};
|
||||
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<Record<string, string[]>>(initialFiltersNormalized);
|
||||
const [resp, setResp] = useState<RecordViewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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<string, string[]>;
|
||||
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 (
|
||||
<Select
|
||||
<MultiSelect
|
||||
key={key}
|
||||
value={activeFilters[key] || ''}
|
||||
onChange={(e) => {
|
||||
setActiveFilters(prev => ({ ...prev, [key]: e.target.value }));
|
||||
placeholder={`Filter ${label}...`}
|
||||
value={activeFilters[key] || []}
|
||||
onChange={(newVals) => {
|
||||
setActiveFilters(prev => ({ ...prev, [key]: newVals }));
|
||||
setPage(1);
|
||||
}}
|
||||
options={[{ value: '', label: `All ${label}` }, ...opts]}
|
||||
className="w-36 sm:w-40 shrink-0"
|
||||
options={opts}
|
||||
className="w-40 sm:w-48 shrink-0"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -243,6 +266,55 @@ export function RecordView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Filter Chips & Clear All */}
|
||||
{Object.entries(activeFilters).some(([_, vals]) => vals && vals.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 py-2.5 bg-slate-50/80 border-b border-[var(--z-block-border)]">
|
||||
<span className="text-xs font-semibold text-slate-500 mr-1">Active Filters:</span>
|
||||
{Object.entries(activeFilters).flatMap(([key, vals]) => {
|
||||
const fieldDef = resp?.config?.fields?.find(f => f.field_key === key);
|
||||
const label = fieldDef?.output_label || key;
|
||||
return vals.map((val) => (
|
||||
<span
|
||||
key={`${key}-${val}`}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-blue-100/90 text-blue-800 border border-blue-200 shadow-xs"
|
||||
>
|
||||
<span className="font-semibold text-blue-950">{label}:</span> {val}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFilters((prev) => {
|
||||
const nextVals = (prev[key] || []).filter((v) => v !== val);
|
||||
const next = { ...prev };
|
||||
if (nextVals.length === 0) {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = nextVals;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
className="hover:bg-blue-200 rounded-full p-0.5 text-blue-700 hover:text-blue-950 cursor-pointer"
|
||||
title={`Remove ${val}`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
));
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
className="ml-auto text-xs font-bold text-ruby-600 hover:text-ruby-700 bg-ruby-50 hover:bg-ruby-100 border border-ruby-200 px-3 py-1 rounded-md transition-colors cursor-pointer"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<EmptyState title="Couldn’t load records" hint={error} />
|
||||
) : loading && !resp ? (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user