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
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { Loader } from "@components/ui/Loader";
import { Center } from "@mantine/core";
import { assignmentActions } from "@store/assignment/assignment.logic";
import {
useBulkRemoveAssignmentsMutation,
useBulkUpdateAssignmentsMutation,
useCreateAssignmentMutation,
useDuplicateAssignmentMutation,
useGetAssignmentsQuery,
useRemoveAssignmentMutation,
useUpdateAssignmentMutation,
useDuplicateAssignmentMutation
useUpdateAssignmentMutation
} from "@store/assignment/assignment.logic.api";
import {
useGetAutoGradeOptionsQuery,
Expand All @@ -22,7 +24,12 @@ import { useDispatch } from "react-redux";
import { useSelectedAssignment } from "@/hooks/useSelectedAssignment";
import { Assignment, CreateAssignmentPayload } from "@/types/assignment";

import { saveEnforceDue, saveVisibility } from "./assignmentMutationHandlers";
import {
saveBulkEnforceDue,
saveBulkVisibility,
saveEnforceDue,
saveVisibility
} from "./assignmentMutationHandlers";
import { ErrorState } from "./components/ErrorState/ErrorState";
import { AssignmentEdit } from "./components/edit/AssignmentEdit";
import { ImportAssignmentModal } from "./components/importAssignment/ImportAssignmentModal";
Expand All @@ -44,6 +51,8 @@ export const AssignmentBuilder = () => {
const [updateAssignment] = useUpdateAssignmentMutation();
const [removeAssignment] = useRemoveAssignmentMutation();
const [duplicateAssignment] = useDuplicateAssignmentMutation();
const [bulkUpdateAssignments] = useBulkUpdateAssignmentsMutation();
const [bulkRemoveAssignments] = useBulkRemoveAssignmentsMutation();

// Load all required data

Expand Down Expand Up @@ -136,6 +145,33 @@ export const AssignmentBuilder = () => {
[updateAssignment]
);

const handleBulkVisibilityChange = useCallback(
async (
targets: Assignment[],
data: { visible: boolean; visible_on: string | null; hidden_on: string | null }
) => {
await saveBulkVisibility(bulkUpdateAssignments, targets, data);
},
[bulkUpdateAssignments]
);

const handleBulkEnforceDueChange = useCallback(
async (targets: Assignment[], enforce_due: boolean) => {
await saveBulkEnforceDue(bulkUpdateAssignments, targets, enforce_due);
},
[bulkUpdateAssignments]
);

const handleBulkRemove = useCallback(
async (targets: Assignment[]) => {
if (targets.length === 0) {
return;
}
await bulkRemoveAssignments(targets);
},
[bulkRemoveAssignments]
);

const handleWizardComplete = async () => {
const formValues = getValues();
const payload: CreateAssignmentPayload = {
Expand Down Expand Up @@ -198,6 +234,9 @@ export const AssignmentBuilder = () => {
onImport={() => setImportModalVisible(true)}
onVisibilityChange={handleVisibilityChange}
onRemove={onRemove}
onBulkVisibilityChange={handleBulkVisibilityChange}
onBulkEnforceDueChange={handleBulkEnforceDueChange}
onBulkRemove={handleBulkRemove}
/>
)}
<ImportAssignmentModal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ import { Assignment } from "@/types/assignment";
import { notify } from "@components/ui/notify";

import {
BulkUpdateAssignmentsTrigger,
getBulkEnforceDueToastCopy,
getBulkUpdateErrorToastCopy,
getBulkVisibilityToastCopy,
getEnforceDueToastCopy,
getVisibilityToastCopy,
saveBulkEnforceDue,
saveBulkVisibility,
saveEnforceDue,
saveVisibility,
UpdateAssignmentTrigger
Expand Down Expand Up @@ -140,5 +146,137 @@ describe("getVisibilityToastCopy", () => {
})
).toBe("Assignment visibility is scheduled");
});
});

