279 lines
8.3 KiB
TypeScript
279 lines
8.3 KiB
TypeScript
import * as React from "react";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Paper,
|
|
TextField,
|
|
Autocomplete,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import DoneIcon from "@mui/icons-material/Done";
|
|
import FilterListIcon from "@mui/icons-material/FilterList";
|
|
import { ResourceField, ResourceMode } from "../types/config";
|
|
|
|
function FilterAutocomplete({
|
|
options,
|
|
value,
|
|
label,
|
|
onChange,
|
|
}: {
|
|
options: string[];
|
|
value: string[];
|
|
label: string;
|
|
onChange: (val: string[]) => void;
|
|
}) {
|
|
const listboxRef = React.useRef<HTMLUListElement>(null);
|
|
const scrollPosRef = React.useRef(0);
|
|
const [frozenValue, setFrozenValue] = React.useState<string[]>(value);
|
|
|
|
const sortedOptions = React.useMemo(() => {
|
|
const sel = new Set(frozenValue);
|
|
const picked: string[] = [];
|
|
const rest: string[] = [];
|
|
for (const o of options) {
|
|
if (sel.has(o)) picked.push(o);
|
|
else rest.push(o);
|
|
}
|
|
return [...picked, ...rest];
|
|
}, [options, frozenValue]);
|
|
|
|
return (
|
|
<Autocomplete
|
|
multiple
|
|
freeSolo
|
|
disableCloseOnSelect
|
|
limitTags={1}
|
|
options={sortedOptions}
|
|
value={value}
|
|
getOptionKey={(option) => option}
|
|
onChange={(_, val) => onChange(val.length > 0 ? val : [])}
|
|
onOpen={() => setFrozenValue(value)}
|
|
onClose={() => setFrozenValue(value)}
|
|
ListboxProps={{
|
|
ref: listboxRef,
|
|
onScroll: (e) => { scrollPosRef.current = (e.target as HTMLUListElement).scrollTop; },
|
|
}}
|
|
renderOption={(props, option, { selected }) => {
|
|
const { key, ...rest } = props;
|
|
return (
|
|
<li key={key} {...rest}>
|
|
{selected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: 'primary.main' }} /> : <Box sx={{ width: 22, mr: 1 }} />}
|
|
{option}
|
|
</li>
|
|
);
|
|
}}
|
|
renderInput={(params) => <TextField {...params} placeholder={`Add ${label}...`} />}
|
|
sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function extractOptions(
|
|
fieldName: string,
|
|
field: ResourceField,
|
|
data: any[]
|
|
): string[] {
|
|
const values = new Set<string>();
|
|
|
|
if (field.options) return field.options;
|
|
if (!data) return [];
|
|
|
|
const pull = (item: any): string | null => {
|
|
if (item == null) return null;
|
|
if (typeof item === "string") return item;
|
|
if (typeof item !== "object") return String(item);
|
|
|
|
const df = field.displayField;
|
|
if (!df) { debugger; return null; }
|
|
|
|
if (Array.isArray(df)) {
|
|
const parts = df.map((k) => item[k]).filter((v) => v != null);
|
|
if (parts.length > 0) return parts.join(" ");
|
|
} else {
|
|
const v = item[df];
|
|
if (v != null) return String(v);
|
|
}
|
|
|
|
debugger;
|
|
return null;
|
|
};
|
|
|
|
for (const row of data) {
|
|
const v = row[fieldName];
|
|
if (v == null) continue;
|
|
|
|
if (Array.isArray(v)) {
|
|
for (const el of v) {
|
|
const label = pull(el);
|
|
if (label) values.add(label);
|
|
}
|
|
} else {
|
|
const label = pull(v);
|
|
if (label) values.add(label);
|
|
}
|
|
}
|
|
|
|
// console.log('extracted', fieldName, Array.from(values).sort())
|
|
return Array.from(values).sort();
|
|
}
|
|
|
|
function renderFilterInput(
|
|
fieldName: string,
|
|
field: ResourceField,
|
|
options: string[],
|
|
value: any,
|
|
onChange: (key: string, val: any) => void
|
|
) {
|
|
const filterType = field.filterType;
|
|
|
|
if (filterType === "number-range") {
|
|
const rangeVal = (value as { min?: string; max?: string }) || {};
|
|
return (
|
|
<Box sx={{ display: "flex", gap: 1 }}>
|
|
<TextField type="number" placeholder="Min" size="small" value={rangeVal.min ?? ""}
|
|
onChange={(e) => onChange("min", e.target.value || undefined)} sx={{ width: 100 }} />
|
|
<TextField type="number" placeholder="Max" size="small" value={rangeVal.max ?? ""}
|
|
onChange={(e) => onChange("max", e.target.value || undefined)} sx={{ width: 100 }} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (filterType === "date-range") {
|
|
const rangeVal = (value as { start?: string; end?: string }) || {};
|
|
return (
|
|
<Box sx={{ display: "flex", gap: 1 }}>
|
|
<TextField type="datetime-local" placeholder="From" size="small" value={rangeVal.start ?? ""}
|
|
onChange={(e) => onChange("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
|
|
<TextField type="datetime-local" placeholder="To" size="small" value={rangeVal.end ?? ""}
|
|
onChange={(e) => onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const selected = Array.isArray(value) ? value : [];
|
|
|
|
return (
|
|
<FilterAutocomplete
|
|
options={options}
|
|
value={selected}
|
|
label={field.label}
|
|
onChange={(val) => onChange("value", val.length > 0 ? val : undefined)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export interface FilterBarProps {
|
|
fields: Record<string, ResourceField>;
|
|
filterableFields: string[];
|
|
mode: ResourceMode;
|
|
data?: any[];
|
|
appliedValues: Record<string, any>;
|
|
onApply: (values: Record<string, any>) => void;
|
|
onClear: () => void;
|
|
}
|
|
|
|
export default function FilterBar({
|
|
fields,
|
|
filterableFields,
|
|
data,
|
|
appliedValues,
|
|
onApply,
|
|
onClear,
|
|
}: FilterBarProps) {
|
|
const [open, setOpen] = React.useState(false);
|
|
const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues }));
|
|
|
|
React.useEffect(() => {
|
|
if (!open) setDraft({ ...appliedValues });
|
|
}, [appliedValues, open]);
|
|
|
|
if (!filterableFields || filterableFields.length === 0) return null;
|
|
|
|
const activeCount = Object.keys(appliedValues).filter((k) => {
|
|
const v = appliedValues[k];
|
|
if (v == null || v === "") return false;
|
|
if (typeof v === "object" && Object.values(v).every((x) => x == null || x === "")) return false;
|
|
return true;
|
|
}).length;
|
|
|
|
const handleApply = () => onApply({ ...draft });
|
|
const handleClear = () => {
|
|
setDraft({});
|
|
onClear();
|
|
};
|
|
|
|
const updateDraft = (fieldName: string, key: string, val: any) => {
|
|
setDraft((prev) => {
|
|
if (key === "value") {
|
|
return { ...prev, [fieldName]: val };
|
|
}
|
|
const existing = prev[fieldName] || {};
|
|
return { ...prev, [fieldName]: { ...existing, [key]: val } };
|
|
});
|
|
};
|
|
|
|
return (
|
|
<Paper variant="outlined" sx={{ mb: 2, borderRadius: 2, overflow: "hidden" }}>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
px: 2,
|
|
py: 1,
|
|
cursor: "pointer",
|
|
"&:hover": { bgcolor: "action.hover" },
|
|
}}
|
|
onClick={() => setOpen((o) => !o)}
|
|
>
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
<FilterListIcon fontSize="small" color="action" />
|
|
<Typography variant="subtitle2" fontWeight={600}>
|
|
{open ? "Hide Filters" : "Show Filters"}
|
|
</Typography>
|
|
</Box>
|
|
{activeCount > 0 && (
|
|
<Typography variant="caption" color="primary" fontWeight={600}>
|
|
{activeCount} active
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
|
|
{open && (
|
|
<Box sx={{ px: 2, pb: 2, borderTop: "1px solid", borderColor: "divider", pt: 2 }}>
|
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, alignItems: "flex-end" }}>
|
|
{filterableFields.map((fieldName) => {
|
|
const field = fields[fieldName];
|
|
if (!field) return null;
|
|
|
|
const needsOptions = !field.filterType || field.filterType === "autocomplete" || field.filterType === "multiselect";
|
|
const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : [];
|
|
const raw = draft[fieldName];
|
|
|
|
return (
|
|
<Box key={fieldName} sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: { sm: 200 } }}>
|
|
<Box sx={{ typography: "caption", mb: 0.5, color: "text.secondary" }}>
|
|
{field.label}
|
|
</Box>
|
|
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
|
|
updateDraft(fieldName, key, val)
|
|
)}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
|
|
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
|
|
<Button variant="contained" onClick={handleApply}>
|
|
Apply
|
|
</Button>
|
|
<Button variant="outlined" onClick={handleClear}>
|
|
Clear
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
</Paper>
|
|
);
|
|
}
|