fix(vendor-portal): hold uplift submit until the evidence check settles

Picking a video as uplift evidence reads its movie header before the file is stored in form state. A submit that landed inside that window validated the previous (usually empty) evidence, showed the missing-evidence error and dropped the request, forcing a second click. The submit button is now disabled and reads "Checking evidence…" while the latest pick is being checked, and the submit handler ignores submissions in that window.
This commit is contained in:
Alexandre Brandizzi 2026-09-25 01:50:27 -03:00
parent 39ee98e09b
commit d653ca0095
2 changed files with 45 additions and 2 deletions

View file

@ -43,12 +43,15 @@ export function UpliftRequestForm({
const [reason, setReason] = useState(initialReason);
const [evidenceFile, setEvidenceFile] = useState<File | undefined>();
const [formError, setFormError] = useState("");
const [checkingEvidence, setCheckingEvidence] = useState(false);
const latestSelection = useRef(0);
const showApprovalFields = mode === "revise" || isApprovalRequired(requested, currentNte);
const submit = (event: React.FormEvent) => {
event.preventDefault();
// The picked file is not in state until its check settles; submitting now would validate the old one.
if (checkingEvidence) return;
const amount = Number.parseFloat(requested);
const validationError = validateUpliftAmount(requested, currentNte, reason, {
file: evidenceFile,
@ -64,11 +67,13 @@ export function UpliftRequestForm({
const chooseFile = async (file?: File) => {
const selection = ++latestSelection.current;
setCheckingEvidence(Boolean(file));
const validationMessage = file ? await validateVendorDocument(file, mediaUsage) : undefined;
// A newer pick may have settled while this one's video metadata was still loading.
if (selection !== latestSelection.current) return;
setFormError(validationMessage ?? "");
setEvidenceFile(file);
setCheckingEvidence(false);
};
const shownError = errorMessage || formError;
@ -147,8 +152,14 @@ export function UpliftRequestForm({
{shownError}
</Text>
<div className="vp-actions">
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy}>
{busy ? "Submitting…" : mode === "revise" ? "Submit revision" : "Request uplift"}
<button type="submit" className="vp-btn vp-btn-primary" disabled={busy || checkingEvidence}>
{busy
? "Submitting…"
: checkingEvidence
? "Checking evidence…"
: mode === "revise"
? "Submit revision"
: "Request uplift"}
</button>
{mode === "revise" && (
<button type="button" className="vp-btn vp-btn-ghost" onClick={onCancel} disabled={busy}>

View file

@ -1,6 +1,9 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
import { probeVideoDurationSeconds } from "@/lib/probe-video-duration";
vi.mock("@/lib/probe-video-duration", () => ({ probeVideoDurationSeconds: vi.fn() }));
function renderForm(overrides: Partial<React.ComponentProps<typeof UpliftRequestForm>> = {}) {
return render(
@ -73,6 +76,35 @@ describe("UpliftRequestForm revise mode", () => {
);
});
it("holds submit while a picked video is still being checked, then submits that video", async () => {
let finishProbe: (seconds: number | null) => void = () => {};
vi.mocked(probeVideoDurationSeconds).mockImplementationOnce(
() => new Promise((resolve) => (finishProbe = resolve)),
);
const onSubmit = vi.fn();
renderForm({ initialRequested: "1200", initialReason: "Extra parts", onSubmit });
const video = new File(["mov"], "IMG_0001.MOV", { type: "video/quicktime" });
fireEvent.change(screen.getByLabelText(/evidence/i), { target: { files: [video] } });
const checking = await screen.findByRole("button", { name: "Checking evidence…" });
expect(checking).toBeDisabled();
fireEvent.submit(checking.closest("form")!);
expect(onSubmit).not.toHaveBeenCalled();
expect(screen.queryByText(/approval-required requests need evidence/i)).not.toBeInTheDocument();
finishProbe(30);
const submitButton = await screen.findByRole("button", { name: /submit revision/i });
expect(submitButton).toBeEnabled();
fireEvent.click(submitButton);
expect(onSubmit).toHaveBeenCalledWith({
amount: 1200,
reason: "Extra parts",
evidenceFile: video,
});
});
it("reports the current NTE threshold in the actionable error", () => {
renderForm({ initialRequested: "900", currentNte: 1000 });