Skip to content

Commit 02230f7

Browse files
committed
Merge branch 'f-1193-1' of github.com:morozov-av/rs into morozov-av-f-1193-1
2 parents cfd1a63 + 55bcd4e commit 02230f7

13 files changed

Lines changed: 1124 additions & 37 deletions

File tree

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import { Loader } from "@components/ui/Loader";
22
import { Center } from "@mantine/core";
33
import { assignmentActions } from "@store/assignment/assignment.logic";
44
import {
5+
useBulkRemoveAssignmentsMutation,
6+
useBulkUpdateAssignmentsMutation,
57
useCreateAssignmentMutation,
8+
useDuplicateAssignmentMutation,
69
useGetAssignmentsQuery,
710
useRemoveAssignmentMutation,
8-
useUpdateAssignmentMutation,
9-
useDuplicateAssignmentMutation
11+
useUpdateAssignmentMutation
1012
} from "@store/assignment/assignment.logic.api";
1113
import {
1214
useGetAutoGradeOptionsQuery,
@@ -22,7 +24,12 @@ import { useDispatch } from "react-redux";
2224
import { useSelectedAssignment } from "@/hooks/useSelectedAssignment";
2325
import { Assignment, CreateAssignmentPayload } from "@/types/assignment";
2426

25-
import { saveEnforceDue, saveVisibility } from "./assignmentMutationHandlers";
27+
import {
28+
saveBulkEnforceDue,
29+
saveBulkVisibility,
30+
saveEnforceDue,
31+
saveVisibility
32+
} from "./assignmentMutationHandlers";
2633
import { ErrorState } from "./components/ErrorState/ErrorState";
2734
import { AssignmentEdit } from "./components/edit/AssignmentEdit";
2835
import { ImportAssignmentModal } from "./components/importAssignment/ImportAssignmentModal";
@@ -44,6 +51,8 @@ export const AssignmentBuilder = () => {
4451
const [updateAssignment] = useUpdateAssignmentMutation();
4552
const [removeAssignment] = useRemoveAssignmentMutation();
4653
const [duplicateAssignment] = useDuplicateAssignmentMutation();
54+
const [bulkUpdateAssignments] = useBulkUpdateAssignmentsMutation();
55+
const [bulkRemoveAssignments] = useBulkRemoveAssignmentsMutation();
4756

4857
// Load all required data
4958

@@ -136,6 +145,33 @@ export const AssignmentBuilder = () => {
136145
[updateAssignment]
137146
);
138147

148+
const handleBulkVisibilityChange = useCallback(
149+
async (
150+
targets: Assignment[],
151+
data: { visible: boolean; visible_on: string | null; hidden_on: string | null }
152+
) => {
153+
await saveBulkVisibility(bulkUpdateAssignments, targets, data);
154+
},
155+
[bulkUpdateAssignments]
156+
);
157+
158+
const handleBulkEnforceDueChange = useCallback(
159+
async (targets: Assignment[], enforce_due: boolean) => {
160+
await saveBulkEnforceDue(bulkUpdateAssignments, targets, enforce_due);
161+
},
162+
[bulkUpdateAssignments]
163+
);
164+
165+
const handleBulkRemove = useCallback(
166+
async (targets: Assignment[]) => {
167+
if (targets.length === 0) {
168+
return;
169+
}
170+
await bulkRemoveAssignments(targets);
171+
},
172+
[bulkRemoveAssignments]
173+
);
174+
139175
const handleWizardComplete = async () => {
140176
const formValues = getValues();
141177
const payload: CreateAssignmentPayload = {
@@ -198,6 +234,9 @@ export const AssignmentBuilder = () => {
198234
onImport={() => setImportModalVisible(true)}
199235
onVisibilityChange={handleVisibilityChange}
200236
onRemove={onRemove}
237+
onBulkVisibilityChange={handleBulkVisibilityChange}
238+
onBulkEnforceDueChange={handleBulkEnforceDueChange}
239+
onBulkRemove={handleBulkRemove}
201240
/>
202241
)}
203242
<ImportAssignmentModal

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.spec.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import { Assignment } from "@/types/assignment";
44
import { notify } from "@components/ui/notify";
55

66
import {
7+
BulkUpdateAssignmentsTrigger,
8+
getBulkEnforceDueToastCopy,
9+
getBulkUpdateErrorToastCopy,
10+
getBulkVisibilityToastCopy,
711
getEnforceDueToastCopy,
812
getVisibilityToastCopy,
13+
saveBulkEnforceDue,
14+
saveBulkVisibility,
915
saveEnforceDue,
1016
saveVisibility,
1117
UpdateAssignmentTrigger
@@ -140,5 +146,137 @@ describe("getVisibilityToastCopy", () => {
140146
})
141147
).toBe("Assignment visibility is scheduled");
142148
});
143-
});
144149

