From 69f4c44dfcecd37efae06daf5181e20df04a35f6 Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Wed, 16 Sep 2026 08:51:37 +0200 Subject: [PATCH 1/2] Add bulk edit actions to the assignment list --- .../AssignmentBuilder/AssignmentBuilder.tsx | 45 +++++- .../assignmentMutationHandlers.spec.ts | 142 +++++++++++++++- .../assignmentMutationHandlers.ts | 86 +++++++++- .../components/edit/visibilityDates.spec.ts | 111 +++++++++++++ .../components/edit/visibilityDates.ts | 71 ++++++++ .../components/list/AssignmentList.spec.tsx | 85 +++++++++- .../components/list/AssignmentList.tsx | 133 ++++++++++++--- .../components/list/BulkActionsBar.module.css | 15 ++ .../components/list/BulkActionsBar.tsx | 153 ++++++++++++++++++ .../components/list/VisibilityDropdown.tsx | 11 +- .../components/list/VisibilityModeFields.tsx | 121 ++++++++++++++ .../assignment/assignment.logic.api.spec.ts | 98 ++++++++++- .../store/assignment/assignment.logic.api.ts | 71 +++++++- 13 files changed, 1104 insertions(+), 38 deletions(-) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityDates.spec.ts create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityDates.ts create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/BulkActionsBar.module.css create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/BulkActionsBar.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityModeFields.tsx diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx index 851dcb860..47a190ad9 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/AssignmentBuilder.tsx @@ -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, @@ -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"; @@ -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 @@ -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 = { @@ -198,6 +234,9 @@ export const AssignmentBuilder = () => { onImport={() => setImportModalVisible(true)} onVisibilityChange={handleVisibilityChange} onRemove={onRemove} + onBulkVisibilityChange={handleBulkVisibilityChange} + onBulkEnforceDueChange={handleBulkEnforceDueChange} + onBulkRemove={handleBulkRemove} /> )} ({ notify: { show: vi.fn(), - success: vi.fn(), - error: vi.fn(), info: vi.fn(), update: vi.fn(), hide: vi.fn(), @@ -140,5 +144,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."); + }); + }); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.ts index b0b5b3694..a44ed0c7a 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.ts @@ -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 { @@ -59,3 +59,87 @@ export const saveVisibility = async ( notify.success(getVisibilityToastCopy(update)); } }; + +interface BulkUnwrappableResult { + unwrap: () => Promise; +} + +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 +): Promise => { + 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 => { + 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 => { + if (assignments.length === 0) { + return; + } + const result = await runBulkUpdate(bulkUpdateAssignments, assignments, { + enforce_due: enforceDue + }); + + notifyBulkResult(result, getBulkEnforceDueToastCopy(result.succeeded, enforceDue)); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityDates.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityDates.spec.ts new file mode 100644 index 000000000..89e39615d --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityDates.spec.ts @@ -0,0 +1,111 @@ +import { + adjustDatesForHiddenOnChange, + adjustDatesForVisibleOnChange, + applyModeDateDefaults +} from "./visibilityDates"; + +describe("applyModeDateDefaults", () => { + it("keeps both dates untouched for the visible and hidden modes", () => { + expect(applyModeDateDefaults("visible", null, null)).toEqual({ + visibleOn: null, + hiddenOn: null + }); + expect(applyModeDateDefaults("hidden", "2026-06-20T00:00:00", "2026-06-21T23:59:00")).toEqual({ + visibleOn: "2026-06-20T00:00:00", + hiddenOn: "2026-06-21T23:59:00" + }); + }); + + it("defaults a missing visible_on for scheduled_visible", () => { + const result = applyModeDateDefaults("scheduled_visible", null, null); + + expect(result.visibleOn).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + expect(result.hiddenOn).toBeNull(); + }); + + it("defaults a missing hidden_on for scheduled_hidden", () => { + const result = applyModeDateDefaults("scheduled_hidden", null, null); + + expect(result.visibleOn).toBeNull(); + expect(result.hiddenOn).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/); + }); + + it("defaults both missing dates for scheduled_period", () => { + const result = applyModeDateDefaults("scheduled_period", null, null); + + expect(result.visibleOn).not.toBeNull(); + expect(result.hiddenOn).not.toBeNull(); + }); + + it("preserves already-set dates for scheduled modes", () => { + expect( + applyModeDateDefaults("scheduled_period", "2026-06-20T00:00:00", "2026-06-21T23:59:00") + ).toEqual({ visibleOn: "2026-06-20T00:00:00", hiddenOn: "2026-06-21T23:59:00" }); + }); +}); + +describe("adjustDatesForVisibleOnChange", () => { + it("pushes hidden_on one day past a visible_on that crosses it in scheduled_period", () => { + const result = adjustDatesForVisibleOnChange( + "scheduled_period", + "2026-06-22T00:00:00", + "2026-06-21T23:59:00" + ); + + expect(result.visibleOn).toBe("2026-06-22T00:00:00"); + expect(result.hiddenOn).toBe("2026-06-23T00:00:00"); + }); + + it("keeps hidden_on when the new visible_on stays before it", () => { + const result = adjustDatesForVisibleOnChange( + "scheduled_period", + "2026-06-20T00:00:00", + "2026-06-21T23:59:00" + ); + + expect(result.hiddenOn).toBe("2026-06-21T23:59:00"); + }); + + it("does not adjust outside scheduled_period", () => { + const result = adjustDatesForVisibleOnChange( + "scheduled_visible", + "2026-06-22T00:00:00", + "2026-06-21T23:59:00" + ); + + expect(result).toEqual({ visibleOn: "2026-06-22T00:00:00", hiddenOn: "2026-06-21T23:59:00" }); + }); +}); + +describe("adjustDatesForHiddenOnChange", () => { + it("pulls visible_on one day before a hidden_on that crosses it in scheduled_period", () => { + const result = adjustDatesForHiddenOnChange( + "scheduled_period", + "2026-06-20T12:00:00", + "2026-06-19T00:00:00" + ); + + expect(result.hiddenOn).toBe("2026-06-19T00:00:00"); + expect(result.visibleOn).toBe("2026-06-18T00:00:00"); + }); + + it("keeps visible_on when the new hidden_on stays after it", () => { + const result = adjustDatesForHiddenOnChange( + "scheduled_period", + "2026-06-20T00:00:00", + "2026-06-21T23:59:00" + ); + + expect(result.visibleOn).toBe("2026-06-20T00:00:00"); + }); + + it("does not adjust outside scheduled_period", () => { + const result = adjustDatesForHiddenOnChange( + "scheduled_hidden", + "2026-06-20T12:00:00", + "2026-06-19T00:00:00" + ); + + expect(result).toEqual({ visibleOn: "2026-06-20T12:00:00", hiddenOn: "2026-06-19T00:00:00" }); + }); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityDates.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityDates.ts new file mode 100644 index 000000000..adb5af0e3 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/visibilityDates.ts @@ -0,0 +1,71 @@ +import { convertDateToISO, parseUTCDate } from "@/utils/date"; + +import { VisibilityMode } from "./visibilityMode"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface VisibilityDates { + visibleOn: string | null; + hiddenOn: string | null; +} + +export const applyModeDateDefaults = ( + mode: VisibilityMode, + visibleOn: string | null, + hiddenOn: string | null +): VisibilityDates => { + let nextVisibleOn = visibleOn; + let nextHiddenOn = hiddenOn; + + if ((mode === "scheduled_visible" || mode === "scheduled_period") && !nextVisibleOn) { + const startOfDay = new Date(); + + startOfDay.setHours(0, 0, 0, 0); + nextVisibleOn = convertDateToISO(startOfDay); + } + if ((mode === "scheduled_hidden" || mode === "scheduled_period") && !nextHiddenOn) { + const endOfDay = new Date(); + + endOfDay.setHours(23, 59, 0, 0); + nextHiddenOn = convertDateToISO(endOfDay); + } + return { visibleOn: nextVisibleOn, hiddenOn: nextHiddenOn }; +}; + +export const adjustDatesForVisibleOnChange = ( + mode: VisibilityMode, + visibleOn: string, + hiddenOn: string | null +): VisibilityDates => { + if (mode === "scheduled_period" && hiddenOn) { + const newVisibleDate = parseUTCDate(visibleOn); + const currentHiddenDate = parseUTCDate(hiddenOn); + + if (newVisibleDate >= currentHiddenDate) { + return { + visibleOn, + hiddenOn: convertDateToISO(new Date(newVisibleDate.getTime() + DAY_MS)) + }; + } + } + return { visibleOn, hiddenOn }; +}; + +export const adjustDatesForHiddenOnChange = ( + mode: VisibilityMode, + visibleOn: string | null, + hiddenOn: string +): VisibilityDates => { + if (mode === "scheduled_period" && visibleOn) { + const newHiddenDate = parseUTCDate(hiddenOn); + const currentVisibleDate = parseUTCDate(visibleOn); + + if (newHiddenDate <= currentVisibleDate) { + return { + visibleOn: convertDateToISO(new Date(newHiddenDate.getTime() - DAY_MS)), + hiddenOn + }; + } + } + return { visibleOn, hiddenOn }; +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.spec.tsx index 6d8d1b91f..2c048e2b4 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.spec.tsx @@ -37,7 +37,10 @@ const baseProps = () => ({ onEnforceDueChange: vi.fn(), onImport: vi.fn(), onVisibilityChange: vi.fn(), - onRemove: vi.fn() + onRemove: vi.fn(), + onBulkVisibilityChange: vi.fn(), + onBulkEnforceDueChange: vi.fn(), + onBulkRemove: vi.fn() }); describe("AssignmentList", () => { @@ -108,6 +111,7 @@ describe("AssignmentList", () => { fireEvent.click(cells[1]); fireEvent.click(cells[2]); fireEvent.click(cells[3]); + fireEvent.click(cells[4]); expect(props.onEdit).toHaveBeenCalledTimes(3); expect(props.onEdit).toHaveBeenCalledWith(ASSIGNMENTS[1]); @@ -204,4 +208,83 @@ describe("AssignmentList", () => { expect(rows[0]).toHaveTextContent("Charlie"); }); + + it("hides the bulk actions bar until a row is selected", () => { + renderWithMantine(); + + expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select Bravo" })); + + expect(screen.getByRole("toolbar", { name: "Bulk actions" })).toBeInTheDocument(); + expect(screen.getByText("1 selected")).toBeInTheDocument(); + }); + + it("selects every row through the header checkbox", () => { + renderWithMantine(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select all assignments" })); + + expect(screen.getByText("3 selected")).toBeInTheDocument(); + }); + + it("clears the selection from the bulk actions bar", () => { + renderWithMantine(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select all assignments" })); + fireEvent.click(screen.getByRole("button", { name: "Clear selection" })); + + expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument(); + }); + + it("applies a bulk visibility change to the selected assignments and clears the selection", () => { + const props = baseProps(); + + renderWithMantine(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select Alpha" })); + fireEvent.click(screen.getByRole("checkbox", { name: "Select Bravo" })); + fireEvent.click(screen.getByRole("button", { name: "Visibility" })); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + + expect(props.onBulkVisibilityChange).toHaveBeenCalledWith([ASSIGNMENTS[0], ASSIGNMENTS[1]], { + visible: true, + visible_on: null, + hidden_on: null + }); + expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument(); + }); + + it("applies a bulk late-submissions change to the selected assignments", () => { + const props = baseProps(); + + renderWithMantine(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select Charlie" })); + fireEvent.click(screen.getByRole("button", { name: "Late submissions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Don't allow late submissions" })); + + expect(props.onBulkEnforceDueChange).toHaveBeenCalledWith([ASSIGNMENTS[2]], true); + }); + + it("confirms a bulk delete before calling onBulkRemove", async () => { + const props = baseProps(); + + renderWithMantine(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select all assignments" })); + + const toolbar = screen.getByRole("toolbar", { name: "Bulk actions" }); + + fireEvent.click(within(toolbar).getByRole("button", { name: "Delete" })); + + expect(await screen.findByText("Delete assignments")).toBeInTheDocument(); + expect(props.onBulkRemove).not.toHaveBeenCalled(); + + const dialog = screen.getByRole("dialog"); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete" })); + + expect(props.onBulkRemove).toHaveBeenCalledWith(ASSIGNMENTS); + }); }); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx index 099a31d72..7389558bf 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx @@ -1,19 +1,29 @@ -import { useCallback, useMemo, useState } from "react"; - import { DataGrid } from "@components/ui/DataGrid"; import { Icon } from "@components/ui/Icon"; import { SearchInput } from "@components/ui/SearchInput"; -import { ActionIcon, Button, Group, Skeleton, Switch, Text, Tooltip } from "@mantine/core"; +import { + ActionIcon, + Button, + Checkbox, + Group, + Skeleton, + Switch, + Text, + Tooltip +} from "@mantine/core"; import { modals } from "@mantine/modals"; -import { ColumnDef, OnChangeFn, SortingState } from "@tanstack/react-table"; +import { ColumnDef, OnChangeFn, RowSelectionState, SortingState } from "@tanstack/react-table"; import classNames from "classnames"; +import { useCallback, useMemo, useState } from "react"; import { Assignment } from "@/types/assignment"; import { formatUTCDateForDisplay } from "@/utils/date"; -import { VisibilityDropdown } from "./VisibilityDropdown"; +import { VisibilityValues } from "../edit/visibilityMode"; import styles from "./AssignmentList.module.css"; +import { BulkActionsBar } from "./BulkActionsBar"; +import { VisibilityDropdown } from "./VisibilityDropdown"; interface AssignmentListProps { assignments: Assignment[]; @@ -30,6 +40,9 @@ interface AssignmentListProps { data: { visible: boolean; visible_on: string | null; hidden_on: string | null } ) => void; onRemove: (assignment: Assignment) => void; + onBulkVisibilityChange: (assignments: Assignment[], data: VisibilityValues) => void; + onBulkEnforceDueChange: (assignments: Assignment[], enforce_due: boolean) => void; + onBulkRemove: (assignments: Assignment[]) => void; } const SORT_STORAGE_KEY = "assignmentList_sortField"; @@ -73,8 +86,13 @@ export const AssignmentList = ({ onEnforceDueChange, onImport, onRemove, - onVisibilityChange + onVisibilityChange, + onBulkVisibilityChange, + onBulkEnforceDueChange, + onBulkRemove }: AssignmentListProps) => { + const [rowSelection, setRowSelection] = useState({}); + const [sortField, setSortField] = useState( () => localStorage.getItem(SORT_STORAGE_KEY) || "name" ); @@ -114,6 +132,34 @@ export const AssignmentList = ({ return assignments.filter((a) => a.name?.toLowerCase().includes(query)); }, [assignments, globalFilter]); + const selectedAssignments = useMemo( + () => assignments.filter((assignment) => rowSelection[String(assignment.id)]), + [assignments, rowSelection] + ); + + const clearSelection = useCallback(() => setRowSelection({}), []); + + const handleBulkVisibilityApply = useCallback( + (values: VisibilityValues) => { + onBulkVisibilityChange(selectedAssignments, values); + clearSelection(); + }, + [onBulkVisibilityChange, selectedAssignments, clearSelection] + ); + + const handleBulkEnforceDueApply = useCallback( + (enforceDue: boolean) => { + onBulkEnforceDueChange(selectedAssignments, enforceDue); + clearSelection(); + }, + [onBulkEnforceDueChange, selectedAssignments, clearSelection] + ); + + const handleBulkDelete = useCallback(() => { + onBulkRemove(selectedAssignments); + clearSelection(); + }, [onBulkRemove, selectedAssignments, clearSelection]); + const confirmRemove = useCallback( (rowData: Assignment) => { modals.openConfirmModal({ @@ -129,8 +175,35 @@ export const AssignmentList = ({ [onRemove] ); + const selectColumn: ColumnDef = { + id: "select", + enableSorting: false, + meta: { + headerStyle: { width: 40 }, + align: "center" + }, + header: ({ table }) => ( + + ), + cell: ({ row }) => ( + + ) + }; + const columns = useMemo[]>( () => [ + ...(filteredAssignments.length > 1 ? [selectColumn] : []), { accessorKey: "name", header: "Name", @@ -284,11 +357,13 @@ export const AssignmentList = ({ } ], [ - confirmRemove, - onDuplicate, + filteredAssignments.length, + selectColumn, onEdit, onEnforceDueChange, - onVisibilityChange + onVisibilityChange, + onDuplicate, + confirmRemove ] ); @@ -340,19 +415,33 @@ export const AssignmentList = ({ ) : ( -
- String(row.id)} - sorting={sorting} - onSortingChange={handleSortingChange} - emptyMessage="No assignments match your search" - ariaLabel="Assignments" - minWidth={TABLE_MIN_WIDTH} - enableSortingRemoval={false} - /> -
+ <> + {selectedAssignments.length > 0 && ( + + )} +
+ String(row.id)} + sorting={sorting} + onSortingChange={handleSortingChange} + enableRowSelection + rowSelection={rowSelection} + onRowSelectionChange={setRowSelection} + emptyMessage="No assignments match your search" + ariaLabel="Assignments" + minWidth={TABLE_MIN_WIDTH} + enableSortingRemoval={false} + /> +
+ )} ); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/BulkActionsBar.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/BulkActionsBar.module.css new file mode 100644 index 000000000..e4bfd0d03 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/BulkActionsBar.module.css @@ -0,0 +1,15 @@ +.bar { + background: var(--rs-brand-50); + border: 1px solid var(--rs-border-solid); + border-radius: var(--rs-radius-lg); + padding: var(--rs-space-2) var(--rs-space-4); +} + +.count { + color: var(--rs-brand-700); + font-variant-numeric: tabular-nums; +} + +.deleteButton { + color: var(--rs-danger-text); +} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/BulkActionsBar.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/BulkActionsBar.tsx new file mode 100644 index 000000000..96a343eb2 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/BulkActionsBar.tsx @@ -0,0 +1,153 @@ +import { Button, Group, Menu, Popover, Stack, Text } from "@mantine/core"; +import { modals } from "@mantine/modals"; +import { useState } from "react"; + +import { Icon } from "@components/ui/Icon"; + +import { + adjustDatesForHiddenOnChange, + adjustDatesForVisibleOnChange, + applyModeDateDefaults +} from "../edit/visibilityDates"; +import { getVisibilityValues, VisibilityMode, VisibilityValues } from "../edit/visibilityMode"; + +import { VisibilityModeFields } from "./VisibilityModeFields"; + +import styles from "./BulkActionsBar.module.css"; + +interface BulkActionsBarProps { + selectedCount: number; + onVisibilityApply: (values: VisibilityValues) => void; + onEnforceDueApply: (enforceDue: boolean) => void; + onDelete: () => void; + onClear: () => void; +} + +export const BulkActionsBar = ({ + selectedCount, + onVisibilityApply, + onEnforceDueApply, + onDelete, + onClear +}: BulkActionsBarProps) => { + const [visibilityOpened, setVisibilityOpened] = useState(false); + const [mode, setMode] = useState("visible"); + const [visibleOn, setVisibleOn] = useState(null); + const [hiddenOn, setHiddenOn] = useState(null); + + const handleModeChange = (newMode: VisibilityMode) => { + const dates = applyModeDateDefaults(newMode, visibleOn, hiddenOn); + + setMode(newMode); + setVisibleOn(dates.visibleOn); + setHiddenOn(dates.hiddenOn); + }; + + const handleVisibleOnChange = (val: string) => { + const dates = adjustDatesForVisibleOnChange(mode, val, hiddenOn); + + setVisibleOn(dates.visibleOn); + setHiddenOn(dates.hiddenOn); + }; + + const handleHiddenOnChange = (val: string) => { + const dates = adjustDatesForHiddenOnChange(mode, visibleOn, val); + + setVisibleOn(dates.visibleOn); + setHiddenOn(dates.hiddenOn); + }; + + const handleVisibilityApply = () => { + setVisibilityOpened(false); + onVisibilityApply(getVisibilityValues(mode, visibleOn, hiddenOn)); + }; + + const confirmDelete = () => { + modals.openConfirmModal({ + title: "Delete assignments", + children: ( + + Delete {selectedCount} selected {selectedCount === 1 ? "assignment" : "assignments"}? This + can't be undone. + + ), + labels: { confirm: "Delete", cancel: "Cancel" }, + confirmProps: { color: "red" }, + onConfirm: onDelete + }); + }; + + return ( + + + {selectedCount} selected + + + + + + + + + + Set visibility for {selectedCount} selected + + + + + + + + + + + + onEnforceDueApply(false)}>Allow late submissions + onEnforceDueApply(true)}> + Don't allow late submissions + + + + + + + + ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx index 35e578df1..54660a747 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityDropdown.tsx @@ -1,17 +1,14 @@ +import { Icon, PrimeIconName } from "@components/ui/Icon"; import { Group, Popover, Radio, Stack, Text, UnstyledButton } from "@mantine/core"; import { useState } from "react"; -import { Icon, PrimeIconName } from "@components/ui/Icon"; - import { Assignment } from "@/types/assignment"; import { convertDateToISO, parseUTCDate } from "@/utils/date"; -import { getVisibilityMode, getVisibilityValues, VisibilityMode } from "../edit/visibilityMode"; - import { DateTimePicker } from "../../../../ui/DateTimePicker"; +import { getVisibilityMode, getVisibilityValues, VisibilityMode } from "../edit/visibilityMode"; import { getVisibilityStatus, VisibilityChip } from "./VisibilityStatusBadge"; - import styles from "./VisibilityStatusBadge.module.css"; interface VisibilityDropdownProps { @@ -87,11 +84,13 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP if ((newMode === "scheduled_visible" || newMode === "scheduled_period") && !newVisibleOn) { const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); newVisibleOn = convertDateToISO(startOfDay); } if ((newMode === "scheduled_hidden" || newMode === "scheduled_period") && !newHiddenOn) { const endOfDay = new Date(); + endOfDay.setHours(23, 59, 0, 0); newHiddenOn = convertDateToISO(endOfDay); } @@ -108,6 +107,7 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP if (mode === "scheduled_period" && newHiddenOn) { const newVisibleDate = parseUTCDate(val); const currentHiddenDate = parseUTCDate(newHiddenOn); + if (newVisibleDate >= currentHiddenDate) { newHiddenOn = convertDateToISO(new Date(newVisibleDate.getTime() + DAY_MS)); setHiddenOn(newHiddenOn); @@ -123,6 +123,7 @@ export const VisibilityDropdown = ({ assignment, onChange }: VisibilityDropdownP if (mode === "scheduled_period" && newVisibleOn) { const newHiddenDate = parseUTCDate(val); const currentVisibleDate = parseUTCDate(newVisibleOn); + if (newHiddenDate <= currentVisibleDate) { newVisibleOn = convertDateToISO(new Date(newHiddenDate.getTime() - DAY_MS)); setVisibleOn(newVisibleOn); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityModeFields.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityModeFields.tsx new file mode 100644 index 000000000..3bb226bd1 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/VisibilityModeFields.tsx @@ -0,0 +1,121 @@ +import { Group, Radio, Stack, Text } from "@mantine/core"; + +import { Icon, PrimeIconName } from "@components/ui/Icon"; + +import { VisibilityMode } from "../edit/visibilityMode"; + +import { DateTimePicker } from "@components/ui/DateTimePicker"; + +interface VisibilityModeFieldsProps { + mode: VisibilityMode; + visibleOn: string | null; + hiddenOn: string | null; + onModeChange: (mode: VisibilityMode) => void; + onVisibleOnChange: (value: string) => void; + onHiddenOnChange: (value: string) => void; +} + +interface RadioOption { + value: VisibilityMode; + label: string; + icon: PrimeIconName; + color: string; +} + +const RADIO_OPTIONS: RadioOption[] = [ + { value: "hidden", label: "Hidden", icon: "eye-slash", color: "var(--rs-text-muted)" }, + { value: "visible", label: "Visible", icon: "eye", color: "var(--rs-success-text)" }, + { + value: "scheduled_visible", + label: "Visible on…", + icon: "clock", + color: "var(--rs-info-text)" + }, + { + value: "scheduled_hidden", + label: "Hidden on…", + icon: "calendar-times", + color: "var(--rs-info-text)" + }, + { + value: "scheduled_period", + label: "Visible during period", + icon: "calendar", + color: "var(--rs-info-text)" + } +]; + +const radioLabel = (option: RadioOption) => ( + + + {option.label} + +); + +export const VisibilityModeFields = ({ + mode, + visibleOn, + hiddenOn, + onModeChange, + onVisibleOnChange, + onHiddenOnChange +}: VisibilityModeFieldsProps) => ( + onModeChange(value as VisibilityMode)}> + + + + + + {mode === "scheduled_visible" && ( + + + + )} + + + {mode === "scheduled_hidden" && ( + + + + )} + + + {mode === "scheduled_period" && ( + +
+ + From: + + +
+
+ + Until: + + +
+
+ )} +
+
+); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignment/assignment.logic.api.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignment/assignment.logic.api.spec.ts index d1b39fe6a..c212e4e10 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignment/assignment.logic.api.spec.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignment/assignment.logic.api.spec.ts @@ -6,11 +6,16 @@ import { useUpdateAssignmentMutation, useCreateAssignmentMutation, useRemoveAssignmentMutation, - useDuplicateAssignmentMutation + useDuplicateAssignmentMutation, + useBulkUpdateAssignmentsMutation, + useBulkRemoveAssignmentsMutation } from "./assignment.logic.api"; import type { Assignment } from "@/types/assignment"; import type { DetailResponse } from "@/types/api"; import type { GetAssignmentsResponse, GetAssignmentResponse } from "@/types/assignment"; +import { notify } from "@/components/ui/notify"; +import { baseQuery } from "../baseQuery"; +import { configureStore } from "@reduxjs/toolkit"; vi.mock("@components/ui/notify", () => ({ notify: { @@ -297,3 +302,94 @@ describe("duplicateAssignment query builder", () => { expect(result.url).toBe("/assignment/instructor/assignments/15/duplicate"); }); }); + +describe("bulk mutations", () => { + const makeStore = () => + configureStore({ + reducer: { [assignmentApi.reducerPath]: assignmentApi.reducer }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(assignmentApi.middleware) + }); + + const mockedBaseQuery = vi.mocked(baseQuery); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("exports the bulk hooks", () => { + expect(typeof useBulkUpdateAssignmentsMutation).toBe("function"); + expect(typeof useBulkRemoveAssignmentsMutation).toBe("function"); + expect(assignmentApi.endpoints.bulkUpdateAssignments).toBeDefined(); + expect(assignmentApi.endpoints.bulkRemoveAssignments).toBeDefined(); + }); + + it("bulkUpdateAssignments PUTs each assignment and counts successes and failures", async () => { + mockedBaseQuery + .mockResolvedValueOnce({ data: {} }) + .mockResolvedValueOnce({ error: { status: 500, data: {} } }); + + const store = makeStore(); + const result = await store.dispatch( + assignmentApi.endpoints.bulkUpdateAssignments.initiate([ + makeAssignment({ id: 1 }), + makeAssignment({ id: 2 }) + ]) + ); + + expect(mockedBaseQuery).toHaveBeenCalledTimes(2); + expect(mockedBaseQuery.mock.calls[0][0]).toMatchObject({ + method: "PUT", + url: "/assignment/instructor/assignments/1" + }); + expect(mockedBaseQuery.mock.calls[1][0]).toMatchObject({ + method: "PUT", + url: "/assignment/instructor/assignments/2" + }); + expect("data" in result && result.data).toEqual({ succeeded: 1, failed: 1 }); + }); + + it("bulkRemoveAssignments DELETEs each assignment and toasts a summary", async () => { + mockedBaseQuery.mockResolvedValue({ data: {} }); + + const store = makeStore(); + const result = await store.dispatch( + assignmentApi.endpoints.bulkRemoveAssignments.initiate([ + makeAssignment({ id: 3 }), + makeAssignment({ id: 4 }) + ]) + ); + + expect(mockedBaseQuery).toHaveBeenCalledTimes(2); + expect(mockedBaseQuery.mock.calls[0][0]).toMatchObject({ + method: "DELETE", + url: "/assignment/instructor/assignments/3" + }); + expect("data" in result && result.data).toEqual({ succeeded: 2, failed: 0 }); + expect(notify.success).toHaveBeenCalledWith("Deleted 2 assignments"); + expect(notify.error).not.toHaveBeenCalled(); + }); + + it("bulkRemoveAssignments toasts the error copy for failed deletions", async () => { + mockedBaseQuery.mockResolvedValue({ error: { status: 500, data: {} } }); + + const store = makeStore(); + const result = await store.dispatch( + assignmentApi.endpoints.bulkRemoveAssignments.initiate([makeAssignment({ id: 5 })]) + ); + + expect("data" in result && result.data).toEqual({ succeeded: 0, failed: 1 }); + expect(notify.success).not.toHaveBeenCalled(); + expect(notify.error).toHaveBeenCalledWith("Couldn't delete 1 assignment. Try again."); + }); + + it("phrases the bulk delete copy with singular and plural subjects", () => { + expect(ASSIGNMENT_TOAST_COPY.bulkDeleted(1)).toBe("Deleted 1 assignment"); + expect(ASSIGNMENT_TOAST_COPY.bulkDeleted(3)).toBe("Deleted 3 assignments"); + expect(ASSIGNMENT_TOAST_COPY.bulkDeleteError(1)).toBe( + "Couldn't delete 1 assignment. Try again." + ); + expect(ASSIGNMENT_TOAST_COPY.bulkDeleteError(2)).toBe( + "Couldn't delete 2 assignments. Try again." + ); + }); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignment/assignment.logic.api.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignment/assignment.logic.api.ts index 7de945b8c..3faead3ef 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignment/assignment.logic.api.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/assignment/assignment.logic.api.ts @@ -20,6 +20,11 @@ import { SharedAssignmentPreview } from "@/types/assignmentSharing"; +export interface BulkActionResult { + succeeded: number; + failed: number; +} + export const ASSIGNMENT_TOAST_COPY = { loadAssignmentsError: "Couldn't load assignments. Refresh the page.", loadAssignmentError: "Couldn't load the assignment. Refresh the page.", @@ -67,7 +72,10 @@ export const ASSIGNMENT_TOAST_COPY = { parts.push(`${result.duedate_not_shifted} due dates not adjusted`); } return `${parts.join(", ")}. Imports are hidden until you make them visible.`; - } + }, + bulkDeleted: (count: number) => `Deleted ${count} ${count === 1 ? "assignment" : "assignments"}`, + bulkDeleteError: (count: number) => + `Couldn't delete ${count} ${count === 1 ? "assignment" : "assignments"}. Try again.` } as const; export const assignmentApi = createApi({ @@ -303,6 +311,63 @@ export const assignmentApi = createApi({ notify.error(ASSIGNMENT_TOAST_COPY.importError); }); } + }), + bulkUpdateAssignments: build.mutation({ + queryFn: async (assignments, _api, _extraOptions, fetchWithBQ) => { + const results = await Promise.all( + assignments.map((assignment) => + fetchWithBQ({ + method: "PUT", + url: `/assignment/instructor/assignments/${assignment.id}`, + body: assignment + }) + ) + ); + const failed = results.filter((result) => result.error).length; + + return { data: { succeeded: assignments.length - failed, failed } }; + }, + invalidatesTags: (result) => { + if (result && result.succeeded > 0) { + return [{ type: "Assignments" }, { type: "Assignment" }]; + } + return []; + } + }), + bulkRemoveAssignments: build.mutation({ + queryFn: async (assignments, _api, _extraOptions, fetchWithBQ) => { + const results = await Promise.all( + assignments.map((assignment) => + fetchWithBQ({ + method: "DELETE", + url: `/assignment/instructor/assignments/${assignment.id}` + }) + ) + ); + const failed = results.filter((result) => result.error).length; + + return { data: { succeeded: assignments.length - failed, failed } }; + }, + invalidatesTags: (result) => { + if (result && result.succeeded > 0) { + return [{ type: "Assignments" }]; + } + return []; + }, + onQueryStarted: (_, { queryFulfilled }) => { + queryFulfilled + .then(({ data }) => { + if (data.succeeded > 0) { + notify.success(ASSIGNMENT_TOAST_COPY.bulkDeleted(data.succeeded)); + } + if (data.failed > 0) { + notify.error(ASSIGNMENT_TOAST_COPY.bulkDeleteError(data.failed)); + } + }) + .catch(() => { + notify.error(ASSIGNMENT_TOAST_COPY.deleteError); + }); + } }) }) }); @@ -317,5 +382,7 @@ export const { useShareableTreeQuery, usePreviewSharedAssignmentQuery, useImportAssignmentMutation, - useImportCourseAssignmentsMutation + useImportCourseAssignmentsMutation, + useBulkUpdateAssignmentsMutation, + useBulkRemoveAssignmentsMutation } = assignmentApi; From 55bcd4ead4ff206a1d6f2eb91bee72c85d610f8b Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Wed, 16 Sep 2026 09:32:28 +0200 Subject: [PATCH 2/2] f-1193 Add bulk edit actions to the assignment list --- .../assignmentMutationHandlers.spec.ts | 2 ++ .../components/list/AssignmentList.spec.tsx | 17 ++++++++++++++++- .../components/list/AssignmentList.tsx | 10 +++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.spec.ts index b1de8cf19..7e9d39d79 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.spec.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/assignmentMutationHandlers.spec.ts @@ -20,6 +20,8 @@ import { vi.mock("@components/ui/notify", () => ({ notify: { show: vi.fn(), + success: vi.fn(), + error: vi.fn(), info: vi.fn(), update: vi.fn(), hide: vi.fn(), diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.spec.tsx index 2c048e2b4..b6e04da72 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.spec.tsx @@ -108,7 +108,6 @@ describe("AssignmentList", () => { const bravoRow = screen.getByRole("button", { name: "Bravo" }).closest("tr")!; const cells = bravoRow.querySelectorAll("td"); - fireEvent.click(cells[1]); fireEvent.click(cells[2]); fireEvent.click(cells[3]); fireEvent.click(cells[4]); @@ -228,6 +227,22 @@ describe("AssignmentList", () => { expect(screen.getByText("3 selected")).toBeInTheDocument(); }); + it("clears selected assignments when the filter changes", () => { + const props = baseProps(); + const { rerender } = renderWithMantine(); + + fireEvent.click(screen.getByRole("checkbox", { name: "Select Alpha" })); + expect(screen.getByText("1 selected")).toBeInTheDocument(); + + rerender(); + + expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).not.toBeInTheDocument(); + + rerender(); + + expect(screen.getByRole("checkbox", { name: "Select Alpha" })).not.toBeChecked(); + }); + it("clears the selection from the bulk actions bar", () => { renderWithMantine(); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx index 7389558bf..9e85ea879 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx @@ -14,7 +14,7 @@ import { import { modals } from "@mantine/modals"; import { ColumnDef, OnChangeFn, RowSelectionState, SortingState } from "@tanstack/react-table"; import classNames from "classnames"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Assignment } from "@/types/assignment"; import { formatUTCDateForDisplay } from "@/utils/date"; @@ -133,12 +133,16 @@ export const AssignmentList = ({ }, [assignments, globalFilter]); const selectedAssignments = useMemo( - () => assignments.filter((assignment) => rowSelection[String(assignment.id)]), - [assignments, rowSelection] + () => filteredAssignments.filter((assignment) => rowSelection[String(assignment.id)]), + [filteredAssignments, rowSelection] ); const clearSelection = useCallback(() => setRowSelection({}), []); + useEffect(() => { + clearSelection(); + }, [globalFilter, clearSelection]); + const handleBulkVisibilityApply = useCallback( (values: VisibilityValues) => { onBulkVisibilityChange(selectedAssignments, values);