fix(repairs): количество запчасти можно поменять — «залипшая единица» (4123)
Поле количества приводилось к минимуму на КАЖДЫЙ onChange
(`Math.max(1, Number(v))`), поэтому стирание единицы мгновенно возвращало
её обратно: набрать «4 свечи» было нельзя — цифра дописывалась и выходило
«14». Ровно этот же баг однажды чинили в цене работы (3b7b2ae), там ""
уже разрешено как промежуточное состояние стейта.
- общий QtyStepper: "" остаётся в стейте, пока печатают; приведение — на
blur и ещё раз на отправке (пустое = 1 шт: строка есть, значит деталь
ставили);
- тап по полю выделяет число — набор поверх заменяет, а не дописывает;
- кнопки −/+: 4 свечи набираются одним пальцем в перчатках, мобильная
цифровая клавиатура для этого слишком долгая;
- то же в форме правки ремонта — там я повторил ту же ошибку при переводе
запчастей на складской каталог;
- регрессионные тесты на стирание/blur/степпер: баг повторился второй раз
на том же паттерне, пусть дальше ловится сам.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi } from "vitest";
|
||||
import QtyStepper from "@/components/repairs/QtyStepper";
|
||||
|
||||
/** Обёртка с реальным стейтом — баг был именно в связке ввод↔стейт. */
|
||||
function Harness({ start = 1 }: { start?: number | "" }) {
|
||||
const [qty, setQty] = useState<number | "">(start);
|
||||
return (
|
||||
<>
|
||||
<QtyStepper value={qty} onChange={setQty} />
|
||||
<span data-testid="out">{qty === "" ? "empty" : qty}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
test("количество можно стереть и набрать заново (жалоба «4 свечи»)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness start={1} />);
|
||||
const input = screen.getByRole("spinbutton");
|
||||
|
||||
await user.clear(input);
|
||||
// Ключевой момент: пустое поле ОСТАЁТСЯ пустым. Раньше сюда мгновенно
|
||||
// возвращалась единица, и «4» превращалось в «14».
|
||||
expect(screen.getByTestId("out")).toHaveTextContent("empty");
|
||||
|
||||
await user.type(input, "4");
|
||||
expect(screen.getByTestId("out")).toHaveTextContent("4");
|
||||
});
|
||||
|
||||
test("пустое поле на blur превращается в 1, а не в 0", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness start={3} />);
|
||||
const input = screen.getByRole("spinbutton");
|
||||
await user.clear(input);
|
||||
await user.tab();
|
||||
expect(screen.getByTestId("out")).toHaveTextContent("1");
|
||||
});
|
||||
|
||||
test("кнопки −/+ меняют количество и не уводят ниже минимума", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness start={1} />);
|
||||
const minus = screen.getByLabelText("Меньше");
|
||||
const plus = screen.getByLabelText("Больше");
|
||||
|
||||
expect(minus).toBeDisabled(); // на единице уменьшать нечего
|
||||
await user.click(plus);
|
||||
await user.click(plus);
|
||||
await user.click(plus);
|
||||
expect(screen.getByTestId("out")).toHaveTextContent("4");
|
||||
|
||||
await user.click(minus);
|
||||
expect(screen.getByTestId("out")).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
test("тап по полю выделяет значение — цифра заменяет его, а не дописывается", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(<QtyStepper value={1} onChange={onChange} />);
|
||||
await user.type(screen.getByRole("spinbutton"), "2");
|
||||
// 2, а не 12: без select-on-focus набор поверх единицы давал бы «12»,
|
||||
// и это ровно тот сорт сюрприза, на который жаловались механики.
|
||||
expect(onChange).toHaveBeenCalledWith(2);
|
||||
// Число, а не строка — иначе qty уехал бы в payload строкой.
|
||||
expect(typeof onChange.mock.calls[0][0]).toBe("number");
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Количество детали в ремонте: −/+ и поле ввода.
|
||||
*
|
||||
* Почему не просто <input type="number">. Пустое поле обязано ОСТАВАТЬСЯ
|
||||
* пустым, пока человек печатает. Если на каждый onChange приводить значение
|
||||
* к минимуму (`Math.max(1, Number(v))`), то стирание единицы мгновенно
|
||||
* возвращает единицу — и набрать «4» становится невозможно: цифра
|
||||
* дописывается к ней («14»). Ровно на это пожаловались механики, и ровно так
|
||||
* же однажды залипал ноль в цене работы.
|
||||
*
|
||||
* Поэтому "" — легальное промежуточное состояние стейта, а к разумному
|
||||
* значению приводим на blur (и ещё раз на отправке формы).
|
||||
*
|
||||
* Кнопки −/+ здесь не украшение: 4 свечи набираются в перчатках одним
|
||||
* пальцем, а мобильная цифровая клавиатура для этого слишком долгая.
|
||||
*/
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface Props {
|
||||
value: number | "";
|
||||
onChange: (next: number | "") => void;
|
||||
min?: number;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function QtyStepper({ value, onChange, min = 1, disabled }: Props) {
|
||||
const current = value === "" ? min : value;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || current <= min}
|
||||
onClick={() => onChange(Math.max(min, current - 1))}
|
||||
aria-label="Меньше"
|
||||
className="h-9 w-9 rounded-md border flex items-center justify-center disabled:opacity-40 active:bg-accent"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={min}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onBlur={() => {
|
||||
const n = value === "" ? min : Math.floor(Number(value));
|
||||
onChange(Number.isFinite(n) && n >= min ? n : min);
|
||||
}}
|
||||
className="w-14 text-center"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(current + 1)}
|
||||
aria-label="Больше"
|
||||
className="h-9 w-9 rounded-md border flex items-center justify-center disabled:opacity-40 active:bg-accent"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { repairCanSubmit, repairSubmitWhyDisabled } from "@/lib/repairForm";
|
||||
import WorksPickerModal from "@/components/repairs/WorksPickerModal";
|
||||
import PartsPickerModal from "@/components/repairs/PartsPickerModal";
|
||||
import QtyStepper from "@/components/repairs/QtyStepper";
|
||||
import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField";
|
||||
import { getMe } from "@/api/me";
|
||||
import {
|
||||
@@ -73,7 +74,9 @@ interface PartRow {
|
||||
name: string;
|
||||
article: string | null;
|
||||
in_stock: number;
|
||||
qty: number;
|
||||
// "" допустимо, пока поле редактируют: иначе стирание единицы мгновенно
|
||||
// возвращало бы её и «4 свечи» было не набрать (см. QtyStepper).
|
||||
qty: number | "";
|
||||
}
|
||||
|
||||
interface OilChangeState {
|
||||
@@ -303,7 +306,7 @@ export default function CreateRepairPage() {
|
||||
// Та же деталь уже в списке — увеличиваем количество, а не плодим строку.
|
||||
const existing = arr.find((p) => p.part_id === item.id);
|
||||
if (existing) {
|
||||
return arr.map((p) => (p.part_id === item.id ? { ...p, qty: p.qty + 1 } : p));
|
||||
return arr.map((p) => (p.part_id === item.id ? { ...p, qty: Number(p.qty || 0) + 1 } : p));
|
||||
}
|
||||
return [
|
||||
...arr,
|
||||
@@ -361,7 +364,11 @@ export default function CreateRepairPage() {
|
||||
manual_override: w.unlocked,
|
||||
override_comment: w.unlocked ? w.override_comment.trim() : null,
|
||||
})),
|
||||
parts: parts.map((p) => ({ part_id: p.part_id, qty: p.qty, name: p.name })),
|
||||
// Пустое поле количества (человек стёр и не дописал) — это 1 штука,
|
||||
// а не ноль: строка в списке есть, значит деталь ставили.
|
||||
parts: parts.map((p) => ({
|
||||
part_id: p.part_id, qty: Math.max(1, Number(p.qty) || 1), name: p.name,
|
||||
})),
|
||||
photos: photoPayload,
|
||||
oil_changes: oilChange.enabled
|
||||
? (["engine", "transmission"] as const)
|
||||
@@ -640,18 +647,13 @@ export default function CreateRepairPage() {
|
||||
: "нет в наличии"}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
<QtyStepper
|
||||
value={p.qty}
|
||||
onChange={(e) =>
|
||||
onChange={(next) =>
|
||||
setParts((arr) =>
|
||||
arr.map((row) =>
|
||||
row.rowKey === p.rowKey ? { ...row, qty: Math.max(1, Number(e.target.value)) } : row,
|
||||
),
|
||||
arr.map((row) => (row.rowKey === p.rowKey ? { ...row, qty: next } : row)),
|
||||
)
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" onClick={() => removePart(p.rowKey)}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { searchWorks, type MechWork, type PartCatalogItem } from "@/api/repairsCreate";
|
||||
import WorksPickerModal from "@/components/repairs/WorksPickerModal";
|
||||
import PartsPickerModal from "@/components/repairs/PartsPickerModal";
|
||||
import QtyStepper from "@/components/repairs/QtyStepper";
|
||||
import MechanicsField, { type SelectedMechanic } from "@/components/repairs/MechanicsField";
|
||||
|
||||
interface WorkRow {
|
||||
@@ -40,7 +41,8 @@ interface PartRow {
|
||||
name: string;
|
||||
article: string | null;
|
||||
in_stock: number | null;
|
||||
qty: number;
|
||||
// "" — промежуточное состояние ввода, см. QtyStepper.
|
||||
qty: number | "";
|
||||
}
|
||||
|
||||
interface OilState {
|
||||
@@ -97,7 +99,7 @@ export default function EditRepairPage() {
|
||||
setParts((arr) => {
|
||||
// Та же деталь уже в списке — увеличиваем количество, а не плодим строку.
|
||||
const existing = arr.find((p) => p.part_id === item.id);
|
||||
if (existing) return arr.map((p) => (p.part_id === item.id ? { ...p, qty: p.qty + 1 } : p));
|
||||
if (existing) return arr.map((p) => (p.part_id === item.id ? { ...p, qty: Number(p.qty || 0) + 1 } : p));
|
||||
return [...arr, {
|
||||
rowKey: uid(), part_id: item.id, name: item.name,
|
||||
article: item.article, in_stock: item.in_stock, qty: 1,
|
||||
@@ -236,7 +238,8 @@ export default function EditRepairPage() {
|
||||
parts: parts.filter((p) => p.name.trim().length > 0).map((p) => ({
|
||||
part_id: p.part_id,
|
||||
name: p.name.trim(),
|
||||
qty: p.qty,
|
||||
// Пустое поле — 1 штука: строка есть, значит деталь ставили.
|
||||
qty: Math.max(1, Number(p.qty) || 1),
|
||||
})),
|
||||
// Полная замена набора: пустой список = замен не было. Пробег —
|
||||
// главный пробег ремонта (один источник истины).
|
||||
@@ -403,15 +406,11 @@ export default function EditRepairPage() {
|
||||
{p.in_stock != null ? ` · на складе: ${p.in_stock}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
<QtyStepper
|
||||
value={p.qty}
|
||||
onChange={(e) =>
|
||||
setParts((arr) => arr.map((x) =>
|
||||
x.rowKey === p.rowKey ? { ...x, qty: Math.max(1, Number(e.target.value)) } : x))
|
||||
onChange={(next) =>
|
||||
setParts((arr) => arr.map((x) => (x.rowKey === p.rowKey ? { ...x, qty: next } : x)))
|
||||
}
|
||||
className="w-20"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
Reference in New Issue
Block a user