Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/app/decision-controls-test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* page.tsx
*
* tests the controls and decision flow
*
* Author: Abby, Alex
* Date Created: 11/06/26
*/
"use client";

import { useState } from "react";
import ApplicantDecisionControls from "@/components/admin/ApplicantDecisionControls";
import type { DecisionStatus } from "@/components/admin/ApplicantDecisionControls";

const TEST_APPLICANTS = [
{ id: "test-applicant-five", note: "approve works" },
{ id: "test-applicant-six", note: "reject works" },
{ id: "test-applicant-seven", note: "approve fails after Five (409)" },
{ id: "test-applicant-three", note: "already rejected on the server (409)" },
{ id: "nope", note: "doesn't exist (404)" },
];

const STATUSES: DecisionStatus[] = ["SUBMITTED", "APPROVED", "REJECTED"];

export default function DecisionControlsTestPage() {
const [applicantId, setApplicantId] = useState(TEST_APPLICANTS[0].id);
const [status, setStatus] = useState<DecisionStatus>("SUBMITTED");
const [lastDecided, setLastDecided] = useState("nothing yet");

return (
<main className="mx-auto flex max-w-xl flex-col gap-6 p-6">
<section className="flex flex-col gap-3 rounded-lg border p-4">
<h1 className="text-xl font-bold">Decision controls test</h1>
<p>Sign in at /admin/sign-in as the admin first.</p>
<label className="flex flex-col gap-1">
Applicant
<select
className="rounded border p-1"
value={applicantId}
onChange={(e) => setApplicantId(e.target.value)}
>
{TEST_APPLICANTS.map((a) => (
<option key={a.id} value={a.id}>
{a.id}: {a.note}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
Status the page loaded with
<select
className="rounded border p-1"
value={status}
onChange={(e) => setStatus(e.target.value as DecisionStatus)}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</label>
<p>onDecided was last called with: {lastDecided}</p>
</section>

{/* The key makes it start fresh whenever you change a choice above */}
<ApplicantDecisionControls
key={`${applicantId}-${status}`}
applicantId={applicantId}
status={status}
onDecided={setLastDecided}
/>
</main>
);
}
127 changes: 127 additions & 0 deletions src/components/admin/ApplicantDecisionControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* ApplicantDecisionControls.tsx
*
* Admin can approve/reject submitted applicants.
* Display confirm modal and updates applicant status.
*
* Author: Abby, Alex
* Date Created: 11/06/26
*/
"use client";

import { useState } from "react";
import ConfirmModal from "@/components/common/ConfirmModal";
import CommonButton from "@/components/common/CommonButton";
import { decideApplicant } from "@/lib/admin-api";
import type { Decision } from "@/lib/admin-api";

export type DecisionStatus = "SUBMITTED" | "APPROVED" | "REJECTED";

type ApplicantDecisionControlsProps = {
applicantId: string;
// The applicant's status when the page loaded
status: DecisionStatus;
// Called when the admin closes the result pop-up, so the page can refresh its own data
onDecided?: (status: "APPROVED" | "REJECTED") => void;
};

/**
* ApplicantDecisionControls /src/components/admin/
*
* Admin can approve/reject submitted applicants.
* Display confirm modal while updating applicant status.
*
* @param applicantId - id of applicant
* @param status - applicants inital status
* @param onDecided - optional callback
* @returns buttons and status
*
*/
export default function ApplicantDecisionControls({
applicantId,
status,
onDecided,
}: ApplicantDecisionControlsProps) {
const [currentStatus, setCurrentStatus] = useState<DecisionStatus>(status);
// Which decision the "Are you sure?" pop-up is open for (null means it's closed)
const [pending, setPending] = useState<Decision | null>(null);
// Which decision the result pop-up is open for (null means it's closed)
const [result, setResult] = useState<"APPROVED" | "REJECTED" | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

async function handleConfirm() {
if (!pending) {
return;
}
setBusy(true);
setError(null);

const response = await decideApplicant(applicantId, pending);
setBusy(false);
if (!response.ok) {
setError(response.message);
return;
}

const newStatus = pending === "approve" ? "APPROVED" : "REJECTED";
setCurrentStatus(newStatus);
setPending(null);
setResult(newStatus);
}

function handleCancel() {
setPending(null);
setError(null);
}

function handleResultClose() {
if (result != null) {
onDecided?.(result);
}
setResult(null);
}

return (
<>
{currentStatus === "SUBMITTED" ? (
<div className="">
<CommonButton title="Approve" onClick={() => setPending("approve")} />

<CommonButton title="Reject" onClick={() => setPending("reject")} />
</div>
) : (
<span>{currentStatus}</span>
)}

{pending && (
<ConfirmModal
title="Are you sure?"
content={`Are you sure you want to ${pending} this applicant?`}
confirmTitle={pending === "approve" ? "Approve" : "Reject"}
onConfirm={handleConfirm}
onCancel={handleCancel}
busy={busy}
error={error}
/>
)}

{result && (
<ConfirmModal
title={
result === "APPROVED"
? "Applicant approved!"
: "Applicant rejected!"
}
content={
result === "APPROVED"
? "The applicant has been approved successfully."
: "The applicant has been rejected."
}
confirmTitle="Close"
onConfirm={handleResultClose}
/>
)}
</>
);
}
89 changes: 89 additions & 0 deletions src/lib/admin-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* admin-api.test.ts
*
* tests admin-api.ts
*
* Author: Abby, Alex
* Date Created: 11/06/26
*/
import { decideApplicant } from "./admin-api";

const originalFetch = global.fetch;

// Replaces fetch with one that answers with a made-up response
function mockFetch(status: number, json: () => Promise<unknown>) {
global.fetch = jest.fn().mockResolvedValue({
ok: status >= 200 && status < 300,
status,
json,
}) as unknown as typeof fetch;
}

afterEach(() => {
global.fetch = originalFetch;
});

describe("decideApplicant", () => {
it("posts to the approve endpoint", async () => {
mockFetch(200, async () => ({ applicant: { id: "a1" } }));
expect(await decideApplicant("a1", "approve")).toEqual({ ok: true });
expect(global.fetch).toHaveBeenCalledWith(
"/api/applicants/a1/approve",
expect.objectContaining({ method: "POST" }),
);
});

it("posts to the reject endpoint", async () => {
mockFetch(200, async () => ({ applicant: { id: "a1" } }));
expect(await decideApplicant("a1", "reject")).toEqual({ ok: true });
expect(global.fetch).toHaveBeenCalledWith(
"/api/applicants/a1/reject",
expect.objectContaining({ method: "POST" }),
);
});

it("encodes the id in the address", async () => {
mockFetch(200, async () => ({}));
await decideApplicant("a/b", "approve");
expect(global.fetch).toHaveBeenCalledWith(
"/api/applicants/a%2Fb/approve",
expect.anything(),
);
});

it("returns the server's message on an error", async () => {
mockFetch(409, async () => ({}));
expect(await decideApplicant("a1", "approve")).toEqual({
ok: false,
message: expect.stringMatching("sometthing went wrong"),
});
});

it("returns a message for a 403 with no body message", async () => {
mockFetch(403, async () => ({}));
expect(await decideApplicant("a1", "approve")).toEqual({
ok: false,
message: expect.stringMatching(/\S/),
});
});

it("returns a message when the body isn't JSON", async () => {
mockFetch(500, async () => {
throw new Error("not json");
});
expect(await decideApplicant("a1", "reject")).toEqual({
ok: false,
message: expect.stringMatching(/\S/),
});
});

it("doesn't throw when fetch fails", async () => {
global.fetch = jest
.fn()
.mockRejectedValue(new Error("offline")) as unknown as typeof fetch;
expect(await decideApplicant("a1", "reject")).toEqual({
ok: false,
message: expect.stringMatching(/\S/),
});
});
});
54 changes: 54 additions & 0 deletions src/lib/admin-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* admin-api.ts
*
* calls POST and sends approval or rejection and returns if succeeded or not
*
* Author: Abby, Alex
* Date Created: 11/06/26
*/

export type Decision = "approve" | "reject";

export type DecisionResult = { ok: true } | { ok: false; message: string };

/**
* decideApplicant
*
* calls POST and sends approval or rejection and returns if succeeded or not
*
* @param applicantId - applicants id string
* @param decision - approve/reject
* @returns decisionResult - success or failure -> with error message
*
*/
export async function decideApplicant(
applicantId: string,
decision: Decision,
): Promise<DecisionResult> {
try {
const response = await fetch(
`/api/applicants/${encodeURIComponent(applicantId)}/${decision}`,
{
method: "POST",
},
);

if (response.ok) {
return { ok: true };
} else {
const data = await response.json();
if (
typeof data === "object" &&
data !== null &&
"error" in data &&
typeof data.error === "string"
) {
return { ok: false, message: data.error };
} else {
return { ok: false, message: "sometthing went wrong" };
}
}
} catch {
return { ok: false, message: "something went wrong" };
}
}
Loading