const makeBulkTrigger = (result: {
succeeded: number;
failed: number;
}): BulkUpdateAssignmentsTrigger => vi.fn(() => ({ unwrap: () => Promise.resolve(result) }));

const makeRejectingBulkTrigger = (): BulkUpdateAssignmentsTrigger =>
vi.fn(() => ({ unwrap: () => Promise.reject(new Error("bulk update failed")) }));

describe("saveBulkVisibility", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("merges the update into every assignment and toasts a bulk success", async () => {
const trigger = makeBulkTrigger({ succeeded: 2, failed: 0 });
const assignments = [
makeAssignment({ id: 1 }),
makeAssignment({ id: 2, name: "Homework 2" })
];
const update = { visible: true, visible_on: null, hidden_on: null };

await saveBulkVisibility(trigger, assignments, update);

expect(trigger).toHaveBeenCalledWith(
assignments.map((assignment) => ({ ...assignment, ...update }))
);
expect(notify.success).toHaveBeenCalledWith("2 assignments now visible");
expect(notify.error).not.toHaveBeenCalled();
});

it("toasts both success and error copies on a partial failure", async () => {
const trigger = makeBulkTrigger({ succeeded: 1, failed: 2 });

await saveBulkVisibility(trigger, [makeAssignment(), makeAssignment(), makeAssignment()], {
visible: false,
visible_on: null,
hidden_on: null
});

expect(notify.success).toHaveBeenCalledWith("1 assignment now hidden");
expect(notify.error).toHaveBeenCalledWith("Couldn't update 2 assignments. Try again.");
});

it("only toasts the error copy when the mutation rejects", async () => {
await saveBulkVisibility(makeRejectingBulkTrigger(), [makeAssignment()], {
visible: true,
visible_on: null,
hidden_on: null
});

expect(notify.success).not.toHaveBeenCalled();
expect(notify.error).toHaveBeenCalledWith("Couldn't update 1 assignment. Try again.");
});

it("does nothing for an empty selection", async () => {
const trigger = makeBulkTrigger({ succeeded: 0, failed: 0 });

await saveBulkVisibility(trigger, [], { visible: true, visible_on: null, hidden_on: null });

expect(trigger).not.toHaveBeenCalled();
expect(notify.success).not.toHaveBeenCalled();
expect(notify.error).not.toHaveBeenCalled();
});
});

describe("saveBulkEnforceDue", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("applies the enforce_due patch to every assignment and toasts success", async () => {
const trigger = makeBulkTrigger({ succeeded: 2, failed: 0 });
const assignments = [makeAssignment({ id: 1 }), makeAssignment({ id: 2 })];

await saveBulkEnforceDue(trigger, assignments, true);

expect(trigger).toHaveBeenCalledWith(
assignments.map((assignment) => ({ ...assignment, enforce_due: true }))
);
expect(notify.success).toHaveBeenCalledWith("Late submissions not allowed for 2 assignments");
});

it("toasts the allowed copy when enforce_due is turned off", async () => {
await saveBulkEnforceDue(
makeBulkTrigger({ succeeded: 1, failed: 0 }),
[makeAssignment()],
false
);

expect(notify.success).toHaveBeenCalledWith("Late submissions allowed for 1 assignment");
});

it("does nothing for an empty selection", async () => {
const trigger = makeBulkTrigger({ succeeded: 0, failed: 0 });

await saveBulkEnforceDue(trigger, [], true);

expect(trigger).not.toHaveBeenCalled();
});
});

