Skip to content

Commit 0aa6a85

Browse files
authored
Merge pull request #98 from GTBitsOfGood/johnny/destructive-action-safeguards
Safeguards for Destructive Actions
2 parents 1b9cbf6 + e0bcd4b commit 0aa6a85

4 files changed

Lines changed: 177 additions & 4 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"use client";
2+
3+
import React from "react";
4+
import BogModal from "@/components/BogModal/BogModal";
5+
import BogButton from "@/components/BogButton/BogButton";
6+
7+
type ConfirmActionModalProps = {
8+
open: boolean;
9+
onOpenChange: (open: boolean) => void;
10+
onConfirm: () => void | Promise<void>;
11+
/** Modal heading, e.g. "Cancel this ride?" */
12+
title: string;
13+
/** Body copy explaining what the action does. */
14+
description: React.ReactNode;
15+
/** Label for the destructive confirm button, e.g. "Cancel ride". */
16+
confirmLabel: string;
17+
/** Label shown on the confirm button while the request is in flight. */
18+
confirmingLabel?: string;
19+
/** Label for the dismiss button. */
20+
cancelLabel?: string;
21+
/** True while the confirmed request is running — disables the buttons and
22+
* prevents the modal from being dismissed. */
23+
confirming?: boolean;
24+
};
25+
26+
/**
27+
* Confirmation dialog for destructive admin actions (ride cancellation,
28+
* user/vehicle deletion). Built on BogModal with the standard palette:
29+
* navy dismiss button, red confirm button.
30+
*/
31+
export default function ConfirmActionModal({
32+
open,
33+
onOpenChange,
34+
onConfirm,
35+
title,
36+
description,
37+
confirmLabel,
38+
confirmingLabel,
39+
cancelLabel = "Nevermind",
40+
confirming = false,
41+
}: ConfirmActionModalProps) {
42+
// While the request is in flight, don't let the modal be dismissed
43+
// (Escape, outside click, or the close button).
44+
const setOpen = (value: React.SetStateAction<boolean>) => {
45+
const next = typeof value === "function" ? value(open) : value;
46+
if (confirming && !next) return;
47+
onOpenChange(next);
48+
};
49+
50+
return (
51+
<BogModal
52+
size="medium"
53+
modal
54+
openState={{ open, setOpen }}
55+
trigger={
56+
<button
57+
type="button"
58+
aria-hidden
59+
tabIndex={-1}
60+
style={{ display: "none" }}
61+
/>
62+
}
63+
title={<h3>{title}</h3>}
64+
>
65+
<div className="flex flex-col gap-[2.4rem]">
66+
<p className="m-0 text-paragraph-1 text-[var(--color-grey-text-strong)]">
67+
{description}
68+
</p>
69+
<div className="flex flex-wrap justify-end gap-[1.2rem]">
70+
<BogButton
71+
variant="secondary"
72+
size="medium"
73+
disabled={confirming}
74+
onClick={() => setOpen(false)}
75+
style={{ borderRadius: "0.5rem" }}
76+
>
77+
{cancelLabel}
78+
</BogButton>
79+
<BogButton
80+
variant="primary"
81+
size="medium"
82+
disabled={confirming}
83+
onClick={() => void onConfirm()}
84+
style={
85+
{
86+
"--color-brand-text": "var(--color-status-red-text)",
87+
"--color-brand-hover": "var(--color-status-red-stroke-strong)",
88+
borderRadius: "0.5rem",
89+
} as React.CSSProperties
90+
}
91+
>
92+
{confirming ? (confirmingLabel ?? confirmLabel) : confirmLabel}
93+
</BogButton>
94+
</div>
95+
</div>
96+
</BogModal>
97+
);
98+
}

‎src/app/admin/RidesTable.tsx‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import BogDropdown from "@/components/BogDropdown/BogDropdown";
66
import BogButton from "@/components/BogButton/BogButton";
77
import { RIDE_COLUMNS } from "./admin-table-data";
88
import type { TableRow } from "@/components/BogTable/BogTable";
9+
import ConfirmActionModal from "./ConfirmActionModal";
910

