fix(emex): dynamic filter chain with proper field dedup across all catalogs
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
- Detect available filter fields from actual data per catalog - Skip fields whose values are substrings of optionsRaw (e.g. driveType redundant when optionsRaw="LEFT (GSX)") - Skip fields that duplicate already-added filters (e.g. engine=LHD same as driveType=LHD in Land Rover) - Use optionsRaw as primary variant text in vehicle rows - Works for all 54 catalogs: Renault (structured), Fiat, BMW, Volvo, etc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,14 +25,7 @@ interface EmexVehicle {
|
||||
optionsRaw: string | null;
|
||||
}
|
||||
|
||||
// ── Filter definition ────────────────────────────────
|
||||
|
||||
interface FilterDef {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Extract the filterable value from a vehicle */
|
||||
getter: (v: EmexVehicle) => string | null | undefined;
|
||||
}
|
||||
// ── Helpers ──────────────────────────────────────────
|
||||
|
||||
/** Parse "Key: Value;Key2: Value2" into a map */
|
||||
function parseOptions(raw: string | null): Record<string, string> {
|
||||
@@ -47,89 +40,11 @@ function parseOptions(raw: string | null): Record<string, string> {
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Check if options_raw has key:value structure */
|
||||
function hasStructuredOptions(vehicles: EmexVehicle[]): boolean {
|
||||
const sample = vehicles.find((v) => v.optionsRaw);
|
||||
return sample?.optionsRaw?.includes(":") ?? false;
|
||||
}
|
||||
|
||||
/** Build the list of filter definitions based on what data actually exists */
|
||||
function buildFilterDefs(
|
||||
vehicles: EmexVehicle[],
|
||||
structured: boolean,
|
||||
): FilterDef[] {
|
||||
const defs: FilterDef[] = [];
|
||||
|
||||
if (structured) {
|
||||
// Renault-style: extract filters from parsed options_raw
|
||||
const optionKeys = new Map<string, number>();
|
||||
for (const v of vehicles) {
|
||||
const opts = parseOptions(v.optionsRaw);
|
||||
for (const k of Object.keys(opts)) {
|
||||
optionKeys.set(k, (optionKeys.get(k) || 0) + 1);
|
||||
}
|
||||
}
|
||||
// "Engine type" and "Engine type and suffix" — use "Engine type" as filter
|
||||
if (optionKeys.has("Engine type")) {
|
||||
defs.push({
|
||||
key: "engineType",
|
||||
label: "engine",
|
||||
getter: (v) => parseOptions(v.optionsRaw)["Engine type"] || v.engine,
|
||||
});
|
||||
}
|
||||
if (optionKeys.has("Gearbox type")) {
|
||||
defs.push({
|
||||
key: "gearboxType",
|
||||
label: "gearbox",
|
||||
getter: (v) =>
|
||||
parseOptions(v.optionsRaw)["Gearbox type"] || v.transmission,
|
||||
});
|
||||
}
|
||||
if (optionKeys.has("Vehicle type")) {
|
||||
defs.push({
|
||||
key: "vehicleType",
|
||||
label: "vehicleType",
|
||||
getter: (v) => parseOptions(v.optionsRaw)["Vehicle type"],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Standard fields — add in a sensible order
|
||||
defs.push({
|
||||
key: "engine",
|
||||
label: "engine",
|
||||
getter: (v) => v.engine,
|
||||
});
|
||||
defs.push({
|
||||
key: "bodyType",
|
||||
label: "body",
|
||||
getter: (v) => v.bodyType,
|
||||
});
|
||||
defs.push({
|
||||
key: "transmission",
|
||||
label: "gearbox",
|
||||
getter: (v) => v.transmission,
|
||||
});
|
||||
defs.push({
|
||||
key: "driveType",
|
||||
label: "drive",
|
||||
getter: (v) => v.driveType,
|
||||
});
|
||||
defs.push({
|
||||
key: "fuelType",
|
||||
label: "fuel",
|
||||
getter: (v) => v.fuelType,
|
||||
});
|
||||
defs.push({
|
||||
key: "optionsRaw",
|
||||
label: "variant",
|
||||
getter: (v) => v.optionsRaw,
|
||||
});
|
||||
}
|
||||
|
||||
return defs;
|
||||
}
|
||||
|
||||
/** Get distinct sorted values for a getter */
|
||||
function distinctValues(
|
||||
vehicles: EmexVehicle[],
|
||||
getter: (v: EmexVehicle) => string | null | undefined,
|
||||
@@ -142,17 +57,127 @@ function distinctValues(
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ── Label map ────────────────────────────────────────
|
||||
// ── Dynamic filter builder ───────────────────────────
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
engine: "Motor",
|
||||
gearbox: "Şanzıman",
|
||||
body: "Kasa Tipi",
|
||||
drive: "Sürüş",
|
||||
fuel: "Yakıt",
|
||||
variant: "Varyant",
|
||||
vehicleType: "Araç Tipi",
|
||||
};
|
||||
interface FilterDef {
|
||||
key: string;
|
||||
label: string;
|
||||
getter: (v: EmexVehicle) => string | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build filter chain dynamically from what the data actually contains.
|
||||
* For structured options_raw (Renault/Dacia/Toyota/Lexus), parse the key:value pairs.
|
||||
* For others, use DB fields in a sensible order and skip optionsRaw if it
|
||||
* duplicates another field or is the only remaining differentiator (shown as row text).
|
||||
*/
|
||||
function buildFilterDefs(
|
||||
vehicles: EmexVehicle[],
|
||||
structured: boolean,
|
||||
): FilterDef[] {
|
||||
if (structured) {
|
||||
return buildStructuredFilters(vehicles);
|
||||
}
|
||||
return buildFieldFilters(vehicles);
|
||||
}
|
||||
|
||||
function buildStructuredFilters(vehicles: EmexVehicle[]): FilterDef[] {
|
||||
// Collect all option keys and count usage
|
||||
const keyCount = new Map<string, number>();
|
||||
for (const v of vehicles) {
|
||||
for (const k of Object.keys(parseOptions(v.optionsRaw))) {
|
||||
keyCount.set(k, (keyCount.get(k) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const defs: FilterDef[] = [];
|
||||
const labelMap: Record<string, string> = {
|
||||
"Engine type": "Motor",
|
||||
"Gearbox type": "Şanzıman",
|
||||
"Vehicle type": "Araç Tipi",
|
||||
};
|
||||
|
||||
for (const [optKey, label] of Object.entries(labelMap)) {
|
||||
if (keyCount.has(optKey)) {
|
||||
const k = optKey;
|
||||
defs.push({
|
||||
key: k.replace(/\s+/g, "_"),
|
||||
label,
|
||||
getter: (v) => parseOptions(v.optionsRaw)[k] || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
function buildFieldFilters(vehicles: EmexVehicle[]): FilterDef[] {
|
||||
// Candidate fields in priority order
|
||||
const candidates: {
|
||||
key: string;
|
||||
label: string;
|
||||
getter: (v: EmexVehicle) => string | null | undefined;
|
||||
}[] = [
|
||||
{ key: "engine", label: "Motor", getter: (v) => v.engine },
|
||||
{ key: "bodyType", label: "Kasa Tipi", getter: (v) => v.bodyType },
|
||||
{ key: "transmission", label: "Şanzıman", getter: (v) => v.transmission },
|
||||
{ key: "driveType", label: "Sürüş", getter: (v) => v.driveType },
|
||||
{ key: "fuelType", label: "Yakıt", getter: (v) => v.fuelType },
|
||||
{ key: "optionsRaw", label: "Varyant", getter: (v) => v.optionsRaw },
|
||||
];
|
||||
|
||||
// Collect distinct values per field
|
||||
const fieldVals = new Map<string, string[]>();
|
||||
for (const c of candidates) {
|
||||
const vals = distinctValues(vehicles, c.getter);
|
||||
if (vals.length >= 2) fieldVals.set(c.key, vals);
|
||||
}
|
||||
|
||||
const optRawVals = fieldVals.get("optionsRaw") || [];
|
||||
// Pre-compute optionsRaw values as lowercase set for containment check
|
||||
const optRawLower = new Set(
|
||||
vehicles
|
||||
.map((v) => v.optionsRaw?.toLowerCase())
|
||||
.filter((x): x is string => !!x),
|
||||
);
|
||||
|
||||
const defs: FilterDef[] = [];
|
||||
for (const c of candidates) {
|
||||
if (!fieldVals.has(c.key)) continue;
|
||||
const vals = fieldVals.get(c.key)!;
|
||||
const valSet = new Set(vals);
|
||||
|
||||
// Skip if this field's distinct values exactly match an already-added field
|
||||
// (e.g. engine="LHD/RHD" and driveType="LHD/RHD")
|
||||
const isDuplicateOfPrev = defs.some((d) => {
|
||||
const dVals = fieldVals.get(d.key) || [];
|
||||
return (
|
||||
dVals.length === vals.length && dVals.every((v) => valSet.has(v))
|
||||
);
|
||||
});
|
||||
if (isDuplicateOfPrev) continue;
|
||||
|
||||
// Skip optionsRaw if its values exactly match another already-added field
|
||||
if (c.key === "optionsRaw" && isDuplicateOfPrev) continue;
|
||||
|
||||
// Skip a field if every one of its values is a substring of the
|
||||
// corresponding vehicle's optionsRaw (e.g. driveType="LEFT" inside
|
||||
// optionsRaw="LEFT (GSX)"). The optionsRaw filter alone is enough.
|
||||
if (c.key !== "optionsRaw" && optRawVals.length >= 2) {
|
||||
const isRedundant = vehicles.every((v) => {
|
||||
const fv = c.getter(v);
|
||||
const ov = v.optionsRaw;
|
||||
if (!fv) return true; // no value → not contradicting
|
||||
if (!ov) return false; // field has value but no optionsRaw → keep field
|
||||
return ov.toLowerCase().includes(fv.toLowerCase());
|
||||
});
|
||||
if (isRedundant) continue;
|
||||
}
|
||||
|
||||
defs.push(c);
|
||||
}
|
||||
|
||||
return defs;
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────
|
||||
|
||||
@@ -171,23 +196,19 @@ function EmexVehicleListPage() {
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
// All possible filter definitions for this catalog
|
||||
const allFilterDefs = useMemo(
|
||||
() => (vehicles ? buildFilterDefs(vehicles, structured) : []),
|
||||
[vehicles, structured],
|
||||
);
|
||||
|
||||
// Distinct model names
|
||||
const modelNames = useMemo(
|
||||
() => (vehicles ? distinctValues(vehicles, (v) => v.name) : []),
|
||||
[vehicles],
|
||||
);
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
// Dynamic filter selections: { filterKey: selectedValue }
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
|
||||
// Vehicles matching selected model
|
||||
const modelVehicles = useMemo(
|
||||
() =>
|
||||
selectedModel && vehicles
|
||||
@@ -196,33 +217,25 @@ function EmexVehicleListPage() {
|
||||
[vehicles, selectedModel],
|
||||
);
|
||||
|
||||
// For each filter def, compute available options (cascading: each filter
|
||||
// narrows the pool for subsequent filters)
|
||||
// Cascading filter chain: compute options per filter, narrowing the pool
|
||||
const filterChain = useMemo(() => {
|
||||
const chain: {
|
||||
def: FilterDef;
|
||||
options: string[];
|
||||
}[] = [];
|
||||
|
||||
const chain: { def: FilterDef; options: string[] }[] = [];
|
||||
let pool = modelVehicles;
|
||||
|
||||
for (const def of allFilterDefs) {
|
||||
const opts = distinctValues(pool, def.getter);
|
||||
// Only show filter if there are >1 options
|
||||
if (opts.length > 1) {
|
||||
chain.push({ def, options: opts });
|
||||
}
|
||||
// If user selected a value for this filter, narrow the pool
|
||||
const selected = filters[def.key];
|
||||
if (selected) {
|
||||
pool = pool.filter((v) => def.getter(v) === selected);
|
||||
}
|
||||
}
|
||||
|
||||
return chain;
|
||||
}, [modelVehicles, allFilterDefs, filters]);
|
||||
|
||||
// Final filtered vehicles (apply all selected filters)
|
||||
// Final filtered list
|
||||
const filteredVehicles = useMemo(() => {
|
||||
let list = modelVehicles;
|
||||
for (const def of allFilterDefs) {
|
||||
@@ -241,7 +254,6 @@ function EmexVehicleListPage() {
|
||||
|
||||
const handleFilterChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
// Clear this filter and all downstream filters
|
||||
const idx = allFilterDefs.findIndex((d) => d.key === key);
|
||||
const newFilters: Record<string, string> = {};
|
||||
for (let i = 0; i < idx; i++) {
|
||||
@@ -266,11 +278,6 @@ function EmexVehicleListPage() {
|
||||
<h1 className="text-xl font-bold">
|
||||
{decodeURIComponent(catalogCode)}
|
||||
</h1>
|
||||
{vehicles && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({vehicles.length} {t("catalog.models").toLowerCase()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -287,7 +294,6 @@ function EmexVehicleListPage() {
|
||||
<>
|
||||
{/* Dropdown chain */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{/* Model — always visible */}
|
||||
<FilterSelect
|
||||
label={t("catalog.models")}
|
||||
placeholder={t("catalog.selectModel")}
|
||||
@@ -296,12 +302,11 @@ function EmexVehicleListPage() {
|
||||
onChange={handleModelChange}
|
||||
/>
|
||||
|
||||
{/* Dynamic filters — only shown after model selection */}
|
||||
{selectedModel &&
|
||||
filterChain.map(({ def, options }) => (
|
||||
<FilterSelect
|
||||
key={def.key}
|
||||
label={FILTER_LABELS[def.label] || def.label}
|
||||
label={def.label}
|
||||
placeholder={t("catalog.fordVariant.showAll")}
|
||||
value={filters[def.key] || ""}
|
||||
options={options}
|
||||
@@ -389,32 +394,40 @@ function VehicleRow({
|
||||
catalogCode: string;
|
||||
structured: boolean;
|
||||
}) {
|
||||
const opts = structured ? parseOptions(vehicle.optionsRaw) : null;
|
||||
|
||||
// Build descriptive subtitle
|
||||
const parts: string[] = [];
|
||||
if (opts) {
|
||||
|
||||
if (structured) {
|
||||
const opts = parseOptions(vehicle.optionsRaw);
|
||||
if (opts["Engine type and suffix"])
|
||||
parts.push(opts["Engine type and suffix"]);
|
||||
else if (opts["Engine type"]) parts.push(opts["Engine type"]);
|
||||
if (opts["Gearbox type"]) parts.push(opts["Gearbox type"]);
|
||||
if (opts["Vehicle type"]) parts.push(opts["Vehicle type"]);
|
||||
} else {
|
||||
if (vehicle.engine) parts.push(vehicle.engine);
|
||||
if (vehicle.bodyType) parts.push(vehicle.bodyType);
|
||||
if (vehicle.transmission) parts.push(vehicle.transmission);
|
||||
if (vehicle.driveType) parts.push(vehicle.driveType);
|
||||
if (vehicle.fuelType) parts.push(vehicle.fuelType);
|
||||
if (vehicle.optionsRaw) parts.push(vehicle.optionsRaw);
|
||||
// Use optionsRaw as primary description when available — it's the most
|
||||
// informative string in most catalogs (trim, engine spec, market etc.)
|
||||
if (vehicle.optionsRaw) {
|
||||
parts.push(vehicle.optionsRaw);
|
||||
}
|
||||
// Add fields not already contained in optionsRaw
|
||||
const raw = vehicle.optionsRaw?.toLowerCase() || "";
|
||||
if (vehicle.engine && !raw.includes(vehicle.engine.toLowerCase()))
|
||||
parts.push(vehicle.engine);
|
||||
if (vehicle.bodyType && !raw.includes(vehicle.bodyType.toLowerCase()))
|
||||
parts.push(vehicle.bodyType);
|
||||
if (
|
||||
vehicle.transmission &&
|
||||
!raw.includes(vehicle.transmission.toLowerCase())
|
||||
)
|
||||
parts.push(vehicle.transmission);
|
||||
if (vehicle.driveType && !raw.includes(vehicle.driveType.toLowerCase()))
|
||||
parts.push(vehicle.driveType);
|
||||
if (vehicle.fuelType && !raw.includes(vehicle.fuelType.toLowerCase()))
|
||||
parts.push(vehicle.fuelType);
|
||||
}
|
||||
|
||||
// Deduplicate (optionsRaw may repeat other fields)
|
||||
const seen = new Set<string>();
|
||||
const unique = parts.filter((p) => {
|
||||
if (seen.has(p)) return false;
|
||||
seen.add(p);
|
||||
return true;
|
||||
});
|
||||
// Fallback if nothing
|
||||
if (parts.length === 0 && vehicle.engine) parts.push(vehicle.engine);
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -424,8 +437,8 @@ function VehicleRow({
|
||||
>
|
||||
<Car className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{unique.length > 0 ? (
|
||||
<p className="truncate text-sm">{unique.join(" · ")}</p>
|
||||
{parts.length > 0 ? (
|
||||
<p className="truncate text-sm">{parts.join(" · ")}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{vehicle.vehicleId}</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user