feat(orders): add edit-mode confirmation for items table
Items in the orders review modal are read-only by default. A new
'Edit items' button (Pencil icon, top right of the items list)
opens a confirmation dialog before the inputs become editable.
- State: editingItems (default false), confirmingEdit (default false).
- Both reset to false whenever the modal re-opens (useEffect on
[open, order.id]) so re-opening always starts read-only.
- All 4 per-row inputs (qty, cost, markup, selling) carry
disabled={!editingItems || saving}.
- Visual hint: when editing, the items list gets a subtle warn
ring + tint (ring-1 ring-warn/40 bg-warn/5).
- data-testid hooks: review-edit-items-btn on the button,
review-edit-confirm-dialog on the dialog, review-items-list on
the ul with data-editing attribute.
- 4 new tests cover: read-only default, button opens dialog,
confirm enables editing, cancel keeps read-only.
Also adds order_attachements?: string[] to the Order type — the
PB file field on the orders collection used by the modal's source-
of-truth panel (separate commit wires the data).
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
22df511293
commit
7801b602f5
3 changed files with 188 additions and 4 deletions
|
|
@ -426,4 +426,74 @@ describe("OrdersReviewModal", () => {
|
|||
);
|
||||
expect(screen.getByText(/unpreviewable attachment/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("edit-mode confirmation", () => {
|
||||
// All 4 per-row inputs (qty, cost, markup, selling) should be
|
||||
// disabled until the operator clicks "Edit items" → confirms.
|
||||
// This is the safety gate — items are read-only by default.
|
||||
it("items are read-only by default", () => {
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
const itemRow = screen.getByText("HSS drill 6mm").closest("li")!;
|
||||
const rowInputs = itemRow.querySelectorAll("input");
|
||||
expect(rowInputs).toHaveLength(4);
|
||||
for (const input of Array.from(rowInputs)) {
|
||||
expect(input).toBeDisabled();
|
||||
}
|
||||
// The list itself should NOT carry the editing visual hint yet.
|
||||
const list = screen.getByTestId("review-items-list");
|
||||
expect(list.getAttribute("data-editing")).toBe("false");
|
||||
});
|
||||
|
||||
it("clicking Edit items opens a confirmation dialog", () => {
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
fireEvent.click(screen.getByTestId("review-edit-items-btn"));
|
||||
// Dialog surfaces the confirmation copy.
|
||||
expect(
|
||||
screen.getByText(/Enable item editing\?/i),
|
||||
).toBeInTheDocument();
|
||||
// Inputs are STILL disabled — confirming is required to flip the gate.
|
||||
const itemRow = screen.getByText("HSS drill 6mm").closest("li")!;
|
||||
for (const input of Array.from(itemRow.querySelectorAll("input"))) {
|
||||
expect(input).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("confirming the dialog enables editing", () => {
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
fireEvent.click(screen.getByTestId("review-edit-items-btn"));
|
||||
fireEvent.click(screen.getByTestId("review-edit-confirm-enable"));
|
||||
|
||||
// Dialog should be closed and items enabled.
|
||||
const itemRow = screen.getByText("HSS drill 6mm").closest("li")!;
|
||||
for (const input of Array.from(itemRow.querySelectorAll("input"))) {
|
||||
expect(input).not.toBeDisabled();
|
||||
}
|
||||
// The visual edit-mode hint should be applied.
|
||||
const list = screen.getByTestId("review-items-list");
|
||||
expect(list.getAttribute("data-editing")).toBe("true");
|
||||
// Button label flips to "Stop editing" so the operator has an
|
||||
// obvious exit without re-confirming.
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Stop editing/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("canceling the dialog keeps items read-only", () => {
|
||||
render(<OrdersReviewModal {...baseProps()} />);
|
||||
fireEvent.click(screen.getByTestId("review-edit-items-btn"));
|
||||
// The Cancel button is the close in the dialog footer.
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Cancel$/i }));
|
||||
|
||||
// Dialog gone, items still disabled, no edit hint.
|
||||
expect(
|
||||
screen.queryByText(/Enable item editing\?/i),
|
||||
).not.toBeInTheDocument();
|
||||
const itemRow = screen.getByText("HSS drill 6mm").closest("li")!;
|
||||
for (const input of Array.from(itemRow.querySelectorAll("input"))) {
|
||||
expect(input).toBeDisabled();
|
||||
}
|
||||
const list = screen.getByTestId("review-items-list");
|
||||
expect(list.getAttribute("data-editing")).toBe("false");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,10 +8,20 @@ import {
|
|||
Loader2,
|
||||
Mail,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CustomerSearch } from "@/components/ui/customer-search";
|
||||
|
|
@ -110,6 +120,16 @@ export function OrdersReviewModal({
|
|||
const [partialFailures, setPartialFailures] = useState<
|
||||
Array<{ id: string; error: string }>
|
||||
>([]);
|
||||
// Edit-mode gate for the items table. Items are read-only by default;
|
||||
// clicking the "Edit items" button pops a confirmation dialog, and on
|
||||
// confirm we flip this on. Reset to false on modal close so re-opening
|
||||
// the modal always starts in read-only mode (defence in depth — parent
|
||||
// already remounts via `key={order.id}`, but we don't want stale
|
||||
// editing state to leak between orders if the key is ever forgotten).
|
||||
const [editingItems, setEditingItems] = useState(false);
|
||||
// Whether the confirmation dialog is currently shown. Distinct from
|
||||
// `editingItems` because the dialog is its own modal surface.
|
||||
const [confirmingEdit, setConfirmingEdit] = useState(false);
|
||||
|
||||
// Re-seed local state when the modal opens for a different order.
|
||||
// Parent already remounts via `key={order.id}`, but defensively
|
||||
|
|
@ -126,6 +146,8 @@ export function OrdersReviewModal({
|
|||
setNumberError(null);
|
||||
setCollidingOrderId(null);
|
||||
setPartialFailures([]);
|
||||
setEditingItems(false);
|
||||
setConfirmingEdit(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, order.id]);
|
||||
|
||||
|
|
@ -446,15 +468,48 @@ export function OrdersReviewModal({
|
|||
The preview region is filled in by Task 5. */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
Order items
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Order items
|
||||
</h3>
|
||||
{/* Edit gate — items are read-only until the operator
|
||||
confirms via the dialog. The button label flips
|
||||
between "Edit items" and "Stop editing" so the
|
||||
operator has a clear exit. Clicking while in edit
|
||||
mode skips the confirmation (no data loss to
|
||||
confirm against; save still has its own gate). */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
if (editingItems) {
|
||||
setEditingItems(false);
|
||||
} else {
|
||||
setConfirmingEdit(true);
|
||||
}
|
||||
}}
|
||||
data-testid="review-edit-items-btn"
|
||||
aria-label={editingItems ? "Stop editing items" : "Edit items"}
|
||||
>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
{editingItems ? "Stop editing" : "Edit items"}
|
||||
</Button>
|
||||
</div>
|
||||
{persistedItems.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
No line items on this order.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-md border bg-card">
|
||||
<ul
|
||||
className={cn(
|
||||
"divide-y rounded-md border bg-card",
|
||||
editingItems && "ring-1 ring-warn/40 bg-warn/5",
|
||||
)}
|
||||
data-testid="review-items-list"
|
||||
data-editing={editingItems ? "true" : "false"}
|
||||
>
|
||||
{persistedItems.map((it) => {
|
||||
const d = itemDrafts[it.id];
|
||||
if (!d) return null;
|
||||
|
|
@ -474,6 +529,7 @@ export function OrdersReviewModal({
|
|||
onChange={(e) =>
|
||||
onQuantityChange(it.id, e.target.value)
|
||||
}
|
||||
disabled={!editingItems || saving}
|
||||
className="h-8 text-xs tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -485,6 +541,7 @@ export function OrdersReviewModal({
|
|||
type="number"
|
||||
value={d.cost_price ?? ""}
|
||||
onChange={(e) => onCostChange(it.id, e.target.value)}
|
||||
disabled={!editingItems || saving}
|
||||
className={cn(
|
||||
"h-8 text-xs tabular-nums",
|
||||
d.lock === "cost" && "border-warn/40 bg-warn/10",
|
||||
|
|
@ -501,6 +558,7 @@ export function OrdersReviewModal({
|
|||
onChange={(e) =>
|
||||
onMarkupChange(it.id, e.target.value)
|
||||
}
|
||||
disabled={!editingItems || saving}
|
||||
className="h-8 text-xs tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -514,6 +572,7 @@ export function OrdersReviewModal({
|
|||
onChange={(e) =>
|
||||
onSellingChange(it.id, e.target.value)
|
||||
}
|
||||
disabled={!editingItems || saving}
|
||||
className={cn(
|
||||
"h-8 text-xs tabular-nums",
|
||||
d.lock === "selling" && "border-warn/40 bg-warn/10",
|
||||
|
|
@ -571,6 +630,49 @@ export function OrdersReviewModal({
|
|||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Edit-mode confirmation dialog. Pops when the operator clicks
|
||||
"Edit items" so the input fields aren't immediately
|
||||
editable — the operator has to confirm intent. Cancel just
|
||||
closes the dialog; Enable editing flips `editingItems` and
|
||||
also closes the dialog. */}
|
||||
<Dialog
|
||||
open={confirmingEdit}
|
||||
onOpenChange={(open) => {
|
||||
// Only allow the dialog to be dismissed via Cancel / X —
|
||||
// don't auto-flip editingItems from the open-change handler
|
||||
// (the only writers are the two buttons below).
|
||||
if (!open) setConfirmingEdit(false);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="sm:max-w-sm"
|
||||
data-testid="review-edit-confirm-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enable item editing?</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to edit the fields? Changes will be
|
||||
saved when you click Save.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button variant="outline" size="sm" />}>
|
||||
Cancel
|
||||
</DialogClose>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingItems(true);
|
||||
setConfirmingEdit(false);
|
||||
}}
|
||||
data-testid="review-edit-confirm-enable"
|
||||
>
|
||||
Enable editing
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -650,6 +650,18 @@ export interface Order extends BaseRecord {
|
|||
* `Quote.ai_insights`. See `components/ai-insights-renderer.tsx`.
|
||||
*/
|
||||
ai_insights?: unknown;
|
||||
/**
|
||||
* Source attachments for the order. Multi-file field on the orders
|
||||
* collection (`maxSelect: 10`, accepts any MIME). Replaces the
|
||||
* previous `quote_attachments` collection rows for orders that were
|
||||
* migrated on 2026-07-29. The PB SDK returns this as a bare
|
||||
* `string[]` of filenames (no token); callers compose the file URL
|
||||
* with `recordId` + filename.
|
||||
*
|
||||
* Note: the field name is the PB-side misspelling ("attachements")
|
||||
* — keep it in sync with the live schema.
|
||||
*/
|
||||
order_attachements?: string[];
|
||||
expand?: {
|
||||
company?: Company;
|
||||
customer?: Customer;
|
||||
|
|
|
|||
Loading…
Reference in a new issue