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:
151
apps/web/src/components/schema/__tests__/parts-panel.test.tsx
Normal file
151
apps/web/src/components/schema/__tests__/parts-panel.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user