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
63 changes: 27 additions & 36 deletions src/app/registration/_pages/NewUoaPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,14 @@

import { useFormError } from "../RegistrationForm";
import { RegistrationDraft } from "../types";

import { useRef } from "react";
import { MAX_MAJORS } from "@/domain/member/constants";

export function NewUoaPage({ fields }: { fields: Partial<RegistrationDraft> }) {
const state = useFormError();
const errorFields = state?.fields;
const field = errorFields ?? fields;

/* Client JS enhancement:
* Used to create a reference to the Other checkbox so it automatically checks when user enters text
*/
const otherFacultyCheckboxRef = useRef<HTMLInputElement>(null);
const majorCount = Math.min(field?.majorCount ?? 1, MAX_MAJORS);

return (
<>
Expand Down Expand Up @@ -136,41 +132,36 @@ export function NewUoaPage({ fields }: { fields: Partial<RegistrationDraft> }) {
/>
Auckland Bioengineering Institute
</label>
</div>
</fieldset>

<label>
<fieldset>
<legend>What are you majoring/specialising in?</legend>
<p>Majors are independent of the faculties you selected above.</p>

{Array.from({ length: majorCount }).map((_, i) => (
<div key={i}>
<label htmlFor={`majors-${i}`} className="sr-only">
Major/specialisation {i + 1}
</label>
<input
ref={otherFacultyCheckboxRef}
type="checkbox"
name="faculty"
value="other"
defaultChecked={
field?.faculty?.includes("other") ||
Boolean(field?.otherFaculty?.trim())
}
type="text"
name="majors"
id={`majors-${i}`}
placeholder="Your answer"
defaultValue={field?.majors?.[i] ?? ""}
maxLength={40}
/>
Other
</label>
</div>
))}

<label htmlFor="otherFaculty" className="sr-only">
Please specify other faculty
</label>
<input
type="text"
name="otherFaculty"
id="otherFaculty"
placeholder="Specify other"
defaultValue={field?.otherFaculty || ""}
maxLength={100}
onInput={(event) => {
const userHasTypedSomething =
event.currentTarget.value.trim().length > 0;
<input type="hidden" name="majorCount" value={majorCount} />

if (userHasTypedSomething && otherFacultyCheckboxRef.current) {
otherFacultyCheckboxRef.current.checked = true;
}
}}
/>
</div>
{majorCount < MAX_MAJORS && (
<button type="submit" name="intent" value="addMajor">
Add another major
</button>
)}
</fieldset>

<div>
Expand Down
92 changes: 65 additions & 27 deletions src/app/registration/_tests/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,32 +295,39 @@ describe("case: newUoa", () => {
expect(result?.error).toMatch(/9-10 digits/);
});

it("rejects when no faculty is selected and no otherFaculty is given", async () => {
it("rejects when no faculty is selected", async () => {
const result = await submitRegistrationStep(
null,
buildFormData({ ...validBase, faculty: [] }),
);
expect(result?.error).toMatch(/at least 1 faculty/);
});

it("rejects 'other' selected without otherFaculty text", async () => {
it("rejects more than MAX_FACULTIES faculties", async () => {
const fd = buildFormData({
...validBase,
faculty: ["other"],
otherFaculty: "",
faculty: ["science", "law", "business"],
});
const result = await submitRegistrationStep(null, fd);
expect(result?.error).toMatch(/specify your other faculty/);
expect(result?.error).toMatch(/at most 2 faculties/);
});

it("rejects an otherFaculty value over the max length", async () => {
it("rejects more than MAX_MAJORS majors", async () => {
const fd = buildFormData({
...validBase,
faculty: ["other"],
otherFaculty: "a".repeat(101),
majors: ["a", "b", "c", "d", "e"],
});
const result = await submitRegistrationStep(null, fd);
expect(result?.error).toMatch(/Other faculty must be under 100 characters/);
expect(result?.error).toMatch(/at most 4 majors/);
});

it("rejects a major value over the max length", async () => {
const fd = buildFormData({
...validBase,
majors: ["a".repeat(41)],
});
const result = await submitRegistrationStep(null, fd);
expect(result?.error).toMatch(/Each major must be under 40 characters/);
});

it("rejects a missing programme", async () => {
Expand Down Expand Up @@ -359,6 +366,45 @@ describe("case: newUoa", () => {
).rejects.toThrow("REDIRECT:/registration");
expect(JSON.parse(cookieStore.get("formState")!).page).toBe("final");
});

describe("addMajor intent", () => {
it("increments majorCount and preserves already-typed fields without advancing the page", async () => {
setCookieDraft({ page: "newUoa", pageStack: ["start", "newMember"] });
const fd = buildFormData({
...validBase,
intent: "addMajor",
majorCount: "1",
majors: ["Computer Science"],
});

await expect(submitRegistrationStep(null, fd)).rejects.toThrow(
"REDIRECT:/registration",
);

const saved = JSON.parse(cookieStore.get("formState")!);
expect(saved.page).toBe("newUoa");
expect(saved.pageStack).toEqual(["start", "newMember"]);
expect(saved.majorCount).toBe(2);
expect(saved.majors).toEqual(["Computer Science"]);
expect(saved.upi).toBe("abcd123");
});

it("caps majorCount at MAX_MAJORS", async () => {
setCookieDraft({ page: "newUoa", pageStack: ["start", "newMember"] });
const fd = buildFormData({
...validBase,
intent: "addMajor",
majorCount: "4",
});

await expect(submitRegistrationStep(null, fd)).rejects.toThrow(
"REDIRECT:/registration",
);

const saved = JSON.parse(cookieStore.get("formState")!);
expect(saved.majorCount).toBe(4);
});
});
});

describe("case: newNonUoa", () => {
Expand Down Expand Up @@ -554,13 +600,13 @@ describe("case: final", () => {
expect(result?.error).toBe("It looks like you've already registered.");
});

describe("otherFaculty merge into faculty", () => {
it("folds otherFaculty into faculty and removes the 'other' placeholder", async () => {
describe("majors flow through to submission", () => {
it("includes majors from the draft in the parsed submission", async () => {
setCookieDraft({
page: "final",
pageStack: ["start", "newMember", "newUoa"],
faculty: ["science", "other"],
otherFaculty: "Faculty of Made Up Studies",
faculty: ["science"],
majors: ["Computer Science", "Statistics"],
});
const fd = buildFormData({
page: "final",
Expand All @@ -572,34 +618,26 @@ describe("case: final", () => {
);

const submittedData = submitMemberRegistrationMock.mock.calls[0][0];
expect(submittedData.faculty).toEqual([
"science",
"Faculty of Made Up Studies",
]);
expect(submittedData.majors).toEqual(["Computer Science", "Statistics"]);
});

it("does not mutate the object read from the cookie", async () => {
it("defaults majors to an empty array when absent from the draft", async () => {
setCookieDraft({
page: "final",
pageStack: ["start", "newMember", "newUoa"],
faculty: ["other"],
otherFaculty: "Faculty of Made Up Studies",
faculty: ["science"],
});
const rawBefore = cookieStore.get("formState")!;

const fd = buildFormData({
page: "final",
linuxSkillLevel: "BEGINNER_USER",
});

await expect(submitRegistrationStep(null, fd)).rejects.toThrow(
"REDIRECT:/registration/success",
);

// Re-parsing the string captured *before* the call proves nothing
// mutated the underlying data during the request — this is the
// test for the prev.faculty = [...] mutation bug.
const reparsed = JSON.parse(rawBefore);
expect(reparsed.faculty).toEqual(["other"]);
const submittedData = submitMemberRegistrationMock.mock.calls[0][0];
expect(submittedData.majors).toEqual([]);
});
});

Expand Down
73 changes: 45 additions & 28 deletions src/app/registration/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
VALID_SKILL_LEVELS,
VALID_YEAR_LEVELS,
MAX_LENGTHS,
MAX_FACULTIES,
MAX_MAJORS,
} from "@/domain/member/constants";

import { exceedsMax } from "@/domain/member/exceedsMax";
Expand Down Expand Up @@ -47,32 +49,29 @@ function stripIrrelevantFields(
lastName,
isCurrentUoaStudent,
faculty,
otherFaculty,
programme,
yearLevel,
majors,
majorCount,
primaryAffiliation,
nonUoaExcerpt,
nonUoaPitch,
...stripped
} = draftFields;
return stripped;
} else if (lastPage == "newUoa") {
const {
primaryAffiliation,
nonUoaExcerpt,
nonUoaPitch,
otherFaculty,
...stripped
} = draftFields;
const { primaryAffiliation, nonUoaExcerpt, nonUoaPitch, ...stripped } =
draftFields;
return stripped;
} else {
const {
upi,
studentId,
faculty,
otherFaculty,
programme,
yearLevel,
majors,
majorCount,
...stripped
} = draftFields;
return stripped;
Expand All @@ -91,6 +90,7 @@ function toParsedSubmission(
upi: draft.upi ?? null,
studentId: draft.studentId ?? null,
faculty: draft.faculty ?? [],
majors: draft.majors ?? [],
programme: draft.programme ?? null,
yearLevel: draft.yearLevel ?? null,
primaryAffiliation: draft.primaryAffiliation ?? null,
Expand Down Expand Up @@ -227,18 +227,36 @@ export async function submitRegistrationStep(
const upi = formData.get("upi") as string;
const studentId = formData.get("studentId") as string;
const faculty = formData.getAll("faculty") as string[];
const otherFaculty = formData.get("otherFaculty") as string;
const majors = (formData.getAll("majors") as string[])
.map((major) => major.trim())
.filter((major) => major !== "");
const majorCount = Math.min(
Number(formData.get("majorCount")) || prev.majorCount || 1,
MAX_MAJORS,
);
const programme = formData.get("programme") as string;
const yearLevel = formData.get("yearLevel") as string;
const fields = {
upi,
studentId,
faculty,
otherFaculty,
majors,
majorCount,
programme,
yearLevel,
};

if (intent == "addMajor") {
const newDraft: Partial<RegistrationDraft> = {
...prev,
...fields,
majorCount: Math.min(majorCount + 1, MAX_MAJORS),
};

cookieStore.set("formState", JSON.stringify(newDraft), COOKIE_OPTIONS);
redirect("/registration");
}

if (!upi) {
return { error: "UPI is required.", fields };
}
Expand All @@ -252,17 +270,27 @@ export async function submitRegistrationStep(
return { error: "Student ID must be 9-10 digits.", fields };
}

if (faculty.length == 0 && !otherFaculty) {
if (faculty.length == 0) {
return { error: "Please select at least 1 faculty.", fields };
}

if (faculty.includes("other") && !otherFaculty) {
return { error: "Please specify your other faculty.", fields };
if (faculty.length > MAX_FACULTIES) {
return {
error: `Please select at most ${MAX_FACULTIES} faculties.`,
fields,
};
}

if (majors.length > MAX_MAJORS) {
return {
error: `Please enter at most ${MAX_MAJORS} majors.`,
fields,
};
}

if (exceedsMax(otherFaculty, "otherFaculty")) {
if (majors.some((major) => exceedsMax(major, "major"))) {
return {
error: `Other faculty must be under ${MAX_LENGTHS.otherFaculty} characters.`,
error: `Each major must be under ${MAX_LENGTHS.major} characters.`,
fields,
};
}
Expand Down Expand Up @@ -387,20 +415,9 @@ export async function submitRegistrationStep(
};
}

// Merge otherFaculty into faculty without mutating prev
const mergedPrev = prev.otherFaculty
? {
...prev,
faculty: [
...(prev.faculty ?? []).filter((f) => f !== "other"),
prev.otherFaculty,
],
}
: prev;

// Merge final step data with full draft
const fullDraft: Partial<RegistrationDraft> = {
...stripIrrelevantFields(mergedPrev),
...stripIrrelevantFields(prev),
linuxSkillLevel,
potentialInvolvement,
discordUsername,
Expand Down
Loading
Loading