feat(FN-368): parts panel loading state, inert unavailable rows, escape hatch (gitea #10)

Commits merged:
- fix(FN-368): parts panel loading state, inert unavailable rows, escape hatch (gitea #10)

Files changed:
.../schema/__tests__/parts-panel.test.tsx          | 151 ++++++++++++++
 apps/web/src/components/schema/parts-panel.tsx     | 230 ++++++++++++---------
 apps/web/src/components/schema/schema-viewer.tsx   |   7 +-
 3 files changed, 291 insertions(+), 97 deletions(-)

Fusion-Task-Id: FN-368
This commit is contained in:
Fusion
2026-05-14 18:43:12 +00:00
parent e274e02c23
commit 0ce68a7b99
3 changed files with 292 additions and 98 deletions

View File

@@ -0,0 +1,151 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Part } from "@/hooks/use-parts";
const captureMock = vi.fn();
const setSelectedGroupMock = vi.fn();
const setHighlightedGroupMock = vi.fn();
vi.mock("@/lib/posthog", () => ({
capture: (...args: unknown[]) => captureMock(...args),
}));
vi.mock("@/lib/api-client", () => ({
api: {
post: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
},
}));
vi.mock("@/stores/schema.store", () => ({
useSchemaStore: () => ({
highlightedGroup: null,
selectedGroup: null,
setHighlightedGroup: setHighlightedGroupMock,
setSelectedGroup: setSelectedGroupMock,
}),
}));
import { PartsPanel } from "../parts-panel";
const buildPart = (overrides: Partial<Part> = {}): Part => ({
id: overrides.id ?? "p1",
name: overrides.name ?? "Part 1",
oemCode: overrides.oemCode ?? "OEM-1",
quantity: overrides.quantity ?? 1,
position: overrides.position ?? "Front",
hotspotIndex: overrides.hotspotIndex ?? 1,
unavailable: overrides.unavailable,
remark: overrides.remark,
modelCodes: overrides.modelCodes,
presel: overrides.presel,
price: overrides.price,
currency: overrides.currency,
note: overrides.note,
});
beforeEach(() => {
captureMock.mockClear();
setSelectedGroupMock.mockClear();
setHighlightedGroupMock.mockClear();
});
afterEach(() => {
cleanup();
});
describe("PartsPanel", () => {
it("renders 8 skeleton rows and skips telemetry when isLoading", () => {
const { container } = render(<PartsPanel parts={[]} isLoading />);
const skeletons = container.querySelectorAll(".h-10.w-full");
expect(skeletons.length).toBe(8);
const rows = container.querySelectorAll('[data-faro-user-action-name="select-part"]');
expect(rows.length).toBe(0);
expect(captureMock).not.toHaveBeenCalled();
});
it("does not invoke store setters when an unavailable row is clicked", () => {
const parts: Part[] = [
buildPart({ id: "available", name: "Available Part", hotspotIndex: 1 }),
buildPart({
id: "unavailable",
name: "Unavailable Part",
hotspotIndex: 2,
unavailable: true,
}),
];
const { container } = render(<PartsPanel parts={parts} />);
const rows = container.querySelectorAll('[data-faro-user-action-name="select-part"]');
expect(rows.length).toBe(2);
const unavailableRow = rows[1] as HTMLElement;
fireEvent.click(unavailableRow);
fireEvent.mouseEnter(unavailableRow);
fireEvent.mouseLeave(unavailableRow);
expect(setSelectedGroupMock).not.toHaveBeenCalled();
expect(setHighlightedGroupMock).not.toHaveBeenCalled();
});
it("applies the correct ARIA and tabIndex to unavailable vs available rows", () => {
const parts: Part[] = [
buildPart({ id: "available", name: "Available Part", hotspotIndex: 1 }),
buildPart({
id: "unavailable",
name: "Unavailable Part",
hotspotIndex: 2,
unavailable: true,
}),
];
const { container } = render(<PartsPanel parts={parts} />);
const rows = container.querySelectorAll('[data-faro-user-action-name="select-part"]');
const availableRow = rows[0] as HTMLElement;
const unavailableRow = rows[1] as HTMLElement;
expect(unavailableRow.getAttribute("aria-disabled")).toBe("true");
expect(unavailableRow.getAttribute("tabindex")).toBe("-1");
expect(availableRow.getAttribute("tabindex")).toBe("0");
expect(availableRow.getAttribute("aria-disabled")).toBeNull();
});
it("renders a Geri don button in the empty state that invokes window.history.back", () => {
const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {});
render(<PartsPanel parts={[]} />);
const button = screen.getByRole("button", { name: "Geri don" });
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(backSpy).toHaveBeenCalledTimes(1);
backSpy.mockRestore();
});
it("gates parts_panel_viewed on isLoading and fires once after load", () => {
const parts: Part[] = [
buildPart({ id: "a", hotspotIndex: 1 }),
buildPart({ id: "b", hotspotIndex: 2 }),
];
const { rerender } = render(
<PartsPanel parts={parts} vehicleId="v1" categoryId="c1" isLoading />,
);
expect(captureMock).not.toHaveBeenCalled();
rerender(<PartsPanel parts={parts} vehicleId="v1" categoryId="c1" isLoading={false} />);
expect(captureMock).toHaveBeenCalledTimes(1);
const [eventName, payload] = captureMock.mock.calls[0];
expect(eventName).toBe("parts_panel_viewed");
expect(payload).toMatchObject({
vehicle_id: "v1",
category_id: "c1",
parts_count: parts.length,
});
});
});

