feat(web): unify vehicle drill-down breadcrumb & back navigation
The vehicle category flow had two competing navigation models: the grid drilled in place (no URL change, own breadcrumb) while the category route had a separate breadcrumb reconstructed from the cached tree plus a back button with different semantics. The route breadcrumb collapsed on refresh/deep-link and for lazily-fetched deep nodes, and browser-back from an in-grid drill ejected the user out of the whole flow. Make the URL + a server-provided ancestor trail the single source of truth: - API: getCategoryWithParts now returns `ancestors[]` (root→parent) via a recursive CTE, so breadcrumbs are complete regardless of client cache. - New shared <CategoryBreadcrumb> (Arama → vehicle → ancestors → current) used by both the vehicle and category pages. - Category page back button derives the parent from `ancestors` (no more cache-path race); cache reconstruction (findCategoryPath) removed. - CategoryGrid drills via route navigation per level (each level a URL + history entry), seeding the query cache for instant render. Browser-back now goes up one level and deep levels are shareable/refreshable. - Tree & Columns intentionally keep their in-place paradigm. - a11y: aria-labels on back buttons and the breadcrumb nav. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
@@ -7,10 +8,10 @@ import { api } from "@/lib/api-client";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { cleanModelName } from "@/lib/vehicle";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, ChevronRight } from "lucide-react";
|
||||
import { Fragment, Suspense, lazy, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
|
||||
import { KEYS_6, KEYS_8 } from "@/lib/keys";
|
||||
const SchemaViewer = lazy(() =>
|
||||
@@ -49,31 +50,9 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/categories_/$cate
|
||||
component: VehicleCategoryPage,
|
||||
});
|
||||
|
||||
interface CategoryTreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: CategoryTreeNode[];
|
||||
}
|
||||
|
||||
function findCategoryPath(
|
||||
nodes: CategoryTreeNode[] | undefined,
|
||||
targetId: string,
|
||||
trail: CategoryTreeNode[] = [],
|
||||
): CategoryTreeNode[] | null {
|
||||
if (!nodes) return null;
|
||||
for (const node of nodes) {
|
||||
const next = [...trail, node];
|
||||
if (node.id === targetId) return next;
|
||||
const found = findCategoryPath(node.children, targetId, next);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function VehicleCategoryPage() {
|
||||
const { id, categoryId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, error, refetch, isFetching } = useCategoryParts(id, categoryId);
|
||||
|
||||
// Vehicle for breadcrumb root — cached if user arrived from /dashboard/vehicles/$id
|
||||
@@ -86,12 +65,6 @@ function VehicleCategoryPage() {
|
||||
|
||||
const hasChildren = data?.children && data.children.length > 0;
|
||||
|
||||
// Resolve breadcrumb trail from the cached category tree (if available)
|
||||
const breadcrumbTrail = useMemo(() => {
|
||||
const tree = queryClient.getQueryData<CategoryTreeNode[]>(["category-tree", id]);
|
||||
return findCategoryPath(tree, categoryId) ?? [];
|
||||
}, [queryClient, id, categoryId]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||
() => getUserSettings().categoryViewMode ?? "grid",
|
||||
);
|
||||
@@ -101,23 +74,16 @@ function VehicleCategoryPage() {
|
||||
setUserSetting("categoryViewMode", mode);
|
||||
};
|
||||
|
||||
// Prefer the breadcrumb parent (works even before `data` loads). Fall back to
|
||||
// data.parentId, then to the vehicle page.
|
||||
const parentFromTrail =
|
||||
breadcrumbTrail.length >= 2 ? breadcrumbTrail[breadcrumbTrail.length - 2] : null;
|
||||
|
||||
// Back = one level up the hierarchy, derived from the server ancestor trail
|
||||
// (reliable on refresh/deep-link). The last ancestor is the direct parent;
|
||||
// when there is none we're at the top category, so return to the vehicle.
|
||||
const handleBack = () => {
|
||||
if (parentFromTrail) {
|
||||
const ancestors = data?.ancestors ?? [];
|
||||
const parentId = ancestors.at(-1)?.id ?? data?.parentId ?? null;
|
||||
if (parentId) {
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: { id, categoryId: parentFromTrail.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data?.parentId) {
|
||||
navigate({
|
||||
to: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: { id, categoryId: data.parentId },
|
||||
params: { id, categoryId: parentId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -129,46 +95,14 @@ function VehicleCategoryPage() {
|
||||
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
|
||||
: "Araç";
|
||||
|
||||
// Breadcrumb segments excluding the current page (last item rendered as text below)
|
||||
const trailWithoutCurrent = breadcrumbTrail.slice(0, -1);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Breadcrumb */}
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<Link
|
||||
to="/dashboard/vehicles/$id"
|
||||
params={{ id }}
|
||||
data-faro-user-action-name="breadcrumb-vehicle"
|
||||
className="truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{vehicleLabel}
|
||||
</Link>
|
||||
{trailWithoutCurrent.map((node) => (
|
||||
<Fragment key={node.id}>
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
<Link
|
||||
to="/dashboard/vehicles/$id/categories/$categoryId"
|
||||
params={{ id, categoryId: node.id }}
|
||||
data-faro-user-action-name="breadcrumb-category"
|
||||
className="max-w-[180px] truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{node.name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
{(breadcrumbTrail.length > 0 || data?.name) && (
|
||||
<>
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
<span aria-current="page" className="truncate px-1 text-foreground">
|
||||
{data?.name ?? breadcrumbTrail[breadcrumbTrail.length - 1]?.name ?? "…"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
<CategoryBreadcrumb
|
||||
vehicleId={id}
|
||||
vehicleLabel={vehicleLabel}
|
||||
ancestors={data?.ancestors ?? []}
|
||||
currentName={data?.name ?? (isLoading ? "…" : undefined)}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -178,6 +112,7 @@ function VehicleCategoryPage() {
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
aria-label="Geri dön"
|
||||
data-faro-user-action-name="category-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CategoryBreadcrumb } from "@/components/categories/category-breadcrumb";
|
||||
import { CategoryColumns } from "@/components/categories/category-columns";
|
||||
import { CategoryGrid } from "@/components/categories/category-grid";
|
||||
import { CategoryTree } from "@/components/categories/category-tree";
|
||||
@@ -72,6 +73,10 @@ function VehicleDetailPage() {
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const vehicleLabel = vehicle?.brandName
|
||||
? `${vehicle.brandName}${cleanModelName(vehicle?.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
|
||||
: "Araç";
|
||||
|
||||
if (vehicleLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -112,6 +117,7 @@ function VehicleDetailPage() {
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
aria-label="Geri dön"
|
||||
data-faro-user-action-name="vehicle-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -158,6 +164,8 @@ function VehicleDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<CategoryBreadcrumb vehicleId={id} vehicleLabel={vehicleLabel} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
@@ -165,6 +173,7 @@ function VehicleDetailPage() {
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
aria-label="Geri dön"
|
||||
data-faro-user-action-name="vehicle-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
Reference in New Issue
Block a user