1011
type RouteEntry = {
1112
_id: string;
@@ -45,6 +46,7 @@ export default function RidesTable() {
4546
const [assignErrors, setAssignErrors] = useState<Record<string, string>>({});
4647
const [canceling, setCanceling] = useState<Set<string>>(new Set());
4748
const [cancelErrors, setCancelErrors] = useState<Record<string, string>>({});
49+
const [pendingCancelId, setPendingCancelId] = useState<string | null>(null);
4850

4951
useEffect(() => {
5052
let cancelled = false;
@@ -272,7 +274,7 @@ export default function RidesTable() {
272274
variant="secondary"
273275
size="small"
274276
disabled={isCanceling || isAssigning}
275-
onClick={() => handleCancel(route._id)}
277+
onClick={() => setPendingCancelId(route._id)}
276278
>
277279
{isCanceling ? "Canceling…" : "Cancel"}
278280
</BogButton>
@@ -297,7 +299,34 @@ export default function RidesTable() {
297299
if (routes.length === 0)
298300
return <p className="text-gray-600">No requested rides.</p>;
299301

302+
const pendingCancelRoute = pendingCancelId
303+
? routes.find((r) => r._id === pendingCancelId)
304+
: undefined;
305+
300306
return (
301-
<BogTable columnHeaders={RIDE_COLUMNS} rows={rows} selectable={false} />
307+
<>
308+
<BogTable columnHeaders={RIDE_COLUMNS} rows={rows} selectable={false} />
309+
<ConfirmActionModal
310+
open={pendingCancelId !== null}
311+
onOpenChange={(o) => {
312+
if (!o) setPendingCancelId(null);
313+
}}
314+
onConfirm={async () => {
315+
if (!pendingCancelId) return;
316+
await handleCancel(pendingCancelId);
317+
setPendingCancelId(null);
318+
}}
319+
title="Cancel this ride?"
320+
description={
321+
pendingCancelRoute
322+
? `This cancels the requested ride for ${pendingCancelRoute.student.firstName} ${pendingCancelRoute.student.lastName}. This action cannot be undone.`
323+
: "This cancels the requested ride. This action cannot be undone."
324+
}
325+
confirmLabel="Cancel ride"
326+
confirmingLabel="Canceling…"
327+
cancelLabel="Keep ride"
328+
confirming={pendingCancelId !== null && canceling.has(pendingCancelId)}
329+
/>
330+
</>
302331
);
303332
}

‎src/app/admin/VehicleDetailsPanel.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import BogIcon from "@/components/BogIcon/BogIcon";
66
import BogTextInput from "@/components/BogTextInput/BogTextInput";
77
import BogDropdown from "@/components/BogDropdown/BogDropdown";
88
import { ProfileRidesTab } from "@/app/profile/ProfileRidesTab";
9+
import ConfirmActionModal from "./ConfirmActionModal";
910

1011
const VEHICLE_ACCESSIBILITY_OPTIONS = ["None", "Wheelchair"] as const;
1112

@@ -48,6 +49,7 @@ export default function VehicleDetailsPanel({
4849
const [saving, setSaving] = useState(false);
4950
const [saveError, setSaveError] = useState<string | null>(null);
5051
const [deleting, setDeleting] = useState(false);
52+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
5153

5254
useEffect(() => {
5355
setLoadingVehicle(true);
@@ -132,6 +134,7 @@ export default function VehicleDetailsPanel({
132134
onDeleted();
133135
} catch {
134136
setDeleting(false);
137+
setShowDeleteConfirm(false);
135138
}
136139
};
137140

@@ -156,7 +159,7 @@ export default function VehicleDetailsPanel({
156159
variant="secondary"
157160
size="medium"
158161
disabled={deleting}
159-
onClick={handleDelete}
162+
onClick={() => setShowDeleteConfirm(true)}
160163
style={
161164
{
162165
"--color-brand-stroke-strong": "var(--color-status-red-text)",
@@ -389,6 +392,21 @@ export default function VehicleDetailsPanel({
389392
</h2>
390393
<ProfileRidesTab userId={vehicleId} userType="Vehicle" />
391394
</div>
395+
396+
<ConfirmActionModal
397+
open={showDeleteConfirm}
398+
onOpenChange={(o) => {
399+
if (!o) setShowDeleteConfirm(false);
400+
}}
401+
onConfirm={handleDelete}
402+
title="Delete this vehicle?"
403+
description={`Are you sure you want to delete ${
404+
vehicle?.name ? `vehicle ${vehicle.name}` : "this vehicle"
405+
}? This action cannot be undone.`}
406+
confirmLabel="Delete vehicle"
407+
confirmingLabel="Deleting…"
408+
confirming={deleting}
409+
/>
392410
</div>
393411
);
394412
}

‎src/app/admin/page.tsx‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import BogDropdown from "@/components/BogDropdown/BogDropdown";
88
import React, { useState, useEffect, Suspense } from "react";
99
import AccommodationsPanel from "./AccommodationsPanel";
1010
import VehicleDetailsPanel from "./VehicleDetailsPanel";
11+
import ConfirmActionModal from "./ConfirmActionModal";
1112

1213
const VEHICLE_ACCESSIBILITY_OPTIONS = ["None", "Wheelchair"] as const;
1314
import { useAdminTableData, type AdminTableType } from "./useAdminTableData";
@@ -31,6 +32,7 @@ function AdminContent() {
3132
useAdminTableData(table);
3233
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
3334
const [deleting, setDeleting] = useState(false);
35+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
3436
const [studentAccessibilityNeeds, setStudentAccessibilityNeeds] = useState<
3537
string[]
3638
>([]);
@@ -68,6 +70,7 @@ function AdminContent() {
6870
useEffect(() => {
6971
setSelectedRows(new Set());
7072
setShowForm(false);
73+
setShowDeleteConfirm(false);
7174
setSelectedVehicleId(null);
7275
setSubmitError(null);
7376
setStudentAccessibilityNeeds([]);
@@ -89,11 +92,19 @@ function AdminContent() {
8992
setSelectedRows(new Set());
9093
} finally {
9194
setDeleting(false);
95+
setShowDeleteConfirm(false);
9296
}
9397
};
9498

9599
const canDelete = selectedRows.size > 0 && rowIds.length > 0;
96100

101+
const deleteNoun =
102+
table === "Vehicles"
103+
? "vehicle"
104+
: table === "Locations"
105+
? "location"
106+
: "user";
107+
97108
const handleAddStudent = (e: React.FormEvent<HTMLFormElement>) => {
98109
e.preventDefault();
99110
setSubmitError(null);
@@ -918,7 +929,7 @@ function AdminContent() {
918929
<BogButton
919930
variant="secondary"
920931
size="medium"
921-
onClick={handleDelete}
932+
onClick={() => setShowDeleteConfirm(true)}
922933
disabled={deleting}
923934
style={
924935
{
@@ -958,6 +969,23 @@ function AdminContent() {
958969
)}
959970
</>
960971
)}
972+
973+
<ConfirmActionModal
974+
open={showDeleteConfirm}
975+
onOpenChange={(o) => {
976+
if (!o) setShowDeleteConfirm(false);
977+
}}
978+
onConfirm={handleDelete}
979+
title={`Delete ${deleteNoun}${selectedRows.size === 1 ? "" : "s"}?`}
980+
description={
981+
selectedRows.size === 1
982+
? `Are you sure you want to delete this ${deleteNoun}? This action cannot be undone.`
983+
: `Are you sure you want to delete these ${selectedRows.size} ${deleteNoun}s? This action cannot be undone.`
984+
}
985+
confirmLabel={deleteLabel}
986+
confirmingLabel="Deleting…"
987+
confirming={deleting}
988+
/>
961989
</div>
962990
);
963991
}

0 commit comments

Comments
 (0)