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:
2026-05-25 22:55:01 +03:00
parent 9fc222d525
commit 001ad1986c
8 changed files with 286 additions and 176 deletions

View File

@@ -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([]);
});
});

View File

@@ -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)