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:
@@ -182,6 +182,8 @@ describe("CategoriesService", () => {
|
||||
c.limit = vi.fn().mockReturnValue([category]);
|
||||
return c;
|
||||
}),
|
||||
// getAncestors runs a raw recursive CTE via db.execute.
|
||||
execute: vi.fn().mockResolvedValue([{ id: "root", name: "Kök", depth: 1 }]),
|
||||
};
|
||||
|
||||
const { service } = createService(db);
|
||||
@@ -196,6 +198,33 @@ describe("CategoriesService", () => {
|
||||
expect(partsCatalogsService.fetchParts).not.toHaveBeenCalled();
|
||||
expect(result.parts).toEqual([]);
|
||||
expect(result.children).toEqual(discovered);
|
||||
// The wrapper attaches the server-resolved ancestor trail (id+name only).
|
||||
expect(result.ancestors).toEqual([{ id: "root", name: "Kök" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAncestors", () => {
|
||||
it("maps recursive-CTE rows to {id,name}, dropping the depth column", async () => {
|
||||
const rows = [
|
||||
{ id: "root", name: "Motor", depth: 2 },
|
||||
{ id: "mid", name: "Silindir kapağı", depth: 1 },
|
||||
];
|
||||
const db = { execute: vi.fn().mockResolvedValue(rows) };
|
||||
const { service } = createService(db);
|
||||
|
||||
const result = await service.getAncestors("leaf");
|
||||
expect(result).toEqual([
|
||||
{ id: "root", name: "Motor" },
|
||||
{ id: "mid", name: "Silindir kapağı" },
|
||||
]);
|
||||
expect(db.execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns an empty trail for a root category", async () => {
|
||||
const db = { execute: vi.fn().mockResolvedValue([]) };
|
||||
const { service } = createService(db);
|
||||
|
||||
expect(await service.getAncestors("root")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -593,7 +593,37 @@ export class CategoriesService {
|
||||
* If the category is a parent (has children), returns children instead.
|
||||
* If it's a leaf with a BOM linkPath, fetches parts from PL24 on-demand.
|
||||
*/
|
||||
/**
|
||||
* Walk the parent_id chain to the root, returning the ancestor trail in
|
||||
* root→parent order (the node itself excluded). Single recursive query;
|
||||
* parent_id is indexed and the tree is shallow. This is the authoritative
|
||||
* source for breadcrumbs — independent of any client-side tree cache, so it
|
||||
* stays correct on refresh, deep links, and lazily-fetched deep nodes.
|
||||
*/
|
||||
async getAncestors(categoryId: string): Promise<Array<{ id: string; name: string }>> {
|
||||
const rows = await this.db.execute<{ id: string; name: string; depth: number }>(sql`
|
||||
WITH RECURSIVE ancestry AS (
|
||||
SELECT id, name, parent_id, 0 AS depth
|
||||
FROM categories WHERE id = ${categoryId}
|
||||
UNION ALL
|
||||
SELECT c.id, c.name, c.parent_id, a.depth + 1
|
||||
FROM categories c
|
||||
JOIN ancestry a ON c.id = a.parent_id
|
||||
)
|
||||
SELECT id, name, depth FROM ancestry WHERE depth > 0 ORDER BY depth DESC
|
||||
`);
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }));
|
||||
}
|
||||
|
||||
async getCategoryWithParts(categoryId: string) {
|
||||
const result = await this.getCategoryWithPartsInner(categoryId);
|
||||
// Attach the full ancestor trail so the client can render a complete,
|
||||
// reliable breadcrumb regardless of what's in its tree cache.
|
||||
const ancestors = await this.getAncestors(categoryId);
|
||||
return { ...result, ancestors };
|
||||
}
|
||||
|
||||
private async getCategoryWithPartsInner(categoryId: string) {
|
||||
const [category] = await this.db
|
||||
.select()
|
||||
.from(categories)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Render TanStack Link as a plain anchor; `to` becomes the href so we can assert
|
||||
// the route target. `params` is stripped to avoid invalid-DOM-attr noise.
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children, to, params: _params, ...props }: any) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { CategoryBreadcrumb } from "../category-breadcrumb";
|
||||
|
||||
describe("CategoryBreadcrumb", () => {
|
||||
it("renders the full trail: Arama → vehicle → ancestors → current", () => {
|
||||
render(
|
||||
<CategoryBreadcrumb
|
||||
vehicleId="v1"
|
||||
vehicleLabel="BMW 320i"
|
||||
ancestors={[
|
||||
{ id: "a1", name: "Motor" },
|
||||
{ id: "a2", name: "Silindir kapağı" },
|
||||
]}
|
||||
currentName="Conta"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Arama")).toBeInTheDocument();
|
||||
expect(screen.getByText("BMW 320i")).toBeInTheDocument();
|
||||
expect(screen.getByText("Motor")).toBeInTheDocument();
|
||||
expect(screen.getByText("Silindir kapağı")).toBeInTheDocument();
|
||||
|
||||
const current = screen.getByText("Conta");
|
||||
expect(current).toHaveAttribute("aria-current", "page");
|
||||
expect(current.closest("a")).toBeNull(); // current is plain text, not a link
|
||||
});
|
||||
|
||||
it("makes the vehicle the terminal crumb on the vehicle page (no category context)", () => {
|
||||
render(<CategoryBreadcrumb vehicleId="v1" vehicleLabel="BMW 320i" />);
|
||||
|
||||
expect(screen.getByText("Arama")).toBeInTheDocument();
|
||||
const vehicle = screen.getByText("BMW 320i");
|
||||
expect(vehicle).toHaveAttribute("aria-current", "page");
|
||||
expect(vehicle.closest("a")).toBeNull();
|
||||
expect(screen.queryByText("Motor")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("links Arama, the vehicle, and ancestors to their routes", () => {
|
||||
render(
|
||||
<CategoryBreadcrumb
|
||||
vehicleId="v1"
|
||||
vehicleLabel="BMW 320i"
|
||||
ancestors={[{ id: "a1", name: "Motor" }]}
|
||||
currentName="Conta"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Arama").closest("a")).toHaveAttribute("href", "/dashboard/search");
|
||||
// With category context the vehicle is a link, not the current crumb.
|
||||
expect(screen.getByText("BMW 320i").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/dashboard/vehicles/$id",
|
||||
);
|
||||
expect(screen.getByText("Motor").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/dashboard/vehicles/$id/categories/$categoryId",
|
||||
);
|
||||
});
|
||||
});
|
||||
88
apps/web/src/components/categories/category-breadcrumb.tsx
Normal file
88
apps/web/src/components/categories/category-breadcrumb.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
interface CategoryBreadcrumbProps {
|
||||
vehicleId: string;
|
||||
/** Display label for the vehicle root crumb (e.g. "BMW 320i"). */
|
||||
vehicleLabel: string;
|
||||
/** Ancestor trail in root→parent order (current node excluded). */
|
||||
ancestors?: Array<{ id: string; name: string }>;
|
||||
/**
|
||||
* Name of the current category. When omitted, the vehicle itself is the
|
||||
* terminal crumb — used on the vehicle detail page.
|
||||
*/
|
||||
currentName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single breadcrumb for the vehicle drill-down flow. The trail is driven by
|
||||
* the server-provided `ancestors`, so it stays complete and correct regardless
|
||||
* of the client tree cache (refresh, deep link, lazily-fetched deep nodes).
|
||||
*
|
||||
* Chain: Arama → [Vehicle] → …ancestors → current
|
||||
*/
|
||||
export function CategoryBreadcrumb({
|
||||
vehicleId,
|
||||
vehicleLabel,
|
||||
ancestors = [],
|
||||
currentName,
|
||||
}: CategoryBreadcrumbProps) {
|
||||
// The vehicle is the terminal crumb only when we're on the vehicle page
|
||||
// itself (no category context at all).
|
||||
const vehicleIsCurrent = !currentName && ancestors.length === 0;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<Link
|
||||
to="/dashboard/search"
|
||||
data-faro-user-action-name="breadcrumb-search"
|
||||
className="rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Arama
|
||||
</Link>
|
||||
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
{vehicleIsCurrent ? (
|
||||
<span aria-current="page" className="max-w-[220px] truncate px-1 text-foreground">
|
||||
{vehicleLabel}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
to="/dashboard/vehicles/$id"
|
||||
params={{ id: vehicleId }}
|
||||
data-faro-user-action-name="breadcrumb-vehicle"
|
||||
className="max-w-[220px] truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{vehicleLabel}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{ancestors.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: vehicleId, 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>
|
||||
))}
|
||||
|
||||
{currentName && (
|
||||
<>
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
<span aria-current="page" className="max-w-[220px] truncate px-1 text-foreground">
|
||||
{currentName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Card, CardContent, Input } from "@sase/ui";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, Loader2, Search, X } from "lucide-react";
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Category {
|
||||
id: string;
|
||||
@@ -18,12 +18,6 @@ interface Category {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface StackEntry {
|
||||
id: string | null;
|
||||
name: string;
|
||||
categories: Category[];
|
||||
}
|
||||
|
||||
interface CategoryGridProps {
|
||||
categories: Category[];
|
||||
vehicleId: string;
|
||||
@@ -45,49 +39,33 @@ export function CategoryGrid({
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [stack, setStack] = useState<StackEntry[]>([
|
||||
{ id: parentId ?? null, name: t("categories.root"), categories },
|
||||
]);
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
const [prefetchingIds, setPrefetchingIds] = useState<Set<string>>(new Set());
|
||||
const [imageOverrides, setImageOverrides] = useState<Map<string, string>>(new Map());
|
||||
const prefetchedRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Reset stack when input categories prop changes (parent navigation).
|
||||
// `t` is intentionally excluded: useTranslation returns a fresh function each
|
||||
// render, so including it would re-fire the effect on every render and reset
|
||||
// the stack immediately after handleSelect pushes a child level — making
|
||||
// parent-category clicks appear to do nothing.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: t identity is unstable; key is static
|
||||
useEffect(() => {
|
||||
setStack([{ id: parentId ?? null, name: t("categories.root"), categories }]);
|
||||
}, [categories, parentId]);
|
||||
|
||||
const current = stack[stack.length - 1];
|
||||
const currentCategories = current.categories;
|
||||
|
||||
// Filter the current drill level by name — deep levels (e.g. Elektrik has 62
|
||||
// Filter the current level by name — deep levels (e.g. Elektrik has 62
|
||||
// children) are hard to scan otherwise. Turkish-aware lowercasing for İ/ı.
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
// Reset the filter whenever the visible level changes (drill in/out).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on level change is intentional — current is the level identity
|
||||
// Reset the filter whenever the rendered level changes — route navigation
|
||||
// swaps the `categories` prop, so that is the level identity now.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run on level change is intentional
|
||||
useEffect(() => {
|
||||
setFilter("");
|
||||
}, [current]);
|
||||
}, [categories]);
|
||||
|
||||
const trimmedFilter = filter.trim().toLocaleLowerCase("tr");
|
||||
const visibleCategories = trimmedFilter
|
||||
? currentCategories.filter((c) => c.name.toLocaleLowerCase("tr").includes(trimmedFilter))
|
||||
: currentCategories;
|
||||
const showFilter = currentCategories.length > 8;
|
||||
? categories.filter((c) => c.name.toLocaleLowerCase("tr").includes(trimmedFilter))
|
||||
: categories;
|
||||
const showFilter = categories.length > 8;
|
||||
|
||||
// Prefetch schema images for leaf categories in batches of 2
|
||||
useEffect(() => {
|
||||
prefetchedRef.current.clear();
|
||||
setImageOverrides(new Map());
|
||||
|
||||
const leafsWithoutImage = currentCategories.filter(
|
||||
const leafsWithoutImage = categories.filter(
|
||||
(c) =>
|
||||
c.children !== undefined &&
|
||||
c.children.length === 0 &&
|
||||
@@ -100,7 +78,7 @@ export function CategoryGrid({
|
||||
return;
|
||||
}
|
||||
|
||||
const firstParentId = currentCategories[0]?.parentId;
|
||||
const firstParentId = categories[0]?.parentId;
|
||||
let didCancel = false;
|
||||
|
||||
const BATCH_SIZE = 2;
|
||||
@@ -137,53 +115,38 @@ export function CategoryGrid({
|
||||
return () => {
|
||||
didCancel = true;
|
||||
};
|
||||
}, [currentCategories, vehicleId]);
|
||||
}, [categories, vehicleId]);
|
||||
|
||||
const navigateToLeaf = useCallback(
|
||||
(categoryId: string) => {
|
||||
// Every drill — parent or leaf — is a route navigation, so each level has its
|
||||
// own URL and history entry: browser-back goes up exactly one level, and deep
|
||||
// levels are shareable/refreshable. For known parents we seed the destination
|
||||
// query cache so children render instantly while the full payload (parts,
|
||||
// ancestors) refetches in the background.
|
||||
const navigateToCategory = useCallback(
|
||||
(category: Category) => {
|
||||
if (!catalogMode && category.children && category.children.length > 0) {
|
||||
queryClient.setQueryData(["category-parts", vehicleId, category.id], {
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: null,
|
||||
parentId: category.parentId ?? parentId ?? null,
|
||||
parts: [],
|
||||
schemaPics: [],
|
||||
hotspots: [],
|
||||
children: category.children,
|
||||
});
|
||||
}
|
||||
navigate({
|
||||
to: catalogMode
|
||||
? "/dashboard/catalog/$brandName/$modelId/categories/$categoryId"
|
||||
: "/dashboard/vehicles/$id/categories/$categoryId",
|
||||
params: catalogMode
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId }
|
||||
: { id: vehicleId, categoryId },
|
||||
? { brandName: brandName ?? vehicleId, modelId: vehicleId, categoryId: category.id }
|
||||
: { id: vehicleId, categoryId: category.id },
|
||||
search: (catalogMode && variantSearch ? variantSearch : undefined) as any,
|
||||
});
|
||||
},
|
||||
[navigate, catalogMode, brandName, vehicleId, variantSearch],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (category: Category) => {
|
||||
const known = category.children;
|
||||
if (known && known.length > 0) {
|
||||
setStack((s) => [...s, { id: category.id, name: category.name, categories: known }]);
|
||||
return;
|
||||
}
|
||||
if (category.children !== undefined && category.children.length === 0) {
|
||||
navigateToLeaf(category.id);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingId(category.id);
|
||||
try {
|
||||
const data = await queryClient.fetchQuery({
|
||||
queryKey: ["category-children", category.id],
|
||||
queryFn: () => api.get<Category[]>(`/categories/${category.id}/children`),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const children = data ?? [];
|
||||
if (children.length === 0) {
|
||||
navigateToLeaf(category.id);
|
||||
return;
|
||||
}
|
||||
setStack((s) => [...s, { id: category.id, name: category.name, categories: children }]);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
},
|
||||
[queryClient, navigateToLeaf],
|
||||
[navigate, queryClient, catalogMode, brandName, vehicleId, parentId, variantSearch],
|
||||
);
|
||||
|
||||
if (!categories || categories.length === 0) {
|
||||
@@ -194,23 +157,6 @@ export function CategoryGrid({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{stack.length > 1 && (
|
||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground mb-3">
|
||||
{stack.map((s, i) => (
|
||||
<Fragment key={s.id ?? "root"}>
|
||||
{i > 0 && <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStack((prev) => prev.slice(0, i + 1))}
|
||||
className="hover:text-foreground hover:underline truncate max-w-[200px] disabled:hover:no-underline"
|
||||
disabled={i === stack.length - 1}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
</Fragment>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
{showFilter && (
|
||||
<div className="relative mb-3 max-w-xs">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -244,14 +190,13 @@ export function CategoryGrid({
|
||||
const Icon = getCategoryIcon(category.name);
|
||||
const isLeaf = category.children !== undefined && category.children.length === 0;
|
||||
const schemaImageUrl = imageOverrides.get(category.id) || category.schemaImageUrl;
|
||||
const isSelectLoading = loadingId === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(category)}
|
||||
disabled={category.unavailable || isSelectLoading}
|
||||
onClick={() => navigateToCategory(category)}
|
||||
disabled={category.unavailable}
|
||||
className={`w-full rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${category.unavailable ? "opacity-40" : ""}`}
|
||||
>
|
||||
<CategoryCard
|
||||
@@ -260,7 +205,7 @@ export function CategoryGrid({
|
||||
Icon={Icon}
|
||||
schemaImageUrl={schemaImageUrl}
|
||||
isLeaf={isLeaf}
|
||||
isLoading={prefetchingIds.has(category.id) || isSelectLoading}
|
||||
isLoading={prefetchingIds.has(category.id)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,9 @@ export interface CategorySchema {
|
||||
parts: Part[];
|
||||
schemaPics: SchemaPic[];
|
||||
hotspots: Hotspot[];
|
||||
/** Ancestor trail in root→parent order (current node excluded). Authoritative
|
||||
* breadcrumb source from the server — reliable on refresh/deep-link. */
|
||||
ancestors?: Array<{ id: string; name: string }>;
|
||||
/** True when a source (e.g. PL24) fetch failed and the empty lists are a
|
||||
* load error rather than a genuinely empty category — UI should offer retry. */
|
||||
loadError?: boolean;
|
||||
|
||||
@@ -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