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:
@@ -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)}
|
||||
<WorksPickerModal
|
||||
query={worksPicker.q}
|
||||
results={worksPicker.results}
|
||||
onQuery={queryWorks}
|
||||
onPick={addWorkFromCatalog}
|
||||
onClose={() => setWorksPicker({ open: false, q: "", results: [] })}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</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)}
|
||||
<WorksPickerModal
|
||||
query={worksPicker.q}
|
||||
results={worksPicker.results}
|
||||
onQuery={queryWorks}
|
||||
onPick={pickWork}
|
||||
onClose={() => setWorksPicker({ open: false, q: "", results: [] })}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Override modal */}
|
||||
|
||||
Reference in New Issue
Block a user