150+
const makeBulkTrigger = (result: {
151+
succeeded: number;
152+
failed: number;
153+
}): BulkUpdateAssignmentsTrigger => vi.fn(() => ({ unwrap: () => Promise.resolve(result) }));
154+
155+
const makeRejectingBulkTrigger = (): BulkUpdateAssignmentsTrigger =>
156+
vi.fn(() => ({ unwrap: () => Promise.reject(new Error("bulk update failed")) }));
157+
158+
describe("saveBulkVisibility", () => {
159+
beforeEach(() => {
160+
vi.clearAllMocks();
161+
});
162+
163+
it("merges the update into every assignment and toasts a bulk success", async () => {
164+
const trigger = makeBulkTrigger({ succeeded: 2, failed: 0 });
165+
const assignments = [
166+
makeAssignment({ id: 1 }),
167+
makeAssignment({ id: 2, name: "Homework 2" })
168+
];
169+
const update = { visible: true, visible_on: null, hidden_on: null };
170+
171+
await saveBulkVisibility(trigger, assignments, update);
172+
173+
expect(trigger).toHaveBeenCalledWith(
174+
assignments.map((assignment) => ({ ...assignment, ...update }))
175+
);
176+
expect(notify.success).toHaveBeenCalledWith("2 assignments now visible");
177+
expect(notify.error).not.toHaveBeenCalled();
178+
});
179+
180+
it("toasts both success and error copies on a partial failure", async () => {
181+
const trigger = makeBulkTrigger({ succeeded: 1, failed: 2 });
182+
183+
await saveBulkVisibility(trigger, [makeAssignment(), makeAssignment(), makeAssignment()], {
184+
visible: false,
185+
visible_on: null,
186+
hidden_on: null
187+
});
188+
189+
expect(notify.success).toHaveBeenCalledWith("1 assignment now hidden");
190+
expect(notify.error).toHaveBeenCalledWith("Couldn't update 2 assignments. Try again.");
191+
});
192+
193+
it("only toasts the error copy when the mutation rejects", async () => {
194+
await saveBulkVisibility(makeRejectingBulkTrigger(), [makeAssignment()], {
195+
visible: true,
196+
visible_on: null,
197+
hidden_on: null
198+
});
199+
200+
expect(notify.success).not.toHaveBeenCalled();
201+
expect(notify.error).toHaveBeenCalledWith("Couldn't update 1 assignment. Try again.");
202+
});
203+
204+
it("does nothing for an empty selection", async () => {
205+
const trigger = makeBulkTrigger({ succeeded: 0, failed: 0 });
206+
207+
await saveBulkVisibility(trigger, [], { visible: true, visible_on: null, hidden_on: null });
208+
209+
expect(trigger).not.toHaveBeenCalled();
210+
expect(notify.success).not.toHaveBeenCalled();
211+
expect(notify.error).not.toHaveBeenCalled();
212+
});
213+
});
214+
215+
describe("saveBulkEnforceDue", () => {
216+
beforeEach(() => {
217+
vi.clearAllMocks();
218+
});
219+
220+
it("applies the enforce_due patch to every assignment and toasts success", async () => {
221+
const trigger = makeBulkTrigger({ succeeded: 2, failed: 0 });
222+
const assignments = [makeAssignment({ id: 1 }), makeAssignment({ id: 2 })];
223+
224+
await saveBulkEnforceDue(trigger, assignments, true);
225+
226+
expect(trigger).toHaveBeenCalledWith(
227+
assignments.map((assignment) => ({ ...assignment, enforce_due: true }))
228+
);
229+
expect(notify.success).toHaveBeenCalledWith("Late submissions not allowed for 2 assignments");
230+
});
231+
232+
it("toasts the allowed copy when enforce_due is turned off", async () => {
233+
await saveBulkEnforceDue(
234+
makeBulkTrigger({ succeeded: 1, failed: 0 }),
235+
[makeAssignment()],
236+
false
237+
);
238+
239+
expect(notify.success).toHaveBeenCalledWith("Late submissions allowed for 1 assignment");
240+
});
241+
242+
it("does nothing for an empty selection", async () => {
243+
const trigger = makeBulkTrigger({ succeeded: 0, failed: 0 });
244+
245+
await saveBulkEnforceDue(trigger, [], true);
246+
247+
expect(trigger).not.toHaveBeenCalled();
248+
});
249+
});
250+
251+
describe("bulk toast copy helpers", () => {
252+
it("describes bulk visibility per mode with singular and plural subjects", () => {
253+
expect(
254+
getBulkVisibilityToastCopy(1, { visible: true, visible_on: null, hidden_on: null })
255+
).toBe("1 assignment now visible");
256+
expect(
257+
getBulkVisibilityToastCopy(3, { visible: false, visible_on: null, hidden_on: null })
258+
).toBe("3 assignments now hidden");
259+
expect(
260+
getBulkVisibilityToastCopy(2, {
261+
visible: false,
262+
visible_on: "2026-06-20T00:00:00",
263+
hidden_on: null
264+
})
265+
).toBe("Visibility scheduled for 2 assignments");
266+
});
267+
268+
it("describes bulk enforce_due copy", () => {
269+
expect(getBulkEnforceDueToastCopy(2, true)).toBe(
270+
"Late submissions not allowed for 2 assignments"
271+
);
272+
expect(getBulkEnforceDueToastCopy(1, false)).toBe(
273+
"Late submissions allowed for 1 assignment"
274+
);
275+
});
276+
277+
it("phrases the bulk error as couldn't-verb plus a fix", () => {
278+
expect(getBulkUpdateErrorToastCopy(1)).toBe("Couldn't update 1 assignment. Try again.");
279+
expect(getBulkUpdateErrorToastCopy(4)).toBe("Couldn't update 4 assignments. Try again.");
280+
});
281+
});
282+
});

bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Assignment } from "@/types/assignment";
22
import { notify } from "@components/ui/notify";
3-
3+
import { BulkActionResult } from "@store/assignment/assignment.logic.api";
44
import { getVisibilityMode } from "./components/edit/visibilityMode";
55

66
export interface VisibilityUpdate {
@@ -59,3 +59,87 @@ export const saveVisibility = async (
5959
notify.success(getVisibilityToastCopy(update));
6060
}
6161
};
62+
63+
interface BulkUnwrappableResult {
64+
unwrap: () => Promise<BulkActionResult>;
65+
}
66+
67+
export type BulkUpdateAssignmentsTrigger = (assignments: Assignment[]) => BulkUnwrappableResult;
68+
69+
const pluralizeAssignments = (count: number): string =>
70+
`${count} ${count === 1 ? "assignment" : "assignments"}`;
71+
72+
export const getBulkVisibilityToastCopy = (count: number, update: VisibilityUpdate): string => {
73+
const mode = getVisibilityMode(update.visible, update.visible_on, update.hidden_on);
74+
const subject = pluralizeAssignments(count);
75+
76+
if (mode === "visible") {
77+
return `${subject} now visible`;
78+
}
79+
if (mode === "hidden") {
80+
return `${subject} now hidden`;
81+
}
82+
return `Visibility scheduled for ${subject}`;
83+
};
84+
85+
export const getBulkEnforceDueToastCopy = (count: number, enforceDue: boolean): string => {
86+
const subject = pluralizeAssignments(count);
87+
88+
return enforceDue
89+
? `Late submissions not allowed for ${subject}`
90+
: `Late submissions allowed for ${subject}`;
91+
};
92+
93+
export const getBulkUpdateErrorToastCopy = (failed: number): string =>
94+
`Couldn't update ${pluralizeAssignments(failed)}. Try again.`;
95+
96+
const runBulkUpdate = async (
97+
bulkUpdateAssignments: BulkUpdateAssignmentsTrigger,
98+
assignments: Assignment[],
99+
patch: Partial<Assignment>
100+
): Promise<BulkActionResult> => {
101+
try {
102+
return await bulkUpdateAssignments(
103+
assignments.map((assignment) => ({ ...assignment, ...patch }))
104+
).unwrap();
105+
} catch {
106+
return { succeeded: 0, failed: assignments.length };
107+
}
108+
};
109+
110+
const notifyBulkResult = (result: BulkActionResult, successCopy: string): void => {
111+
if (result.succeeded > 0) {
112+
notify.success(successCopy);
113+
}
114+
if (result.failed > 0) {
115+
notify.error(getBulkUpdateErrorToastCopy(result.failed));
116+
}
117+
};
118+
119+
export const saveBulkVisibility = async (
120+
bulkUpdateAssignments: BulkUpdateAssignmentsTrigger,
121+
assignments: Assignment[],
122+
update: VisibilityUpdate
123+
): Promise<void> => {
124+
if (assignments.length === 0) {
125+
return;
126+
}
127+
const result = await runBulkUpdate(bulkUpdateAssignments, assignments, update);
128+
129+
notifyBulkResult(result, getBulkVisibilityToastCopy(result.succeeded, update));
130+
};
131+
132+
export const saveBulkEnforceDue = async (
133+
bulkUpdateAssignments: BulkUpdateAssignmentsTrigger,
134+
assignments: Assignment[],
135+
enforceDue: boolean
136+
): Promise<void> => {
137+
if (assignments.length === 0) {
138+
return;
139+
}
140+
const result = await runBulkUpdate(bulkUpdateAssignments, assignments, {
141+
enforce_due: enforceDue
142+
});
143+
144+
notifyBulkResult(result, getBulkEnforceDueToastCopy(result.succeeded, enforceDue));
145+
};

0 commit comments

Comments
 (0)