Add multi-select filters to Missing Data view using ColumnFilterPopover

This commit is contained in:
Christian Vidal Wolf
2026-04-12 22:27:04 +02:00
parent 31921666ad
commit 4533a98ddf
+31 -55
View File
@@ -1,7 +1,8 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { Search, ChevronDown, ChevronUp, X, Edit2, Save, Filter, XCircle, ChevronLeft, ChevronRight } from 'lucide-react'; import { Search, ChevronDown, ChevronUp, X, Edit2, Save } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
interface MissingDataViewProps { interface MissingDataViewProps {
data: ExcelRow[]; data: ExcelRow[];
@@ -52,9 +53,8 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
const [sortDesc, setSortDesc] = useState(false); const [sortDesc, setSortDesc] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [editing, setEditing] = useState<EditingState | null>(null); const [editing, setEditing] = useState<EditingState | null>(null);
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({}); const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [showFilterMenu, setShowFilterMenu] = useState<number | null>(null); const [openFilter, setOpenFilter] = useState<number | null>(null);
const [filterSearch, setFilterSearch] = useState<Record<number, string>>({});
const pageSize = 100; const pageSize = 100;
const launchDateCol = useMemo(() => const launchDateCol = useMemo(() =>
@@ -92,12 +92,10 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
return result; return result;
}, [data, columns, launchDateCol, readyToOrderCol]); }, [data, columns, launchDateCol, readyToOrderCol]);
const getDisplayValues = (col: number) => { const getUniqueValues = (col: number): string[] => {
const values = columnUniqueValues[col]; const values = columnUniqueValues[col];
if (!values) return []; if (!values) return [];
const searchVal = filterSearch[col]?.toLowerCase() || ''; return Array.from(values).sort() as string[];
const arr = Array.from(values).sort();
return searchVal ? arr.filter((v: string) => v.toLowerCase().includes(searchVal)) : arr;
}; };
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
@@ -118,16 +116,15 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
); );
} }
(Object.entries(columnFilters) as [string, string][]).forEach(([colIdx, filterValue]) => { (Object.entries(columnFilters) as [string, string[]][]).forEach(([colIdx, filterValues]) => {
if (!filterValue) return; if (!filterValues || filterValues.length === 0) return;
const colIdxNum = parseInt(colIdx); const colIdxNum = parseInt(colIdx);
const filterLower = filterValue.toLowerCase();
result = result.filter(r => { result = result.filter(r => {
const val: any = r.row[colIdxNum]; const val: any = r.row[colIdxNum];
const displayVal = colIdxNum === launchDateCol || colIdxNum === readyToOrderCol const displayVal = colIdxNum === launchDateCol || colIdxNum === readyToOrderCol
? formatDateValue(val) || String(val ?? '') ? formatDateValue(val) || String(val ?? '')
: String(val ?? ''); : String(val ?? '');
return displayVal.toLowerCase().includes(filterLower); return filterValues.includes(displayVal);
}); });
}); });
@@ -235,9 +232,9 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10"> <thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
<tr> <tr>
{columns.map(({ col, label, width }) => { {columns.map(({ col, label, width }) => {
const isOpen = showFilterMenu === col; const selectedFilters = columnFilters[col] || [];
const hasFilter = columnFilters[col]; const allValues = getUniqueValues(col);
const displayValues = getDisplayValues(col); const filterCount = selectedFilters.length;
return ( return (
<th <th
key={col} key={col}
@@ -255,51 +252,30 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
</div> </div>
<div className="mt-1 relative"> <div className="mt-1 relative">
<button <button
onClick={(e) => { e.stopPropagation(); setShowFilterMenu(isOpen ? null : col); }} onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === col ? null : col); }}
className={cn( className={cn(
"w-full flex items-center justify-between px-1.5 py-0.5 bg-slate-800 border rounded text-[10px] transition-colors", "w-full flex items-center justify-between px-1.5 py-0.5 bg-slate-800 border rounded text-[10px] transition-colors",
hasFilter ? "border-indigo-500 text-white" : "border-slate-600 text-slate-400 hover:border-slate-500" filterCount > 0 ? "border-indigo-500 text-white" : "border-slate-600 text-slate-400 hover:border-slate-500"
)} )}
> >
<span className="truncate">{hasFilter || 'Filter...'}</span> <span className="truncate">{filterCount > 0 ? `${filterCount} selected` : 'Filter...'}</span>
<ChevronDown className={cn("w-3 h-3 transition-transform", isOpen && "rotate-180")} /> <ChevronDown className={cn("w-3 h-3 transition-transform", openFilter === col && "rotate-180")} />
</button> </button>
{isOpen && ( {openFilter === col && (
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-800 border border-slate-600 rounded-md shadow-lg z-50 max-h-64 flex flex-col"> <ColumnFilterPopover
<div className="p-1.5 border-b border-slate-700"> uniqueValues={allValues}
<input selectedValues={selectedFilters}
type="text" onToggle={(val) => setColumnFilters(prev => {
placeholder="Search..." const current = prev[col] || [];
value={filterSearch[col] || ''} if (current.includes(val)) {
onChange={(e) => setFilterSearch(prev => ({ ...prev, [col]: e.target.value }))} return { ...prev, [col]: current.filter(v => v !== val) };
onClick={(e) => e.stopPropagation()} }
className="w-full px-2 py-1 bg-slate-900 border border-slate-700 rounded text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500" return { ...prev, [col]: [...current, val] };
/> })}
</div> onSelectAll={(vals) => setColumnFilters(prev => ({ ...prev, [col]: vals }))}
<div className="flex-1 overflow-auto"> onClear={() => { setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; }); }}
<button onClose={() => setOpenFilter(null)}
onClick={(e) => { e.stopPropagation(); setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; }); setPage(1); }} />
className="w-full px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-700 hover:text-white"
>
(All)
</button>
{displayValues.map((val) => (
<button
key={val}
onClick={(e) => { e.stopPropagation(); setColumnFilters(prev => ({ ...prev, [col]: val })); setShowFilterMenu(null); setPage(1); }}
className={cn(
"w-full px-2 py-1.5 text-left text-xs hover:bg-slate-700 hover:text-white",
columnFilters[col] === val ? "text-indigo-400 bg-indigo-500/10" : "text-slate-300"
)}
>
{val}
</button>
))}
{displayValues.length === 0 && (
<div className="px-2 py-2 text-xs text-slate-500 text-center">No values</div>
)}
</div>
</div>
)} )}
</div> </div>
</th> </th>