Files
khata-ui/react-openapi/components/FilterBar.tsx

287 lines
8.0 KiB
TypeScript

import * as React from "react";
import {
Box,
Button,
Paper,
TextField,
Autocomplete,
Select,
MenuItem,
FormControl,
InputLabel,
Typography,
} from "@mui/material";
import FilterListIcon from "@mui/icons-material/FilterList";
import { ResourceField, ResourceMode } from "../types/config";
function getDisplayValue(item: any, field: ResourceField): string {
if (!item) return "";
const df = field.displayField;
if (!df) return item.name || item.title || item.label || String(item.id ?? "");
if (Array.isArray(df)) {
return df.map((k) => item[k]).filter((v) => v != null).join(" ");
}
return item[df] ?? String(item.id ?? "");
}
function extractOptions(
fieldName: string,
field: ResourceField,
data: any[]
): string[] {
const values = new Set<string>();
if (field.options) {
return field.options;
}
if (!data) return [];
for (const item of data) {
const v = item[fieldName];
if (v == null) continue;
if (field.type === "array" && Array.isArray(v)) {
for (const el of v) {
if (el != null && typeof el === "object") {
const d = getDisplayValue(el, field);
if (d) values.add(d);
} else if (el != null) {
values.add(String(el));
}
}
} else if (typeof v === "object") {
const d = getDisplayValue(v, field);
if (d) values.add(d);
} else {
values.add(String(v));
}
}
return Array.from(values).sort();
}
function renderFilterInput(
fieldName: string,
field: ResourceField,
options: string[],
value: any,
onChange: (key: string, val: any) => void
) {
const isRange =
field.type === "number" || field.type === "datetime" || field.type === "date";
if (isRange) {
const rangeVal = (value as { min?: string; max?: string; start?: string; end?: string }) || {};
const isDate = field.type === "datetime" || field.type === "date";
const inputType = isDate ? "datetime-local" : "number";
if (isDate) {
return (
<Box key={fieldName} sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Typography variant="caption" sx={{ minWidth: 80, color: "text.secondary" }}>
{field.label}
</Typography>
<TextField
type={inputType}
placeholder="From"
size="small"
value={rangeVal.start ?? ""}
onChange={(e) => onChange("start", e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
sx={{ width: 190 }}
/>
<TextField
type={inputType}
placeholder="To"
size="small"
value={rangeVal.end ?? ""}
onChange={(e) => onChange("end", e.target.value || undefined)}
InputLabelProps={{ shrink: true }}
sx={{ width: 190 }}
/>
</Box>
);
}
return (
<Box key={fieldName} sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Typography variant="caption" sx={{ minWidth: 80, color: "text.secondary" }}>
{field.label}
</Typography>
<TextField
type={inputType}
placeholder="Min"
size="small"
value={rangeVal.min ?? ""}
onChange={(e) => onChange("min", e.target.value || undefined)}
sx={{ width: 120 }}
/>
<TextField
type={inputType}
placeholder="Max"
size="small"
value={rangeVal.max ?? ""}
onChange={(e) => onChange("max", e.target.value || undefined)}
sx={{ width: 120 }}
/>
</Box>
);
}
if (field.type === "boolean") {
return (
<FormControl key={fieldName} size="small" sx={{ minWidth: 140 }}>
<InputLabel>{field.label}</InputLabel>
<Select
value={value ?? ""}
label={field.label}
onChange={(e) => onChange("value", e.target.value || undefined)}
>
<MenuItem value="">All</MenuItem>
<MenuItem value="true">Yes</MenuItem>
<MenuItem value="false">No</MenuItem>
</Select>
</FormControl>
);
}
if (options.length <= 20) {
return (
<Autocomplete
key={fieldName}
options={options}
value={value ?? null}
onChange={(_, val) => onChange("value", val || undefined)}
renderInput={(params) => (
<TextField {...params} label={field.label} size="small" />
)}
sx={{ minWidth: 180 }}
size="small"
/>
);
}
return (
<TextField
key={fieldName}
label={field.label}
value={value ?? ""}
onChange={(e) => onChange("value", e.target.value || undefined)}
size="small"
sx={{ minWidth: 180 }}
/>
);
}
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 options = extractOptions(fieldName, field, data ?? []);
const raw = draft[fieldName];
return (
<React.Fragment key={fieldName}>
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
updateDraft(fieldName, key, val)
)}
</React.Fragment>
);
})}
</Box>
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
<Button variant="contained" size="small" onClick={handleApply}>
Apply
</Button>
<Button variant="outlined" size="small" onClick={handleClear}>
Clear
</Button>
</Box>
</Box>
)}
</Paper>
);
}