describe("bulk toast copy helpers", () => {
it("describes bulk visibility per mode with singular and plural subjects", () => {
expect(
getBulkVisibilityToastCopy(1, { visible: true, visible_on: null, hidden_on: null })
).toBe("1 assignment now visible");
expect(
getBulkVisibilityToastCopy(3, { visible: false, visible_on: null, hidden_on: null })
).toBe("3 assignments now hidden");
expect(
getBulkVisibilityToastCopy(2, {
visible: false,
visible_on: "2026-06-20T00:00:00",
hidden_on: null
})
).toBe("Visibility scheduled for 2 assignments");
});

it("describes bulk enforce_due copy", () => {
expect(getBulkEnforceDueToastCopy(2, true)).toBe(
"Late submissions not allowed for 2 assignments"
);
expect(getBulkEnforceDueToastCopy(1, false)).toBe(
"Late submissions allowed for 1 assignment"
);
});

it("phrases the bulk error as couldn't-verb plus a fix", () => {
expect(getBulkUpdateErrorToastCopy(1)).toBe("Couldn't update 1 assignment. Try again.");
expect(getBulkUpdateErrorToastCopy(4)).toBe("Couldn't update 4 assignments. Try again.");
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Assignment } from "@/types/assignment";
import { notify } from "@components/ui/notify";

import { BulkActionResult } from "@store/assignment/assignment.logic.api";
import { getVisibilityMode } from "./components/edit/visibilityMode";

export interface VisibilityUpdate {
Expand Down Expand Up @@ -59,3 +59,87 @@ export const saveVisibility = async (
notify.success(getVisibilityToastCopy(update));
}
};

interface BulkUnwrappableResult {
unwrap: () => Promise<BulkActionResult>;
}

export type BulkUpdateAssignmentsTrigger = (assignments: Assignment[]) => BulkUnwrappableResult;

const pluralizeAssignments = (count: number): string =>
`${count} ${count === 1 ? "assignment" : "assignments"}`;

export const getBulkVisibilityToastCopy = (count: number, update: VisibilityUpdate): string => {
const mode = getVisibilityMode(update.visible, update.visible_on, update.hidden_on);
const subject = pluralizeAssignments(count);

if (mode === "visible") {
return `${subject} now visible`;
}
if (mode === "hidden") {
return `${subject} now hidden`;
}
return `Visibility scheduled for ${subject}`;
};

export const getBulkEnforceDueToastCopy = (count: number, enforceDue: boolean): string => {
const subject = pluralizeAssignments(count);

return enforceDue
? `Late submissions not allowed for ${subject}`
: `Late submissions allowed for ${subject}`;
};

export const getBulkUpdateErrorToastCopy = (failed: number): string =>
`Couldn't update ${pluralizeAssignments(failed)}. Try again.`;

const runBulkUpdate = async (
bulkUpdateAssignments: BulkUpdateAssignmentsTrigger,
assignments: Assignment[],
patch: Partial<Assignment>
): Promise<BulkActionResult> => {
try {
return await bulkUpdateAssignments(
assignments.map((assignment) => ({ ...assignment, ...patch }))
).unwrap();
} catch {
return { succeeded: 0, failed: assignments.length };
}
};

const notifyBulkResult = (result: BulkActionResult, successCopy: string): void => {
if (result.succeeded > 0) {
notify.success(successCopy);
}
if (result.failed > 0) {
notify.error(getBulkUpdateErrorToastCopy(result.failed));
}
};

export const saveBulkVisibility = async (
bulkUpdateAssignments: BulkUpdateAssignmentsTrigger,
assignments: Assignment[],
update: VisibilityUpdate
): Promise<void> => {
if (assignments.length === 0) {
return;
}
const result = await runBulkUpdate(bulkUpdateAssignments, assignments, update);

notifyBulkResult(result, getBulkVisibilityToastCopy(result.succeeded, update));
};

export const saveBulkEnforceDue = async (
bulkUpdateAssignments: BulkUpdateAssignmentsTrigger,
assignments: Assignment[],
enforceDue: boolean
): Promise<void> => {
if (assignments.length === 0) {
return;
}
const result = await runBulkUpdate(bulkUpdateAssignments, assignments, {
enforce_due: enforceDue
});

notifyBulkResult(result, getBulkEnforceDueToastCopy(result.succeeded, enforceDue));
};
Loading
Loading