refactor(pwa): extract shared WorksPickerModal from Create/EditRepairPage

The two repair forms genuinely diverged in UX (inline price-unlock + photos
vs modal override, no photos), so a single merged form would be worse. The
real duplication was the works-catalog picker modal — extracted into one
tested presentational component, removing ~75 duplicated lines.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-06-05 17:32:11 +03:00
co-authored by claude-flow
parent fab8b4496b
commit b2969f36c3
4 changed files with 135 additions and 81 deletions
@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import WorksPickerModal from "@/components/repairs/WorksPickerModal";
import type { MechWork } from "@/api/repairsCreate";
const works: MechWork[] = [
{ id: 1, name: "Замена масла", category_id: 1, price: 1500, norma_hours: null },
{ id: 2, name: "Развал-схождение", category_id: 1, price: "2000", norma_hours: null },
];
test("рендерит работы с ценой и зовёт onPick по клику", async () => {
const user = userEvent.setup();
const onPick = vi.fn();
render(
<WorksPickerModal query="" results={works} onQuery={() => {}} onPick={onPick} onClose={() => {}} />,
);
expect(screen.getByText("Замена масла")).toBeInTheDocument();
expect(screen.getByText("1 500 ₽")).toBeInTheDocument(); // число из 1500
expect(screen.getByText("2 000 ₽")).toBeInTheDocument(); // строка "2000" → число
await user.click(screen.getByText("Развал-схождение"));
expect(onPick).toHaveBeenCalledWith(works[1]);
});
test("пустой список → «Ничего не найдено»", () => {
render(
<WorksPickerModal query="x" results={[]} onQuery={() => {}} onPick={() => {}} onClose={() => {}} />,
);
expect(screen.getByText("Ничего не найдено")).toBeInTheDocument();
});
test("ввод в поиск зовёт onQuery; кнопка назад — onClose", async () => {
const user = userEvent.setup();
const onQuery = vi.fn();
const onClose = vi.fn();
render(
<WorksPickerModal query="" results={[]} onQuery={onQuery} onPick={() => {}} onClose={onClose} />,
);
await user.type(screen.getByPlaceholderText("Поиск работы…"), "масло");
expect(onQuery).toHaveBeenCalled();
await user.click(screen.getByLabelText("Закрыть"));
expect(onClose).toHaveBeenCalled();
});
@@ -0,0 +1,74 @@
/**
* Модалка выбора работы из каталога. Раньше была продублирована почти один-в-один
* в CreateRepairPage и EditRepairPage — вынесена в общий презентационный компонент.
*
* Состояние (открыта/запрос/результаты) живёт в родителе; здесь только UI +
* колбэки. Это сознательно: формы создания и правки ремонта разошлись по UX
* (inline-разблокировка цены против модалки-override, фото против их отсутствия),
* поэтому единая «форма ремонта» была бы хуже — общим является именно этот пикер.
*/
import { ChevronLeft } from "lucide-react";
import { Input } from "@/components/ui/input";
import { fmtMoney } from "@/lib/repairsFeed";
import type { MechWork } from "@/api/repairsCreate";
interface Props {
query: string;
results: MechWork[];
onQuery: (q: string) => void;
onPick: (work: MechWork) => void;
onClose: () => void;
}
export default function WorksPickerModal({ query, results, onQuery, onPick, onClose }: Props) {
return (
<div className="fixed inset-0 z-50 bg-background/95 flex flex-col">
<header className="border-b p-3 flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="rounded-full p-2 hover:bg-accent"
aria-label="Закрыть"
>
<ChevronLeft className="h-5 w-5" />
</button>
<Input
autoFocus
placeholder="Поиск работы…"
value={query}
onChange={(e) => onQuery(e.target.value)}
/>
</header>
<div className="flex-1 overflow-auto">
{results.map((mw) => (
<button
type="button"
key={mw.id}
onClick={() => onPick(mw)}
className="w-full text-left p-3 border-b hover:bg-accent/40 flex items-start justify-between gap-3"
>
<span
className="flex-1 min-w-0 text-sm leading-snug break-words"
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{mw.name}
</span>
<span className="text-sm text-muted-foreground font-mono whitespace-nowrap shrink-0 mt-0.5">
{fmtMoney(Number(mw.price))}
</span>
</button>
))}
{results.length === 0 && (
<div className="text-center text-muted-foreground p-8 text-sm">
Ничего не найдено
</div>
)}
</div>
</div>
);
}
@@ -22,6 +22,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { repairCanSubmit, repairSubmitWhyDisabled } from "@/lib/repairForm";
import WorksPickerModal from "@/components/repairs/WorksPickerModal";
import {
createRepair,
getCarContext,
@@ -698,54 +699,15 @@ export default function CreateRepairPage() {
</section>
</main>
{/* Works picker modal */}
{/* Works picker modal — общий компонент (см. WorksPickerModal) */}
{worksPicker.open && (
<div className="fixed inset-0 z-50 bg-background/95 flex flex-col">
<header className="border-b p-3 flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setWorksPicker({ open: false, q: "", results: [] })}>
Отмена
</Button>
<Input
autoFocus
placeholder="Поиск работы…"
value={worksPicker.q}
onChange={(e) => queryWorks(e.target.value)}
/>
</header>
<main className="flex-1 overflow-y-auto">
<ul className="divide-y">
{worksPicker.results.map((w) => (
<li key={w.id}>
<button
type="button"
onClick={() => addWorkFromCatalog(w)}
className="w-full text-left p-3 hover:bg-accent/40 flex items-start justify-between gap-3"
>
<span
className="flex-1 min-w-0 text-sm leading-snug break-words"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{w.name}
</span>
<span className="text-sm text-muted-foreground font-mono whitespace-nowrap shrink-0 mt-0.5">
{fmtPrice(w.price)}
</span>
</button>
</li>
))}
{worksPicker.results.length === 0 && (
<li className="p-6 text-center text-sm text-muted-foreground">
Нет работ по запросу
</li>
)}
</ul>
</main>
</div>
<WorksPickerModal
query={worksPicker.q}
results={worksPicker.results}
onQuery={queryWorks}
onPick={addWorkFromCatalog}
onClose={() => setWorksPicker({ open: false, q: "", results: [] })}
/>
)}
</div>
);
@@ -17,6 +17,7 @@ import {
type RepairDetailResponse,
} from "@/api/repairsFeed";
import { searchWorks, suggestParts, type MechWork } from "@/api/repairsCreate";
import WorksPickerModal from "@/components/repairs/WorksPickerModal";
interface WorkRow {
rowKey: string;
@@ -376,41 +377,15 @@ export default function EditRepairPage() {
</footer>
)}
{/* Works picker modal */}
{/* Works picker modal — общий компонент (см. WorksPickerModal) */}
{worksPicker.open && (
<div className="fixed inset-0 z-50 bg-background/95 flex flex-col">
<header className="border-b p-3 flex items-center gap-2">
<button onClick={() => setWorksPicker({ open: false, q: "", results: [] })}
className="rounded-full p-2 hover:bg-accent">
<ChevronLeft className="h-5 w-5" />
</button>
<Input
autoFocus
placeholder="Поиск работы…"
value={worksPicker.q}
onChange={(e) => queryWorks(e.target.value)}
/>
</header>
<div className="flex-1 overflow-auto">
{worksPicker.results.map((mw) => (
<button
key={mw.id}
onClick={() => pickWork(mw)}
className="w-full text-left p-3 border-b hover:bg-accent/40"
>
<div className="text-sm font-medium">{mw.name}</div>
<div className="text-xs text-muted-foreground font-mono">
{fmt(Number(mw.price))}
</div>
</button>
))}
{worksPicker.results.length === 0 && (
<div className="text-center text-muted-foreground p-8 text-sm">
Ничего не найдено
</div>
)}
</div>
</div>
<WorksPickerModal
query={worksPicker.q}
results={worksPicker.results}
onQuery={queryWorks}
onPick={pickWork}
onClose={() => setWorksPicker({ open: false, q: "", results: [] })}
/>
)}
{/* Override modal */}