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)
|
||||
|
||||
Reference in New Issue
Block a user