feat(assign-dialog): gate submit on ready_for_collection with warning
This commit is contained in:
parent
d78d479091
commit
b5cd061da1
4 changed files with 131 additions and 1 deletions
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -36,6 +36,7 @@
|
|||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
|
@ -3331,6 +3332,20 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
"version": "14.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/dom": ">=7.21.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common": {
|
||||
"version": "0.27.0",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@
|
|||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
|
|
|||
73
src/components/ui/assign-driver-dialog.test.tsx
Normal file
73
src/components/ui/assign-driver-dialog.test.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AssignDriverDialog } from "./assign-driver-dialog";
|
||||
|
||||
vi.mock("@/components/ui/user-search", () => ({
|
||||
UserSearch: ({
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<input
|
||||
data-testid="driver-search"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
function openDialog(overrides: Partial<React.ComponentProps<typeof AssignDriverDialog>> = {}) {
|
||||
const onSubmit = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
const result = render(
|
||||
<AssignDriverDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onSubmit={onSubmit}
|
||||
initialDriverId=""
|
||||
initialDate="2026-07-12"
|
||||
requireReadyForCollection
|
||||
readyForCollection={false}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { ...result, onSubmit, onOpenChange };
|
||||
}
|
||||
|
||||
describe("AssignDriverDialog ready-gate", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows the amber warning and keeps Submit disabled when requireReadyForCollection and not ready", () => {
|
||||
openDialog();
|
||||
expect(
|
||||
screen.getByText(/Supplier hasn't confirmed this item is ready/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Assign driver/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("allows Submit when requireReadyForCollection=true and readyForCollection=true", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = openDialog({ readyForCollection: true });
|
||||
// Type a driver id so canSubmit would otherwise be true.
|
||||
await user.type(screen.getByTestId("driver-search"), "drv-1");
|
||||
const btn = screen.getByRole("button", { name: /Assign driver/i });
|
||||
expect(btn).toBeEnabled();
|
||||
await user.click(btn);
|
||||
expect(onSubmit).toHaveBeenCalledWith({ driverId: "drv-1", date: "2026-07-12" });
|
||||
});
|
||||
|
||||
it("does not show the warning when requireReadyForCollection is unset (other call sites)", () => {
|
||||
openDialog({ requireReadyForCollection: undefined });
|
||||
expect(
|
||||
screen.queryByText(/Supplier hasn't confirmed this item is ready/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -67,6 +67,19 @@ export interface AssignDriverDialogProps {
|
|||
eyebrow?: React.ReactNode;
|
||||
/** Disable the submit button. */
|
||||
busy?: boolean;
|
||||
/**
|
||||
* When true, the dialog blocks the Assign path until the row's
|
||||
* `ready_for_collection` flag is true. The warning copy links to
|
||||
* `/collection-pool` — that's where the operator flips the flag.
|
||||
* Defaults to false (so other call sites that don't care about
|
||||
* the supplier gate keep their current behavior). The flag is
|
||||
* intentionally caller-supplied because the dialog stays
|
||||
* row-agnostic.
|
||||
*/
|
||||
requireReadyForCollection?: boolean;
|
||||
/** Current value of the row's `ready_for_collection` flag. When
|
||||
* `requireReadyForCollection` is unset, this is ignored. */
|
||||
readyForCollection?: boolean;
|
||||
}
|
||||
|
||||
function todayAsYmd(): string {
|
||||
|
|
@ -88,6 +101,8 @@ export function AssignDriverDialog({
|
|||
bulkCount = 1,
|
||||
eyebrow,
|
||||
busy = false,
|
||||
requireReadyForCollection = false,
|
||||
readyForCollection,
|
||||
}: AssignDriverDialogProps) {
|
||||
const isBulk = bulkCount > 1;
|
||||
const singleIntent = intent ?? (initialDriverId ? "reschedule" : "assign");
|
||||
|
|
@ -122,6 +137,8 @@ export function AssignDriverDialog({
|
|||
submitLabel={submitLabel}
|
||||
eyebrow={eyebrow}
|
||||
busy={busy}
|
||||
requireReadyForCollection={requireReadyForCollection}
|
||||
readyForCollection={readyForCollection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -138,6 +155,8 @@ type AssignDriverDialogInnerProps = {
|
|||
submitLabel: string;
|
||||
eyebrow?: React.ReactNode;
|
||||
busy: boolean;
|
||||
requireReadyForCollection: boolean;
|
||||
readyForCollection: boolean | undefined;
|
||||
};
|
||||
|
||||
function AssignDriverDialogInner({
|
||||
|
|
@ -152,6 +171,8 @@ function AssignDriverDialogInner({
|
|||
submitLabel,
|
||||
eyebrow,
|
||||
busy,
|
||||
requireReadyForCollection,
|
||||
readyForCollection,
|
||||
}: AssignDriverDialogInnerProps) {
|
||||
const [driverId, setDriverId] = useState(initialDriverId);
|
||||
const [date, setDate] = useState(initialDate ?? todayAsYmd());
|
||||
|
|
@ -169,7 +190,11 @@ function AssignDriverDialogInner({
|
|||
[],
|
||||
);
|
||||
|
||||
const canSubmit = date.length === 10 && driverId.length > 0 && !busy;
|
||||
const isAssignment = driverId.length > 0;
|
||||
const isReadyBlocked =
|
||||
isAssignment && requireReadyForCollection && readyForCollection === false;
|
||||
const canSubmit =
|
||||
date.length === 10 && driverId.length > 0 && !busy && !isReadyBlocked;
|
||||
|
||||
return (
|
||||
<ModalShell.Dialog
|
||||
|
|
@ -210,6 +235,22 @@ function AssignDriverDialogInner({
|
|||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{requireReadyForCollection && readyForCollection === false && (
|
||||
<p
|
||||
role="status"
|
||||
className="mb-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900"
|
||||
>
|
||||
Supplier hasn't confirmed this item is ready for collection. Mark it
|
||||
ready in{" "}
|
||||
<a
|
||||
href="/collection-pool"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
/collection-pool
|
||||
</a>{" "}
|
||||
before assigning a driver.
|
||||
</p>
|
||||
)}
|
||||
{/* Driver picker */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="assign-driver">Driver</Label>
|
||||
|
|
|
|||
Loading…
Reference in a new issue