View File

@@ -2,7 +2,7 @@ import type { Part } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { capture } from "@/lib/posthog";
import { useSchemaStore } from "@/stores/schema.store";
import { cn } from "@sase/ui";
import { Button, Skeleton, cn } from "@sase/ui";
import { Check, Copy } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -10,9 +10,12 @@ interface PartsPanelProps {
parts: Part[];
vehicleId?: string;
categoryId?: string;
isLoading?: boolean;
}
export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
const SKELETON_ROW_KEYS = ["s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7"] as const;
export function PartsPanel({ parts, vehicleId, categoryId, isLoading }: PartsPanelProps) {
const { highlightedGroup, selectedGroup, setHighlightedGroup, setSelectedGroup } =
useSchemaStore();
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
@@ -22,6 +25,7 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
const hasPrices = parts.some((p) => p.price != null);
useEffect(() => {
if (isLoading) return;
const key = `${vehicleId ?? ""}|${categoryId ?? ""}`;
if (viewedKeyRef.current === key) return;
viewedKeyRef.current = key;
@@ -34,7 +38,7 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
parts.filter((p) => p.hotspotIndex != null && !p.unavailable).map((p) => p.hotspotIndex),
).size,
});
}, [vehicleId, categoryId, parts.length, hasPrices, parts]);
}, [vehicleId, categoryId, parts.length, hasPrices, parts, isLoading]);
// Map group → IDs of available (non-unavailable) parts
const availableByGroup = useMemo(() => {
@@ -84,6 +88,24 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
}
}, [selectedGroup]);
if (isLoading) {
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">Parcalar</h3>
<p className="text-xs text-muted-foreground">Yukleniyor...</p>
</div>
<div className="flex-1 overflow-y-auto">
<div className="space-y-2 p-3">
{SKELETON_ROW_KEYS.map((k) => (
<Skeleton key={k} className="h-10 w-full" />
))}
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b border-border px-4 py-3">
@@ -92,106 +114,122 @@ export function PartsPanel({ parts, vehicleId, categoryId }: PartsPanelProps) {
</div>
<div className="flex-1 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
<th className="px-3 py-2 w-10">#</th>
<th className="px-3 py-2">Parca Adi</th>
<th className="px-3 py-2">OEM Kodu</th>
<th className="px-3 py-2 w-14 text-center">Adet</th>
<th className="px-3 py-2">Pozisyon</th>
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
</tr>
</thead>
<tbody>
{parts.map((part) => {
const group = part.hotspotIndex;
const isHighlighted = group != null && highlightedGroup === group;
const isSelected = group != null && selectedGroup === group;
{parts.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-4 py-12 text-center text-sm text-muted-foreground">
<p>Bu kategori icin parca bulunamadi.</p>
<Button type="button" variant="outline" onClick={() => window.history.back()}>
Geri don
</Button>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
<th className="px-3 py-2 w-10">#</th>
<th className="px-3 py-2">Parca Adi</th>
<th className="px-3 py-2">OEM Kodu</th>
<th className="px-3 py-2 w-14 text-center">Adet</th>
<th className="px-3 py-2">Pozisyon</th>
{hasPrices && <th className="px-3 py-2 text-right">Fiyat</th>}
</tr>
</thead>
<tbody>
{parts.map((part) => {
const group = part.hotspotIndex;
const isHighlighted = group != null && highlightedGroup === group;
const isSelected = group != null && selectedGroup === group;
const isUnavailable = part.unavailable === true;
return (
<tr
key={part.id}
data-faro-user-action-name="select-part"
ref={(el) => {
if (group == null || !el) return;
const availableIds = availableByGroup.get(group);
if (availableIds?.length === 1) {
// Single available part — scroll directly to it
if (part.id === availableIds[0]) {
return (
<tr
key={part.id}
data-faro-user-action-name="select-part"
aria-disabled={isUnavailable ? "true" : undefined}
role={isUnavailable ? undefined : "button"}
tabIndex={isUnavailable ? -1 : 0}
ref={(el) => {
if (group == null || !el) return;
const availableIds = availableByGroup.get(group);
if (availableIds?.length === 1) {
// Single available part — scroll directly to it
if (part.id === availableIds[0]) {
rowRefs.current.set(group, el);
}
} else if (!rowRefs.current.has(group)) {
// Multiple or zero available — first part in group
rowRefs.current.set(group, el);
}
} else if (!rowRefs.current.has(group)) {
// Multiple or zero available — first part in group
rowRefs.current.set(group, el);
}
}}
className={cn(
"cursor-pointer border-b border-border/50 transition-colors duration-150",
part.unavailable && "opacity-40",
isSelected && "bg-primary/10 ring-1 ring-inset ring-primary/20",
isHighlighted && !isSelected && "bg-accent",
!isSelected && !isHighlighted && "hover:bg-accent/50",
)}
onMouseEnter={() => setHighlightedGroup(group)}
onMouseLeave={() => setHighlightedGroup(null)}
onClick={() => setSelectedGroup(selectedGroup === group ? null : group)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedGroup(selectedGroup === group ? null : group);
}
}}
>
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
<td className="px-3 py-2">
<span className="font-medium">{part.name}</span>
{(part.remark || part.modelCodes) && (
<span className="block text-xs text-muted-foreground">
{[part.remark, part.modelCodes].filter(Boolean).join(" · ")}
</span>
}}
className={cn(
"border-b border-border/50 transition-colors duration-150",
!isUnavailable && "cursor-pointer",
isUnavailable && "opacity-40",
isSelected && "bg-primary/10 ring-1 ring-inset ring-primary/20",
isHighlighted && !isSelected && "bg-accent",
!isUnavailable && !isSelected && !isHighlighted && "hover:bg-accent/50",
)}
</td>
<td className="px-3 py-2 font-mono text-xs">
<span className="inline-flex items-center gap-1">
{part.oemCode && (
<button
type="button"
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => copyOemCode(e, part.id, part.oemCode)}
>
{copiedId === part.id ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</button>
onMouseEnter={isUnavailable ? undefined : () => setHighlightedGroup(group)}
onMouseLeave={isUnavailable ? undefined : () => setHighlightedGroup(null)}
onClick={
isUnavailable
? undefined
: () => setSelectedGroup(selectedGroup === group ? null : group)
}
onKeyDown={
isUnavailable
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedGroup(selectedGroup === group ? null : group);
}
}
}
>
<td className="px-3 py-2 text-muted-foreground">{part.hotspotIndex}</td>
<td className="px-3 py-2">
<span className="font-medium">{part.name}</span>
{(part.remark || part.modelCodes) && (
<span className="block text-xs text-muted-foreground">
{[part.remark, part.modelCodes].filter(Boolean).join(" · ")}
</span>
)}
{part.oemCode}
</span>
</td>
<td className="px-3 py-2 text-center">{part.quantity}</td>
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
{hasPrices && (
<td className="px-3 py-2 text-right text-xs">
{part.price != null
? new Intl.NumberFormat("de-DE", {
style: "currency",
currency: part.currency ?? "EUR",
}).format(part.price)
: "—"}
</td>
)}
</tr>
);
})}
</tbody>
</table>
{parts.length === 0 && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Bu kategori icin parca bulunamadi.
</div>
<td className="px-3 py-2 font-mono text-xs">
<span className="inline-flex items-center gap-1">
{part.oemCode && (
<button
type="button"
className="inline-flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(e) => copyOemCode(e, part.id, part.oemCode)}
>
{copiedId === part.id ? (
<Check className="size-3.5 text-green-500" />
) : (
<Copy className="size-3.5" />
)}
</button>
)}
{part.oemCode}
</span>
</td>
<td className="px-3 py-2 text-center">{part.quantity}</td>
<td className="px-3 py-2 text-muted-foreground">{part.position}</td>
{hasPrices && (
<td className="px-3 py-2 text-right text-xs">
{part.price != null
? new Intl.NumberFormat("de-DE", {
style: "currency",
currency: part.currency ?? "EUR",
}).format(part.price)
: "—"}
</td>
)}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>

View File

@@ -149,7 +149,12 @@ export function SchemaViewer({
{/* Right side: Parts panel (40%) */}
<div className="max-h-[500px] w-full md:max-h-none md:w-[40%]">
<PartsPanel parts={parts} vehicleId={vehicleId} categoryId={categoryId} />
<PartsPanel
parts={parts}
vehicleId={vehicleId}
categoryId={categoryId}
isLoading={isLoading}
/>
</div>
</div>
);