From b2969f36c3ae11b505d369f3518fadbb3b6a8abb Mon Sep 17 00:00:00 2001 From: vladtechno Date: Fri, 5 Jun 2026 17:32:11 +0300 Subject: [PATCH] refactor(pwa): extract shared WorksPickerModal from Create/EditRepairPage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../repairs/WorksPickerModal.test.tsx | 43 +++++++++++ .../components/repairs/WorksPickerModal.tsx | 74 +++++++++++++++++++ .../src/pages/repairs/CreateRepairPage.tsx | 56 +++----------- .../src/pages/repairs/EditRepairPage.tsx | 43 +++-------- 4 files changed, 135 insertions(+), 81 deletions(-) create mode 100644 mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.test.tsx create mode 100644 mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.tsx diff --git a/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.test.tsx b/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.test.tsx new file mode 100644 index 0000000..c5ed579 --- /dev/null +++ b/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.test.tsx @@ -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( + {}} 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( + {}} onPick={() => {}} onClose={() => {}} />, + ); + expect(screen.getByText("Ничего не найдено")).toBeInTheDocument(); +}); + +test("ввод в поиск зовёт onQuery; кнопка назад — onClose", async () => { + const user = userEvent.setup(); + const onQuery = vi.fn(); + const onClose = vi.fn(); + render( + {}} onClose={onClose} />, + ); + await user.type(screen.getByPlaceholderText("Поиск работы…"), "масло"); + expect(onQuery).toHaveBeenCalled(); + await user.click(screen.getByLabelText("Закрыть")); + expect(onClose).toHaveBeenCalled(); +}); diff --git a/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.tsx b/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.tsx new file mode 100644 index 0000000..c547b3b --- /dev/null +++ b/mechanic-pwa/frontend/src/components/repairs/WorksPickerModal.tsx @@ -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 ( +
+
+ + onQuery(e.target.value)} + /> +
+
+ {results.map((mw) => ( + + ))} + {results.length === 0 && ( +
+ Ничего не найдено +
+ )} +
+
+ ); +} diff --git a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx index ec53f03..032daba 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/CreateRepairPage.tsx @@ -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() { - {/* Works picker modal */} + {/* Works picker modal — общий компонент (см. WorksPickerModal) */} {worksPicker.open && ( -
-
- - queryWorks(e.target.value)} - /> -
-
-
    - {worksPicker.results.map((w) => ( -
  • - -
  • - ))} - {worksPicker.results.length === 0 && ( -
  • - Нет работ по запросу -
  • - )} -
-
-
+ setWorksPicker({ open: false, q: "", results: [] })} + /> )} ); diff --git a/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx index d93a4b1..2a3ed55 100644 --- a/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx +++ b/mechanic-pwa/frontend/src/pages/repairs/EditRepairPage.tsx @@ -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() { )} - {/* Works picker modal */} + {/* Works picker modal — общий компонент (см. WorksPickerModal) */} {worksPicker.open && ( -
-
- - queryWorks(e.target.value)} - /> -
-
- {worksPicker.results.map((mw) => ( - - ))} - {worksPicker.results.length === 0 && ( -
- Ничего не найдено -
- )} -
-
+ setWorksPicker({ open: false, q: "", results: [] })} + /> )} {/* Override modal */}